mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-05 04:08:02 +02:00
Compare commits
290 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 637504db41 | |||
| 48e499eaeb | |||
| 7372a34d25 | |||
| 4411d80a19 | |||
| 316d7677c7 | |||
| fa061fc587 | |||
| 38605080b7 | |||
| 478179169c | |||
| 83594831a9 | |||
| cec3acfff6 | |||
| 18ef5e0aee | |||
| f674eef681 | |||
| 1b95085977 | |||
| 35ab00a7bd | |||
| f2ec276b91 | |||
| ee797756f7 | |||
| 2d54ac1d12 | |||
| 87f624c685 | |||
| 48ec563aa1 | |||
| 070e0cd8cf | |||
| 948d7aa735 | |||
| 1aaa033dc1 | |||
| 56a7ec0763 | |||
| 7da5f69551 | |||
| ace70de9e1 | |||
| e7369bb4a9 | |||
| cd6598a866 | |||
| 93dc95ccc4 | |||
| 951518ba81 | |||
| e3449ded60 | |||
| 913db0c97d | |||
| f675c1f223 | |||
| 2d8ee8b04f | |||
| ef1f1b381f | |||
| e2dce6c623 | |||
| 1da8228f89 | |||
| 67df645ca0 | |||
| 258166c973 | |||
| 780aa8494b | |||
| 0a539bde70 | |||
| 5232af5a36 | |||
| 01b4c257ff | |||
| 914c179a1c | |||
| 6d3bea874c | |||
| 10a3fed592 | |||
| 9245b7fe5d | |||
| bca72234be | |||
| d3d77688bf | |||
| a1fb0f1db7 | |||
| 2f58426385 | |||
| f495ce4340 | |||
| cace5993d2 | |||
| d0da28209e | |||
| ea30ac3eb9 | |||
| 1ff9963209 | |||
| 1e00024ca2 | |||
| e685bef532 | |||
| 4b2d61ef2d | |||
| d79d739200 | |||
| 08281b9302 | |||
| 95b85b9ad4 | |||
| d1ff6b6311 | |||
| fe159efc5e | |||
| 92b83fc7ba | |||
| f828e21b39 | |||
| 581b394d46 | |||
| 7f120f3a7e | |||
| 7c4714db36 | |||
| 7c3f8e6297 | |||
| cb416fffd4 | |||
| a46644abd3 | |||
| 660cca6fc4 | |||
| ef9715f54a | |||
| b38132d3b7 | |||
| 1b00569cb2 | |||
| 4e2539167a | |||
| dff7d33461 | |||
| ec228788ca | |||
| 83b6ce7648 | |||
| 7f669680cd | |||
| 1e2e201eff | |||
| b2fcfe5f18 | |||
| 9d9c3ff1e8 | |||
| 071d096314 | |||
| 983971ec83 | |||
| 2adcffd95f | |||
| bd3734a68c | |||
| 0a0eefaf3f | |||
| 2b65d5aedd | |||
| 77f5fc68c8 | |||
| fd79bde4ab | |||
| a99b0230f4 | |||
| 81e41e2f6c | |||
| 97ff250465 | |||
| f8700ee017 | |||
| d7a009cade | |||
| a2d8feebb3 | |||
| e6f9b4c01d | |||
| 9682f30fd6 | |||
| 5c85cb5575 | |||
| 4bc93381d4 | |||
| a41c62548a | |||
| fd028b6d6c | |||
| 01dd2d52c3 | |||
| 3f777eb1cb | |||
| ebfb5150e7 | |||
| aed56e7717 | |||
| 7f4f69620b | |||
| 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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,6 +6,23 @@
|
|||||||
<img alt="SpotiFLAC Mobile" src="assets/images/banner-readme-light.png" width="650" height="auto">
|
<img alt="SpotiFLAC Mobile" src="assets/images/banner-readme-light.png" width="650" height="auto">
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://trendshift.io/repositories/17247">
|
||||||
|
<img src="https://trendshift.io/api/badge/repositories/17247" alt="zarzet%2FSpotiFLAC-Mobile | Trendshift" width="250" height="55">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
|
[](https://www.virustotal.com/gui/file/cc11355330c76f97548b8d26452b91746db9d9c1edbcfc4c18250133484d1487)
|
||||||
|
[](https://crowdin.com/project/spotiflac-mobile)
|
||||||
|
|
||||||
|
[](https://t.me/spotiflac)
|
||||||
|
[](https://t.me/spotiflac_chat)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
@@ -17,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>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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) | |
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> If SpotiFLAC is useful to you, consider supporting development:
|
||||||
|
>
|
||||||
|
> [](https://ko-fi.com/zarzet)
|
||||||
|
|
||||||
> [!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 ~
|
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import org.json.JSONObject
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
|
import java.security.MessageDigest
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
class MainActivity: FlutterFragmentActivity() {
|
class MainActivity: FlutterFragmentActivity() {
|
||||||
@@ -38,7 +39,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
"com.zarz.spotiflac/download_progress_stream"
|
"com.zarz.spotiflac/download_progress_stream"
|
||||||
private val LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL =
|
private val LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL =
|
||||||
"com.zarz.spotiflac/library_scan_progress_stream"
|
"com.zarz.spotiflac/library_scan_progress_stream"
|
||||||
private val STREAM_POLLING_INTERVAL_MS = 800L
|
private val STREAM_POLLING_INTERVAL_MS = 1200L
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
private var pendingSafTreeResult: MethodChannel.Result? = null
|
private var pendingSafTreeResult: MethodChannel.Result? = null
|
||||||
private val safScanLock = Any()
|
private val safScanLock = Any()
|
||||||
@@ -111,6 +112,13 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun buildStableLibraryId(filePath: String): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-1")
|
||||||
|
val bytes = digest.digest(filePath.toByteArray(Charsets.UTF_8))
|
||||||
|
val hex = bytes.joinToString("") { "%02x".format(it) }
|
||||||
|
return "lib_$hex"
|
||||||
|
}
|
||||||
|
|
||||||
data class SafScanProgress(
|
data class SafScanProgress(
|
||||||
var totalFiles: Int = 0,
|
var totalFiles: Int = 0,
|
||||||
var scannedFiles: Int = 0,
|
var scannedFiles: Int = 0,
|
||||||
@@ -469,6 +477,32 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
lastLibraryScanProgressPayload = null
|
lastLibraryScanProgressPayload = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun loadExistingFilesJsonFromSnapshot(snapshotPath: String): String {
|
||||||
|
if (snapshotPath.isBlank()) {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
|
||||||
|
val snapshotFile = File(snapshotPath)
|
||||||
|
if (!snapshotFile.exists()) {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = JSONObject()
|
||||||
|
snapshotFile.forEachLine { line ->
|
||||||
|
if (line.isBlank()) return@forEachLine
|
||||||
|
val separatorIndex = line.indexOf('\t')
|
||||||
|
if (separatorIndex <= 0 || separatorIndex >= line.length - 1) {
|
||||||
|
return@forEachLine
|
||||||
|
}
|
||||||
|
val modTime = line.substring(0, separatorIndex).toLongOrNull() ?: 0L
|
||||||
|
val filePath = line.substring(separatorIndex + 1)
|
||||||
|
if (filePath.isNotEmpty()) {
|
||||||
|
result.put(filePath, modTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toString()
|
||||||
|
}
|
||||||
|
|
||||||
private fun resolveSafFile(treeUriStr: String, relativeDir: String, fileName: String): String {
|
private fun resolveSafFile(treeUriStr: String, relativeDir: String, fileName: String): String {
|
||||||
val obj = JSONObject()
|
val obj = JSONObject()
|
||||||
if (treeUriStr.isBlank() || fileName.isBlank()) {
|
if (treeUriStr.isBlank() || fileName.isBlank()) {
|
||||||
@@ -703,6 +737,80 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun buildUriDisplayName(
|
||||||
|
uri: Uri,
|
||||||
|
displayNameHint: String? = null,
|
||||||
|
fallbackExt: String? = null,
|
||||||
|
): String {
|
||||||
|
val explicitName = displayNameHint?.trim().orEmpty()
|
||||||
|
if (explicitName.isNotEmpty()) return explicitName
|
||||||
|
|
||||||
|
val docName = try { DocumentFile.fromSingleUri(this, uri)?.name } catch (_: Exception) { null }
|
||||||
|
val uriName = uri.lastPathSegment
|
||||||
|
val resolvedName = (docName ?: uriName ?: "").trim()
|
||||||
|
if (resolvedName.isNotEmpty()) return resolvedName
|
||||||
|
|
||||||
|
val ext = when {
|
||||||
|
fallbackExt.isNullOrBlank().not() -> fallbackExt
|
||||||
|
isMediaStoreUri(uri) -> resolveMediaStoreExt(uri, fallbackExt)
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
return if (ext.isNullOrBlank()) "audio" else "audio$ext"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readAudioMetadataFromUri(
|
||||||
|
uri: Uri,
|
||||||
|
displayNameHint: String? = null,
|
||||||
|
fallbackExt: String? = null,
|
||||||
|
): JSONObject? {
|
||||||
|
val displayName = buildUriDisplayName(uri, displayNameHint, fallbackExt)
|
||||||
|
|
||||||
|
try {
|
||||||
|
contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
|
||||||
|
val directPath = "/proc/self/fd/${pfd.fd}"
|
||||||
|
val metadataJson = Gobackend.readAudioMetadataWithHintJSON(directPath, displayName)
|
||||||
|
if (metadataJson.isNotBlank()) {
|
||||||
|
val obj = JSONObject(metadataJson)
|
||||||
|
if (!obj.has("error")) {
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.d(
|
||||||
|
"SpotiFLAC",
|
||||||
|
"Direct SAF metadata read fallback for $uri: ${e.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val tempPath = try {
|
||||||
|
copyUriToTemp(uri, fallbackExt)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w(
|
||||||
|
"SpotiFLAC",
|
||||||
|
"SAF metadata fallback copy failed for $uri: ${e.message}",
|
||||||
|
)
|
||||||
|
null
|
||||||
|
} ?: return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
val metadataJson = Gobackend.readAudioMetadataWithHintJSON(tempPath, displayName)
|
||||||
|
if (metadataJson.isBlank()) return null
|
||||||
|
val obj = JSONObject(metadataJson)
|
||||||
|
return if (obj.has("error")) null else obj
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w(
|
||||||
|
"SpotiFLAC",
|
||||||
|
"SAF metadata temp read failed for $uri: ${e.message}",
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
File(tempPath).delete()
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun writeUriFromPath(uri: Uri, srcPath: String): Boolean {
|
private fun writeUriFromPath(uri: Uri, srcPath: String): Boolean {
|
||||||
val srcFile = File(srcPath)
|
val srcFile = File(srcPath)
|
||||||
if (!srcFile.exists()) return false
|
if (!srcFile.exists()) return false
|
||||||
@@ -873,6 +981,66 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val cueSiblingAudioExtensions = listOf(
|
||||||
|
".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun getSafChildFileLookup(
|
||||||
|
dir: DocumentFile,
|
||||||
|
cache: MutableMap<String, Map<String, DocumentFile>>,
|
||||||
|
): Map<String, DocumentFile> {
|
||||||
|
val dirKey = dir.uri.toString()
|
||||||
|
return cache.getOrPut(dirKey) {
|
||||||
|
try {
|
||||||
|
buildMap {
|
||||||
|
for (child in dir.listFiles()) {
|
||||||
|
if (!child.isFile) continue
|
||||||
|
val childName = child.name?.trim().orEmpty()
|
||||||
|
if (childName.isBlank()) continue
|
||||||
|
put(childName.lowercase(Locale.ROOT), child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w(
|
||||||
|
"SpotiFLAC",
|
||||||
|
"Failed to build SAF child lookup for $dirKey: ${e.message}",
|
||||||
|
)
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveCueAudioSibling(
|
||||||
|
parentDir: DocumentFile,
|
||||||
|
cueName: String,
|
||||||
|
audioFileName: String?,
|
||||||
|
childLookupCache: MutableMap<String, Map<String, DocumentFile>>,
|
||||||
|
): DocumentFile? {
|
||||||
|
val childLookup = getSafChildFileLookup(parentDir, childLookupCache)
|
||||||
|
|
||||||
|
val directMatch = audioFileName
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
?.substringAfterLast("/")
|
||||||
|
?.substringAfterLast("\\")
|
||||||
|
?.lowercase(Locale.ROOT)
|
||||||
|
?.let(childLookup::get)
|
||||||
|
if (directMatch != null) {
|
||||||
|
return directMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
val cueBaseName = cueName.substringBeforeLast('.').trim()
|
||||||
|
if (cueBaseName.isBlank()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val cueBaseKey = cueBaseName.lowercase(Locale.ROOT)
|
||||||
|
for (ext in cueSiblingAudioExtensions) {
|
||||||
|
childLookup["$cueBaseKey$ext"]?.let { return it }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
private fun scanSafTree(treeUriStr: String): String {
|
private fun scanSafTree(treeUriStr: String): String {
|
||||||
if (treeUriStr.isBlank()) return "[]"
|
if (treeUriStr.isBlank()) return "[]"
|
||||||
|
|
||||||
@@ -891,6 +1059,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
// CUE files: (cueDoc, parentDir) — we need the parent to find sibling audio
|
// CUE files: (cueDoc, parentDir) — we need the parent to find sibling audio
|
||||||
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
||||||
val visitedDirUris = mutableSetOf<String>()
|
val visitedDirUris = mutableSetOf<String>()
|
||||||
|
val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>()
|
||||||
var traversalErrors = 0
|
var traversalErrors = 0
|
||||||
|
|
||||||
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
|
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
|
||||||
@@ -987,7 +1156,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
var tempCuePath: String? = null
|
var tempCuePath: String? = null
|
||||||
var tempAudioPath: String? = null
|
var tempAudioPath: String? = null
|
||||||
try {
|
try {
|
||||||
// Copy CUE to temp
|
|
||||||
tempCuePath = copyUriToTemp(cueDoc.uri, ".cue")
|
tempCuePath = copyUriToTemp(cueDoc.uri, ".cue")
|
||||||
if (tempCuePath == null) {
|
if (tempCuePath == null) {
|
||||||
errors++
|
errors++
|
||||||
@@ -996,27 +1164,14 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the audio filename from the CUE sheet text
|
|
||||||
val audioFileName = extractCueAudioFileName(tempCuePath)
|
val audioFileName = extractCueAudioFileName(tempCuePath)
|
||||||
|
|
||||||
// Find the referenced audio file as a sibling in the same SAF directory
|
val audioDoc = resolveCueAudioSibling(
|
||||||
var audioDoc: DocumentFile? = null
|
parentDir = parentDir,
|
||||||
if (!audioFileName.isNullOrBlank()) {
|
cueName = cueName,
|
||||||
audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null }
|
audioFileName = audioFileName,
|
||||||
}
|
childLookupCache = safChildLookupCache,
|
||||||
|
)
|
||||||
// Fallback: try common audio extensions with the CUE base name
|
|
||||||
if (audioDoc == null) {
|
|
||||||
val cueBaseName = cueName.substringBeforeLast('.')
|
|
||||||
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a")
|
|
||||||
for (ext in commonExts) {
|
|
||||||
audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null }
|
|
||||||
if (audioDoc != null) break
|
|
||||||
// Try uppercase
|
|
||||||
audioDoc = try { parentDir.findFile(cueBaseName + ext.uppercase(Locale.ROOT)) } catch (_: Exception) { null }
|
|
||||||
if (audioDoc != null) break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (audioDoc == null) {
|
if (audioDoc == null) {
|
||||||
android.util.Log.w("SpotiFLAC", "SAF scan: no audio file found for CUE $cueName")
|
android.util.Log.w("SpotiFLAC", "SAF scan: no audio file found for CUE $cueName")
|
||||||
@@ -1052,7 +1207,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
|
|
||||||
val cueLastModified = try { cueDoc.lastModified() } catch (_: Exception) { 0L }
|
val cueLastModified = try { cueDoc.lastModified() } catch (_: Exception) { 0L }
|
||||||
|
|
||||||
// Call Go to produce library scan entries for each CUE track
|
|
||||||
val cueResultsJson = Gobackend.scanCueSheetForLibrary(
|
val cueResultsJson = Gobackend.scanCueSheetForLibrary(
|
||||||
tempCuePath,
|
tempCuePath,
|
||||||
tempDir,
|
tempDir,
|
||||||
@@ -1111,35 +1265,19 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
|
|
||||||
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||||
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
|
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
|
||||||
val tempPath = try {
|
val metadataObj = readAudioMetadataFromUri(doc.uri, name, fallbackExt)
|
||||||
copyUriToTemp(doc.uri, fallbackExt)
|
if (metadataObj == null) {
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.w(
|
|
||||||
"SpotiFLAC",
|
|
||||||
"SAF scan: failed to copy ${doc.uri}: ${e.message}",
|
|
||||||
)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
if (tempPath == null) {
|
|
||||||
errors++
|
errors++
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
val metadataJson = Gobackend.readAudioMetadataJSON(tempPath)
|
|
||||||
if (metadataJson.isNotBlank()) {
|
|
||||||
val obj = JSONObject(metadataJson)
|
|
||||||
val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
|
val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
|
||||||
obj.put("filePath", doc.uri.toString())
|
val stableUri = doc.uri.toString()
|
||||||
obj.put("fileModTime", lastModified)
|
metadataObj.put("id", buildStableLibraryId(stableUri))
|
||||||
results.put(obj)
|
metadataObj.put("filePath", stableUri)
|
||||||
} else {
|
metadataObj.put("fileModTime", lastModified)
|
||||||
errors++
|
results.put(metadataObj)
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
errors++
|
errors++
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
File(tempPath).delete()
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1214,6 +1352,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
||||||
val currentUris = mutableSetOf<String>()
|
val currentUris = mutableSetOf<String>()
|
||||||
val visitedDirUris = mutableSetOf<String>()
|
val visitedDirUris = mutableSetOf<String>()
|
||||||
|
val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>()
|
||||||
var traversalErrors = 0
|
var traversalErrors = 0
|
||||||
|
|
||||||
// Build a map of CUE base URIs -> existing virtual track URIs from the database.
|
// Build a map of CUE base URIs -> existing virtual track URIs from the database.
|
||||||
@@ -1398,22 +1537,12 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
val audioFileName = extractCueAudioFileName(tempCuePath)
|
val audioFileName = extractCueAudioFileName(tempCuePath)
|
||||||
|
|
||||||
// Find the referenced audio file as a sibling in the same SAF directory
|
// Find the referenced audio file as a sibling in the same SAF directory
|
||||||
var audioDoc: DocumentFile? = null
|
val audioDoc = resolveCueAudioSibling(
|
||||||
if (!audioFileName.isNullOrBlank()) {
|
parentDir = parentDir,
|
||||||
audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null }
|
cueName = cueName,
|
||||||
}
|
audioFileName = audioFileName,
|
||||||
|
childLookupCache = safChildLookupCache,
|
||||||
// Fallback: try common audio extensions with the CUE base name
|
)
|
||||||
if (audioDoc == null) {
|
|
||||||
val cueBaseName = cueName.substringBeforeLast('.')
|
|
||||||
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a")
|
|
||||||
for (ext in commonExts) {
|
|
||||||
audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null }
|
|
||||||
if (audioDoc != null) break
|
|
||||||
audioDoc = try { parentDir.findFile(cueBaseName + ext.uppercase(Locale.ROOT)) } catch (_: Exception) { null }
|
|
||||||
if (audioDoc != null) break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (audioDoc == null) {
|
if (audioDoc == null) {
|
||||||
android.util.Log.w("SpotiFLAC", "SAF incremental scan: no audio file found for CUE $cueName")
|
android.util.Log.w("SpotiFLAC", "SAF incremental scan: no audio file found for CUE $cueName")
|
||||||
@@ -1501,24 +1630,13 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
tempCue = copyUriToTemp(cueDoc.uri, ".cue")
|
tempCue = copyUriToTemp(cueDoc.uri, ".cue")
|
||||||
if (tempCue != null) {
|
if (tempCue != null) {
|
||||||
val audioFileName = extractCueAudioFileName(tempCue)
|
val audioFileName = extractCueAudioFileName(tempCue)
|
||||||
var audioDoc: DocumentFile? = null
|
|
||||||
if (!audioFileName.isNullOrBlank()) {
|
|
||||||
audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
// Fallback: try common extensions with CUE base name
|
|
||||||
if (audioDoc == null) {
|
|
||||||
val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" }
|
val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" }
|
||||||
val cueBaseName = cueName.substringBeforeLast('.')
|
val audioDoc = resolveCueAudioSibling(
|
||||||
if (cueBaseName.isNotBlank()) {
|
parentDir = parentDir,
|
||||||
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a")
|
cueName = cueName,
|
||||||
for (ext in commonExts) {
|
audioFileName = audioFileName,
|
||||||
audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null }
|
childLookupCache = safChildLookupCache,
|
||||||
if (audioDoc != null) break
|
)
|
||||||
audioDoc = try { parentDir.findFile(cueBaseName + ext.uppercase(Locale.ROOT)) } catch (_: Exception) { null }
|
|
||||||
if (audioDoc != null) break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (audioDoc != null) {
|
if (audioDoc != null) {
|
||||||
cueReferencedAudioUris.add(audioDoc.uri.toString())
|
cueReferencedAudioUris.add(audioDoc.uri.toString())
|
||||||
}
|
}
|
||||||
@@ -1566,36 +1684,20 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
|
|
||||||
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||||
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
|
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
|
||||||
val tempPath = try {
|
val metadataObj = readAudioMetadataFromUri(doc.uri, name, fallbackExt)
|
||||||
copyUriToTemp(doc.uri, fallbackExt)
|
if (metadataObj == null) {
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.w(
|
|
||||||
"SpotiFLAC",
|
|
||||||
"SAF incremental scan: failed to copy ${doc.uri}: ${e.message}",
|
|
||||||
)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
if (tempPath == null) {
|
|
||||||
errors++
|
errors++
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
val metadataJson = Gobackend.readAudioMetadataJSON(tempPath)
|
|
||||||
if (metadataJson.isNotBlank()) {
|
|
||||||
val obj = JSONObject(metadataJson)
|
|
||||||
val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified }
|
val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified }
|
||||||
obj.put("filePath", doc.uri.toString())
|
val stableUri = doc.uri.toString()
|
||||||
obj.put("fileModTime", safeLastModified)
|
metadataObj.put("id", buildStableLibraryId(stableUri))
|
||||||
obj.put("lastModified", safeLastModified)
|
metadataObj.put("filePath", stableUri)
|
||||||
results.put(obj)
|
metadataObj.put("fileModTime", safeLastModified)
|
||||||
} else {
|
metadataObj.put("lastModified", safeLastModified)
|
||||||
errors++
|
results.put(metadataObj)
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
errors++
|
errors++
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
File(tempPath).delete()
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2540,6 +2642,28 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
// Tidal search API
|
||||||
|
"searchTidalAll" -> {
|
||||||
|
val query = call.argument<String>("query") ?: ""
|
||||||
|
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||||
|
val artistLimit = call.argument<Int>("artist_limit") ?: 2
|
||||||
|
val filter = call.argument<String>("filter") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.searchTidalAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
// Qobuz search API
|
||||||
|
"searchQobuzAll" -> {
|
||||||
|
val query = call.argument<String>("query") ?: ""
|
||||||
|
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||||
|
val artistLimit = call.argument<Int>("artist_limit") ?: 2
|
||||||
|
val filter = call.argument<String>("filter") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.searchQobuzAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
"getDeezerRelatedArtists" -> {
|
"getDeezerRelatedArtists" -> {
|
||||||
val artistId = call.argument<String>("artist_id") ?: ""
|
val artistId = call.argument<String>("artist_id") ?: ""
|
||||||
val limit = call.argument<Int>("limit") ?: 12
|
val limit = call.argument<Int>("limit") ?: 12
|
||||||
@@ -2556,6 +2680,22 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
"getQobuzMetadata" -> {
|
||||||
|
val resourceType = call.argument<String>("resource_type") ?: ""
|
||||||
|
val resourceId = call.argument<String>("resource_id") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getQobuzMetadata(resourceType, resourceId)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"getTidalMetadata" -> {
|
||||||
|
val resourceType = call.argument<String>("resource_type") ?: ""
|
||||||
|
val resourceId = call.argument<String>("resource_id") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getTidalMetadata(resourceType, resourceId)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
"parseDeezerUrl" -> {
|
"parseDeezerUrl" -> {
|
||||||
val url = call.argument<String>("url") ?: ""
|
val url = call.argument<String>("url") ?: ""
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
@@ -2563,6 +2703,13 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
"parseQobuzUrl" -> {
|
||||||
|
val url = call.argument<String>("url") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.parseQobuzURLExport(url)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
"parseTidalUrl" -> {
|
"parseTidalUrl" -> {
|
||||||
val url = call.argument<String>("url") ?: ""
|
val url = call.argument<String>("url") ?: ""
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
@@ -2791,6 +2938,15 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
"searchTracksWithMetadataProviders" -> {
|
||||||
|
val query = call.argument<String>("query") ?: ""
|
||||||
|
val limit = call.argument<Int>("limit") ?: 20
|
||||||
|
val includeExtensions = call.argument<Boolean>("include_extensions") ?: true
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.searchTracksWithMetadataProvidersJSON(query, limit.toLong(), includeExtensions)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
"enrichTrackWithExtension" -> {
|
"enrichTrackWithExtension" -> {
|
||||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
val trackJson = call.argument<String>("track") ?: "{}"
|
val trackJson = call.argument<String>("track") ?: "{}"
|
||||||
@@ -2996,6 +3152,25 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
"setStoreRegistryUrl" -> {
|
||||||
|
val registryUrl = call.argument<String>("registry_url") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.setStoreRegistryURLJSON(registryUrl)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"getStoreRegistryUrl" -> {
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getStoreRegistryURLJSON()
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"clearStoreRegistryUrl" -> {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.clearStoreRegistryURLJSON()
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
"getStoreExtensions" -> {
|
"getStoreExtensions" -> {
|
||||||
val forceRefresh = call.argument<Boolean>("force_refresh") ?: false
|
val forceRefresh = call.argument<Boolean>("force_refresh") ?: false
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
@@ -3071,6 +3246,18 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
"scanLibraryFolderIncrementalFromSnapshot" -> {
|
||||||
|
val folderPath = call.argument<String>("folder_path") ?: ""
|
||||||
|
val snapshotPath = call.argument<String>("snapshot_path") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
safScanActive = false
|
||||||
|
Gobackend.scanLibraryFolderIncrementalFromSnapshotJSON(
|
||||||
|
folderPath,
|
||||||
|
snapshotPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
"scanSafTree" -> {
|
"scanSafTree" -> {
|
||||||
val treeUri = call.argument<String>("tree_uri") ?: ""
|
val treeUri = call.argument<String>("tree_uri") ?: ""
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
@@ -3086,6 +3273,16 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
"scanSafTreeIncrementalFromSnapshot" -> {
|
||||||
|
val treeUri = call.argument<String>("tree_uri") ?: ""
|
||||||
|
val snapshotPath = call.argument<String>("snapshot_path") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
val existingFilesJson =
|
||||||
|
loadExistingFilesJsonFromSnapshot(snapshotPath)
|
||||||
|
scanSafTreeIncremental(treeUri, existingFilesJson)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
"getSafFileModTimes" -> {
|
"getSafFileModTimes" -> {
|
||||||
val uris = call.argument<String>("uris") ?: "[]"
|
val uris = call.argument<String>("uris") ?: "[]"
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
@@ -3116,13 +3313,10 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
try {
|
try {
|
||||||
if (filePath.startsWith("content://")) {
|
if (filePath.startsWith("content://")) {
|
||||||
val uri = Uri.parse(filePath)
|
val uri = Uri.parse(filePath)
|
||||||
val tempPath = copyUriToTemp(uri)
|
val metadata = readAudioMetadataFromUri(uri)
|
||||||
?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
|
?: return@withContext """{"error":"Failed to read SAF audio metadata"}"""
|
||||||
try {
|
metadata.put("filePath", filePath)
|
||||||
Gobackend.readAudioMetadataJSON(tempPath)
|
metadata.toString()
|
||||||
} finally {
|
|
||||||
try { File(tempPath).delete() } catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Gobackend.readAudioMetadataJSON(filePath)
|
Gobackend.readAudioMetadataJSON(filePath)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "SpotiFLAC Source",
|
||||||
|
"identifier": "com.zarzet.spotiflac.source",
|
||||||
|
"subtitle": "FLAC Downloader for iOS",
|
||||||
|
"apps": [
|
||||||
|
{
|
||||||
|
"name": "SpotiFLAC",
|
||||||
|
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||||
|
"developerName": "zarzet",
|
||||||
|
"version": "3.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)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1566,7 +1566,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 +1594,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 +1614,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 +1634,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,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")
|
||||||
|
|||||||
+27
-14
@@ -114,7 +114,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 +124,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,7 +134,6 @@ 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
|
// Extract filename and type
|
||||||
@@ -148,7 +145,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// TRACK
|
|
||||||
if strings.HasPrefix(upper, "TRACK ") {
|
if strings.HasPrefix(upper, "TRACK ") {
|
||||||
// Save previous track
|
// Save previous track
|
||||||
if currentTrack != nil {
|
if currentTrack != nil {
|
||||||
@@ -168,7 +164,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,7 +179,6 @@ 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
|
||||||
@@ -430,7 +424,15 @@ func ParseCueFileJSON(cuePath string, audioDir string) (string, error) {
|
|||||||
// entries, one per track. This is used by the library scanner to populate the
|
// 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 +443,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 +554,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"`
|
||||||
@@ -1086,6 +1087,7 @@ 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) {
|
||||||
@@ -1118,6 +1120,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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); 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 != "" {
|
||||||
|
resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
|
||||||
|
if resolvedID != "" {
|
||||||
|
if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil {
|
||||||
|
GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr)
|
||||||
|
// Fall through to ISRC search instead of using wrong track.
|
||||||
|
} else {
|
||||||
|
return availability.DeezerURL, nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
return availability.DeezerURL, nil
|
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); verifyErr != nil {
|
||||||
|
GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr)
|
||||||
|
return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("https://www.deezer.com/track/%s", resolvedID), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,6 +252,26 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
|||||||
return "", fmt.Errorf("could not resolve Deezer track URL")
|
return "", fmt.Errorf("could not resolve Deezer track URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func verifyDeezerTrack(req DownloadRequest, deezerID string) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
||||||
|
defer cancel()
|
||||||
|
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil // Can't verify — don't block the download.
|
||||||
|
}
|
||||||
|
resolved := resolvedTrackInfo{
|
||||||
|
Title: trackResp.Track.Name,
|
||||||
|
ArtistName: trackResp.Track.Artists,
|
||||||
|
Duration: trackResp.Track.DurationMS / 1000,
|
||||||
|
}
|
||||||
|
if !trackMatchesRequest(req, resolved, "Deezer") {
|
||||||
|
return fmt.Errorf("expected '%s - %s', got '%s - %s'",
|
||||||
|
req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
|
||||||
|
}
|
||||||
|
GoLog("[Deezer] Track %s verified: '%s - %s' ✓\n", deezerID, resolved.ArtistName, resolved.Title)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type deezerMusicDLRequest struct {
|
type deezerMusicDLRequest struct {
|
||||||
Platform string `json:"platform"`
|
Platform string `json:"platform"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
@@ -394,11 +433,6 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
spotifyURL, err := resolveSpotifyURLForYoinkify(req)
|
|
||||||
if err != nil {
|
|
||||||
return DeezerDownloadResult{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
"artist": req.ArtistName,
|
"artist": req.ArtistName,
|
||||||
@@ -461,6 +495,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)
|
||||||
|
|||||||
+370
-72
@@ -128,6 +128,7 @@ type DownloadResult struct {
|
|||||||
TrackNumber int
|
TrackNumber int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
ISRC string
|
ISRC string
|
||||||
|
CoverURL string
|
||||||
Genre string
|
Genre string
|
||||||
Label string
|
Label string
|
||||||
Copyright string
|
Copyright string
|
||||||
@@ -135,6 +136,36 @@ type DownloadResult struct {
|
|||||||
DecryptionKey string
|
DecryptionKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func preferredReleaseMetadata(
|
||||||
|
req DownloadRequest,
|
||||||
|
album string,
|
||||||
|
releaseDate string,
|
||||||
|
trackNumber int,
|
||||||
|
discNumber int,
|
||||||
|
) (string, string, int, int) {
|
||||||
|
preferredAlbum := strings.TrimSpace(req.AlbumName)
|
||||||
|
if preferredAlbum == "" {
|
||||||
|
preferredAlbum = album
|
||||||
|
}
|
||||||
|
|
||||||
|
preferredReleaseDate := strings.TrimSpace(req.ReleaseDate)
|
||||||
|
if preferredReleaseDate == "" {
|
||||||
|
preferredReleaseDate = releaseDate
|
||||||
|
}
|
||||||
|
|
||||||
|
preferredTrackNumber := req.TrackNumber
|
||||||
|
if preferredTrackNumber == 0 {
|
||||||
|
preferredTrackNumber = trackNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
preferredDiscNumber := req.DiscNumber
|
||||||
|
if preferredDiscNumber == 0 {
|
||||||
|
preferredDiscNumber = discNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
return preferredAlbum, preferredReleaseDate, preferredTrackNumber, preferredDiscNumber
|
||||||
|
}
|
||||||
|
|
||||||
func buildDownloadSuccessResponse(
|
func buildDownloadSuccessResponse(
|
||||||
req DownloadRequest,
|
req DownloadRequest,
|
||||||
result DownloadResult,
|
result DownloadResult,
|
||||||
@@ -153,25 +184,16 @@ func buildDownloadSuccessResponse(
|
|||||||
artist = req.ArtistName
|
artist = req.ArtistName
|
||||||
}
|
}
|
||||||
|
|
||||||
album := result.Album
|
// Preserve requested release metadata when available so mixed-provider
|
||||||
if album == "" {
|
// fallback downloads from the same source album do not get split into
|
||||||
album = req.AlbumName
|
// different albums just because Tidal/Qobuz report variant titles/dates.
|
||||||
}
|
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
|
||||||
|
req,
|
||||||
releaseDate := result.ReleaseDate
|
result.Album,
|
||||||
if releaseDate == "" {
|
result.ReleaseDate,
|
||||||
releaseDate = req.ReleaseDate
|
result.TrackNumber,
|
||||||
}
|
result.DiscNumber,
|
||||||
|
)
|
||||||
trackNumber := result.TrackNumber
|
|
||||||
if trackNumber == 0 {
|
|
||||||
trackNumber = req.TrackNumber
|
|
||||||
}
|
|
||||||
|
|
||||||
discNumber := result.DiscNumber
|
|
||||||
if discNumber == 0 {
|
|
||||||
discNumber = req.DiscNumber
|
|
||||||
}
|
|
||||||
|
|
||||||
isrc := result.ISRC
|
isrc := result.ISRC
|
||||||
if isrc == "" {
|
if isrc == "" {
|
||||||
@@ -193,6 +215,11 @@ func buildDownloadSuccessResponse(
|
|||||||
copyright = req.Copyright
|
copyright = req.Copyright
|
||||||
}
|
}
|
||||||
|
|
||||||
|
coverURL := strings.TrimSpace(result.CoverURL)
|
||||||
|
if coverURL == "" {
|
||||||
|
coverURL = strings.TrimSpace(req.CoverURL)
|
||||||
|
}
|
||||||
|
|
||||||
return DownloadResponse{
|
return DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: message,
|
Message: message,
|
||||||
@@ -209,7 +236,7 @@ func buildDownloadSuccessResponse(
|
|||||||
TrackNumber: trackNumber,
|
TrackNumber: trackNumber,
|
||||||
DiscNumber: discNumber,
|
DiscNumber: discNumber,
|
||||||
ISRC: isrc,
|
ISRC: isrc,
|
||||||
CoverURL: req.CoverURL,
|
CoverURL: coverURL,
|
||||||
Genre: genre,
|
Genre: genre,
|
||||||
Label: label,
|
Label: label,
|
||||||
Copyright: copyright,
|
Copyright: copyright,
|
||||||
@@ -262,7 +289,7 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.ISRC == "" || (req.Genre != "" && req.Label != "") {
|
if req.ISRC == "" || (req.Genre != "" && req.Label != "" && req.Copyright != "") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,8 +311,11 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) {
|
|||||||
if req.Label == "" && extMeta.Label != "" {
|
if req.Label == "" && extMeta.Label != "" {
|
||||||
req.Label = extMeta.Label
|
req.Label = extMeta.Label
|
||||||
}
|
}
|
||||||
if req.Genre != "" || req.Label != "" {
|
if req.Copyright == "" && extMeta.Copyright != "" {
|
||||||
GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s\n", req.Genre, req.Label)
|
req.Copyright = extMeta.Copyright
|
||||||
|
}
|
||||||
|
if req.Genre != "" || req.Label != "" || req.Copyright != "" {
|
||||||
|
GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,6 +384,7 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
TrackNumber: qobuzResult.TrackNumber,
|
TrackNumber: qobuzResult.TrackNumber,
|
||||||
DiscNumber: qobuzResult.DiscNumber,
|
DiscNumber: qobuzResult.DiscNumber,
|
||||||
ISRC: qobuzResult.ISRC,
|
ISRC: qobuzResult.ISRC,
|
||||||
|
CoverURL: qobuzResult.CoverURL,
|
||||||
LyricsLRC: qobuzResult.LyricsLRC,
|
LyricsLRC: qobuzResult.LyricsLRC,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -562,6 +593,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
TrackNumber: qobuzResult.TrackNumber,
|
TrackNumber: qobuzResult.TrackNumber,
|
||||||
DiscNumber: qobuzResult.DiscNumber,
|
DiscNumber: qobuzResult.DiscNumber,
|
||||||
ISRC: qobuzResult.ISRC,
|
ISRC: qobuzResult.ISRC,
|
||||||
|
CoverURL: qobuzResult.CoverURL,
|
||||||
LyricsLRC: qobuzResult.LyricsLRC,
|
LyricsLRC: qobuzResult.LyricsLRC,
|
||||||
}
|
}
|
||||||
} else if !errors.Is(qobuzErr, ErrDownloadCancelled) {
|
} else if !errors.Is(qobuzErr, ErrDownloadCancelled) {
|
||||||
@@ -715,6 +747,26 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if isM4A {
|
} else if isM4A {
|
||||||
|
meta, err := ReadM4ATags(filePath)
|
||||||
|
if err == nil && meta != nil {
|
||||||
|
result["title"] = meta.Title
|
||||||
|
result["artist"] = meta.Artist
|
||||||
|
result["album"] = meta.Album
|
||||||
|
result["album_artist"] = meta.AlbumArtist
|
||||||
|
result["date"] = meta.Date
|
||||||
|
if meta.Date == "" {
|
||||||
|
result["date"] = meta.Year
|
||||||
|
}
|
||||||
|
result["track_number"] = meta.TrackNumber
|
||||||
|
result["disc_number"] = meta.DiscNumber
|
||||||
|
result["isrc"] = meta.ISRC
|
||||||
|
result["lyrics"] = meta.Lyrics
|
||||||
|
result["genre"] = meta.Genre
|
||||||
|
result["label"] = meta.Label
|
||||||
|
result["copyright"] = meta.Copyright
|
||||||
|
result["composer"] = meta.Composer
|
||||||
|
result["comment"] = meta.Comment
|
||||||
|
}
|
||||||
quality, qualityErr := GetM4AQuality(filePath)
|
quality, qualityErr := GetM4AQuality(filePath)
|
||||||
if qualityErr == nil {
|
if qualityErr == nil {
|
||||||
result["bit_depth"] = quality.BitDepth
|
result["bit_depth"] = quality.BitDepth
|
||||||
@@ -1103,6 +1155,36 @@ func SearchDeezerAll(query string, trackLimit, artistLimit int, filter string) (
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SearchTidalAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
|
||||||
|
downloader := NewTidalDownloader()
|
||||||
|
results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(results)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SearchQobuzAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
|
||||||
|
downloader := NewQobuzDownloader()
|
||||||
|
results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(results)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
func GetDeezerRelatedArtists(artistID string, limit int) (string, error) {
|
func GetDeezerRelatedArtists(artistID string, limit int) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -1156,6 +1238,66 @@ func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetQobuzMetadata(resourceType, resourceID string) (string, error) {
|
||||||
|
downloader := NewQobuzDownloader()
|
||||||
|
|
||||||
|
var data interface{}
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch resourceType {
|
||||||
|
case "track":
|
||||||
|
data, err = downloader.GetTrackMetadata(resourceID)
|
||||||
|
case "album":
|
||||||
|
data, err = downloader.GetAlbumMetadata(resourceID)
|
||||||
|
case "artist":
|
||||||
|
data, err = downloader.GetArtistMetadata(resourceID)
|
||||||
|
case "playlist":
|
||||||
|
data, err = downloader.GetPlaylistMetadata(resourceID)
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported Qobuz resource type: %s", resourceType)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTidalMetadata(resourceType, resourceID string) (string, error) {
|
||||||
|
downloader := NewTidalDownloader()
|
||||||
|
|
||||||
|
var data interface{}
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch resourceType {
|
||||||
|
case "track":
|
||||||
|
data, err = downloader.GetTrackMetadata(resourceID)
|
||||||
|
case "album":
|
||||||
|
data, err = downloader.GetAlbumMetadata(resourceID)
|
||||||
|
case "artist":
|
||||||
|
data, err = downloader.GetArtistMetadata(resourceID)
|
||||||
|
case "playlist":
|
||||||
|
data, err = downloader.GetPlaylistMetadata(resourceID)
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported Tidal resource type: %s", resourceType)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
func ParseDeezerURLExport(url string) (string, error) {
|
func ParseDeezerURLExport(url string) (string, error) {
|
||||||
resourceType, resourceID, err := parseDeezerURL(url)
|
resourceType, resourceID, err := parseDeezerURL(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1175,6 +1317,25 @@ func ParseDeezerURLExport(url string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ParseQobuzURLExport(url string) (string, error) {
|
||||||
|
resourceType, resourceID, err := parseQobuzURL(url)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]string{
|
||||||
|
"type": resourceType,
|
||||||
|
"id": resourceID,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
func ParseTidalURLExport(url string) (string, error) {
|
func ParseTidalURLExport(url string) (string, error) {
|
||||||
resourceType, resourceID, err := parseTidalURL(url)
|
resourceType, resourceID, err := parseTidalURL(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1235,10 +1396,7 @@ func GetDeezerExtendedMetadata(trackID string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
result := map[string]string{
|
result := buildDeezerExtendedMetadataResult(metadata)
|
||||||
"genre": metadata.Genre,
|
|
||||||
"label": metadata.Label,
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(result)
|
jsonBytes, err := json.Marshal(result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1258,7 +1416,8 @@ func SearchDeezerByISRC(isrc string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(track)
|
result := buildDeezerISRCSearchResult(track)
|
||||||
|
jsonBytes, err := json.Marshal(result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -1266,6 +1425,55 @@ func SearchDeezerByISRC(isrc string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildDeezerExtendedMetadataResult(metadata *AlbumExtendedMetadata) map[string]string {
|
||||||
|
if metadata == nil {
|
||||||
|
return map[string]string{
|
||||||
|
"genre": "",
|
||||||
|
"label": "",
|
||||||
|
"copyright": "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]string{
|
||||||
|
"genre": metadata.Genre,
|
||||||
|
"label": metadata.Label,
|
||||||
|
"copyright": metadata.Copyright,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDeezerISRCSearchResult(track *TrackMetadata) map[string]interface{} {
|
||||||
|
if track == nil {
|
||||||
|
return map[string]interface{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"spotify_id": track.SpotifyID,
|
||||||
|
"artists": track.Artists,
|
||||||
|
"name": track.Name,
|
||||||
|
"album_name": track.AlbumName,
|
||||||
|
"album_artist": track.AlbumArtist,
|
||||||
|
"duration_ms": track.DurationMS,
|
||||||
|
"images": track.Images,
|
||||||
|
"release_date": track.ReleaseDate,
|
||||||
|
"track_number": track.TrackNumber,
|
||||||
|
"total_tracks": track.TotalTracks,
|
||||||
|
"disc_number": track.DiscNumber,
|
||||||
|
"external_urls": track.ExternalURL,
|
||||||
|
"isrc": track.ISRC,
|
||||||
|
"album_id": track.AlbumID,
|
||||||
|
"artist_id": track.ArtistID,
|
||||||
|
"album_type": track.AlbumType,
|
||||||
|
}
|
||||||
|
|
||||||
|
if deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:")); deezerID != "" {
|
||||||
|
result["id"] = deezerID
|
||||||
|
result["track_id"] = deezerID
|
||||||
|
result["success"] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -1311,7 +1519,6 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// For artists/playlists, SongLink doesn't provide direct mapping
|
|
||||||
return "", fmt.Errorf("Spotify to Deezer conversion only supported for tracks and albums. Please search by name for %s", resourceType)
|
return "", fmt.Errorf("Spotify to Deezer conversion only supported for tracks and albums. Please search by name for %s", resourceType)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1545,6 +1752,8 @@ func ExtractCoverToFile(audioPath string, outputPath string) error {
|
|||||||
|
|
||||||
if strings.HasSuffix(lower, ".flac") {
|
if strings.HasSuffix(lower, ".flac") {
|
||||||
coverData, err = ExtractCoverArt(audioPath)
|
coverData, err = ExtractCoverArt(audioPath)
|
||||||
|
} else if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") {
|
||||||
|
coverData, err = extractCoverFromM4A(audioPath)
|
||||||
} else if strings.HasSuffix(lower, ".mp3") {
|
} else if strings.HasSuffix(lower, ".mp3") {
|
||||||
coverData, _, err = extractMP3CoverArt(audioPath)
|
coverData, _, err = extractMP3CoverArt(audioPath)
|
||||||
} else if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
|
} else if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
|
||||||
@@ -1675,52 +1884,28 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
|
|
||||||
GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath)
|
GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath)
|
||||||
|
|
||||||
// When search_online is true, search for metadata from internet.
|
// When search_online is true, search for metadata from internet using the
|
||||||
// Priority: 1) Deezer (reliable, no credentials) 2) Extension providers (spotify-web etc)
|
// configured metadata-provider priority.
|
||||||
if req.SearchOnline && req.TrackName != "" && req.ArtistName != "" {
|
if req.SearchOnline && req.TrackName != "" && req.ArtistName != "" {
|
||||||
GoLog("[ReEnrich] Searching online metadata for: %s - %s\n", req.TrackName, req.ArtistName)
|
GoLog("[ReEnrich] Searching online metadata for: %s - %s\n", req.TrackName, req.ArtistName)
|
||||||
searchQuery := req.TrackName + " " + req.ArtistName
|
searchQuery := req.TrackName + " " + req.ArtistName
|
||||||
found := false
|
found := false
|
||||||
|
|
||||||
// 1) Try Deezer first (reliable, no credentials needed)
|
|
||||||
GoLog("[ReEnrich] Trying Deezer search...\n")
|
|
||||||
deezerClient := GetDeezerClient()
|
deezerClient := GetDeezerClient()
|
||||||
{
|
GoLog("[ReEnrich] Trying metadata providers in configured priority...\n")
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
||||||
deezerResults, err := deezerClient.SearchAll(ctx, searchQuery, 5, 0, "track")
|
|
||||||
cancel()
|
|
||||||
if err == nil && len(deezerResults.Tracks) > 0 {
|
|
||||||
track := deezerResults.Tracks[0]
|
|
||||||
GoLog("[ReEnrich] Deezer match: %s - %s (album: %s)\n", track.Name, track.Artists, track.AlbumName)
|
|
||||||
req.SpotifyID = "deezer:" + track.SpotifyID
|
|
||||||
req.AlbumName = track.AlbumName
|
|
||||||
req.AlbumArtist = track.AlbumArtist
|
|
||||||
req.TrackNumber = track.TrackNumber
|
|
||||||
req.DiscNumber = track.DiscNumber
|
|
||||||
req.ReleaseDate = track.ReleaseDate
|
|
||||||
req.ISRC = track.ISRC
|
|
||||||
if track.Images != "" {
|
|
||||||
req.CoverURL = track.Images
|
|
||||||
}
|
|
||||||
req.DurationMs = int64(track.DurationMS)
|
|
||||||
found = true
|
|
||||||
} else if err != nil {
|
|
||||||
GoLog("[ReEnrich] Deezer search failed: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Try extension metadata providers (spotify-web etc) if Deezer failed
|
|
||||||
if !found {
|
|
||||||
GoLog("[ReEnrich] Trying extension metadata providers...\n")
|
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
extTracks, extErr := manager.SearchTracksWithExtensions(searchQuery, 5)
|
tracks, searchErr := manager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
|
||||||
if extErr == nil && len(extTracks) > 0 {
|
if searchErr == nil && len(tracks) > 0 {
|
||||||
track := extTracks[0]
|
track := tracks[0]
|
||||||
GoLog("[ReEnrich] Extension match (%s): %s - %s (album: %s)\n", track.ProviderID, track.Name, track.Artists, track.AlbumName)
|
GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s)\n", track.ProviderID, track.Name, track.Artists, track.AlbumName)
|
||||||
if track.SpotifyID != "" {
|
if track.SpotifyID != "" {
|
||||||
req.SpotifyID = track.SpotifyID
|
req.SpotifyID = track.SpotifyID
|
||||||
} else if track.DeezerID != "" {
|
} else if track.DeezerID != "" {
|
||||||
req.SpotifyID = "deezer:" + track.DeezerID
|
req.SpotifyID = "deezer:" + track.DeezerID
|
||||||
|
} else if track.QobuzID != "" {
|
||||||
|
req.SpotifyID = "qobuz:" + track.QobuzID
|
||||||
|
} else if track.TidalID != "" {
|
||||||
|
req.SpotifyID = "tidal:" + track.TidalID
|
||||||
} else {
|
} else {
|
||||||
req.SpotifyID = track.ID
|
req.SpotifyID = track.ID
|
||||||
}
|
}
|
||||||
@@ -1745,13 +1930,12 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
req.Copyright = track.Copyright
|
req.Copyright = track.Copyright
|
||||||
}
|
}
|
||||||
found = true
|
found = true
|
||||||
} else if extErr != nil {
|
} else if searchErr != nil {
|
||||||
GoLog("[ReEnrich] Extension search failed: %v\n", extErr)
|
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to get extended metadata (genre, label) from Deezer if not already set
|
// Try to get extended metadata from Deezer if not already set
|
||||||
if found && req.ISRC != "" && (req.Genre == "" || req.Label == "") {
|
if found && req.ISRC != "" && (req.Genre == "" || req.Label == "" || req.Copyright == "") {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
|
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
|
||||||
cancel()
|
cancel()
|
||||||
@@ -1762,7 +1946,10 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
if req.Label == "" && extMeta.Label != "" {
|
if req.Label == "" && extMeta.Label != "" {
|
||||||
req.Label = extMeta.Label
|
req.Label = extMeta.Label
|
||||||
}
|
}
|
||||||
GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s\n", req.Genre, req.Label)
|
if req.Copyright == "" && extMeta.Copyright != "" {
|
||||||
|
req.Copyright = extMeta.Copyright
|
||||||
|
}
|
||||||
|
GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1831,8 +2018,15 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Fetch lyrics
|
// Preserve existing lyrics when online enrichment does not return a replacement.
|
||||||
var lyricsLRC string
|
var lyricsLRC string
|
||||||
|
existingLyrics, existingLyricsErr := ExtractLyrics(req.FilePath)
|
||||||
|
if existingLyricsErr == nil && strings.TrimSpace(existingLyrics) != "" {
|
||||||
|
lyricsLRC = existingLyrics
|
||||||
|
GoLog("[ReEnrich] Preserving existing embedded/sidecar lyrics\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch lyrics
|
||||||
if req.EmbedLyrics {
|
if req.EmbedLyrics {
|
||||||
client := NewLyricsClient()
|
client := NewLyricsClient()
|
||||||
durationSec := float64(req.DurationMs) / 1000.0
|
durationSec := float64(req.DurationMs) / 1000.0
|
||||||
@@ -1913,7 +2107,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MP3/Opus: return metadata map for Dart to use FFmpeg
|
|
||||||
// Don't cleanup cover temp — Dart needs it for FFmpeg embed
|
// Don't cleanup cover temp — Dart needs it for FFmpeg embed
|
||||||
cleanupCover = false
|
cleanupCover = false
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
@@ -2149,6 +2342,21 @@ func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SearchTracksWithMetadataProvidersJSON(query string, limit int, includeExtensions bool) (string, error) {
|
||||||
|
manager := GetExtensionManager()
|
||||||
|
tracks, err := manager.SearchTracksWithMetadataProviders(query, limit, includeExtensions)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(tracks)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
|
func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
|
||||||
var req DownloadRequest
|
var req DownloadRequest
|
||||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||||
@@ -2534,6 +2742,28 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
artistResponse["albums"] = albums
|
artistResponse["albums"] = albums
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(result.Artist.Releases) > 0 {
|
||||||
|
releases := make([]map[string]interface{}, len(result.Artist.Releases))
|
||||||
|
for i, release := range result.Artist.Releases {
|
||||||
|
releaseType := release.AlbumType
|
||||||
|
if releaseType == "" {
|
||||||
|
releaseType = "album"
|
||||||
|
}
|
||||||
|
releases[i] = map[string]interface{}{
|
||||||
|
"id": release.ID,
|
||||||
|
"name": release.Name,
|
||||||
|
"artists": release.Artists,
|
||||||
|
"images": release.CoverURL,
|
||||||
|
"cover_url": release.CoverURL,
|
||||||
|
"release_date": release.ReleaseDate,
|
||||||
|
"total_tracks": release.TotalTracks,
|
||||||
|
"album_type": releaseType,
|
||||||
|
"provider_id": release.ProviderID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
artistResponse["releases"] = releases
|
||||||
|
}
|
||||||
|
|
||||||
if len(result.Artist.TopTracks) > 0 {
|
if len(result.Artist.TopTracks) > 0 {
|
||||||
topTracks := make([]map[string]interface{}, len(result.Artist.TopTracks))
|
topTracks := make([]map[string]interface{}, len(result.Artist.TopTracks))
|
||||||
for i, track := range result.Artist.TopTracks {
|
for i, track := range result.Artist.TopTracks {
|
||||||
@@ -2783,6 +3013,27 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
|||||||
"provider_id": artist.ProviderID,
|
"provider_id": artist.ProviderID,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(artist.Releases) > 0 {
|
||||||
|
releases := make([]map[string]interface{}, len(artist.Releases))
|
||||||
|
for i, release := range artist.Releases {
|
||||||
|
releaseType := release.AlbumType
|
||||||
|
if releaseType == "" {
|
||||||
|
releaseType = "album"
|
||||||
|
}
|
||||||
|
releases[i] = map[string]interface{}{
|
||||||
|
"id": release.ID,
|
||||||
|
"name": release.Name,
|
||||||
|
"artists": release.Artists,
|
||||||
|
"cover_url": release.CoverURL,
|
||||||
|
"release_date": release.ReleaseDate,
|
||||||
|
"total_tracks": release.TotalTracks,
|
||||||
|
"album_type": releaseType,
|
||||||
|
"provider_id": release.ProviderID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response["releases"] = releases
|
||||||
|
}
|
||||||
|
|
||||||
if artist.HeaderImage != "" {
|
if artist.HeaderImage != "" {
|
||||||
response["header_image"] = artist.HeaderImage
|
response["header_image"] = artist.HeaderImage
|
||||||
}
|
}
|
||||||
@@ -2930,6 +3181,45 @@ func InitExtensionStoreJSON(cacheDir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SetStoreRegistryURLJSON(registryURL string) error {
|
||||||
|
store := GetExtensionStore()
|
||||||
|
if store == nil {
|
||||||
|
return fmt.Errorf("extension store not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := ResolveRegistryURL(registryURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := requireHTTPSURL(resolved, "registry"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
store.SetRegistryURL(resolved)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClearStoreRegistryURLJSON() error {
|
||||||
|
store := GetExtensionStore()
|
||||||
|
if store == nil {
|
||||||
|
return fmt.Errorf("extension store not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
store.SetRegistryURL("")
|
||||||
|
store.ClearCache()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetStoreRegistryURLJSON() (string, error) {
|
||||||
|
store := GetExtensionStore()
|
||||||
|
if store == nil {
|
||||||
|
return "", fmt.Errorf("extension store not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
return store.GetRegistryURL(), nil
|
||||||
|
}
|
||||||
|
|
||||||
func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
|
func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
|
||||||
store := GetExtensionStore()
|
store := GetExtensionStore()
|
||||||
if store == nil {
|
if store == nil {
|
||||||
@@ -3087,6 +3377,10 @@ func ScanLibraryFolderIncrementalJSON(folderPath, existingFilesJSON string) (str
|
|||||||
return ScanLibraryFolderIncremental(folderPath, existingFilesJSON)
|
return ScanLibraryFolderIncremental(folderPath, existingFilesJSON)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ScanLibraryFolderIncrementalFromSnapshotJSON(folderPath, snapshotPath string) (string, error) {
|
||||||
|
return ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath)
|
||||||
|
}
|
||||||
|
|
||||||
func GetLibraryScanProgressJSON() string {
|
func GetLibraryScanProgressJSON() string {
|
||||||
return GetLibraryScanProgress()
|
return GetLibraryScanProgress()
|
||||||
}
|
}
|
||||||
@@ -3098,3 +3392,7 @@ func CancelLibraryScanJSON() {
|
|||||||
func ReadAudioMetadataJSON(filePath string) (string, error) {
|
func ReadAudioMetadataJSON(filePath string) (string, error) {
|
||||||
return ReadAudioMetadata(filePath)
|
return ReadAudioMetadata(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ReadAudioMetadataWithHintJSON(filePath, displayName string) (string, error) {
|
||||||
|
return ReadAudioMetadataWithDisplayName(filePath, displayName)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestBuildDeezerExtendedMetadataResultHandlesNil(t *testing.T) {
|
||||||
|
result := buildDeezerExtendedMetadataResult(nil)
|
||||||
|
|
||||||
|
if result["genre"] != "" {
|
||||||
|
t.Fatalf("expected empty genre, got %q", result["genre"])
|
||||||
|
}
|
||||||
|
if result["label"] != "" {
|
||||||
|
t.Fatalf("expected empty label, got %q", result["label"])
|
||||||
|
}
|
||||||
|
if result["copyright"] != "" {
|
||||||
|
t.Fatalf("expected empty copyright, got %q", result["copyright"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDeezerExtendedMetadataResultIncludesCopyright(t *testing.T) {
|
||||||
|
result := buildDeezerExtendedMetadataResult(&AlbumExtendedMetadata{
|
||||||
|
Genre: "Rock",
|
||||||
|
Label: "EMI",
|
||||||
|
Copyright: "(C) Queen",
|
||||||
|
})
|
||||||
|
|
||||||
|
if result["genre"] != "Rock" {
|
||||||
|
t.Fatalf("unexpected genre: %q", result["genre"])
|
||||||
|
}
|
||||||
|
if result["label"] != "EMI" {
|
||||||
|
t.Fatalf("unexpected label: %q", result["label"])
|
||||||
|
}
|
||||||
|
if result["copyright"] != "(C) Queen" {
|
||||||
|
t.Fatalf("unexpected copyright: %q", result["copyright"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDeezerISRCSearchResultAddsCompatibilityIDs(t *testing.T) {
|
||||||
|
result := buildDeezerISRCSearchResult(&TrackMetadata{
|
||||||
|
SpotifyID: "deezer:3135556",
|
||||||
|
Name: "Love Of My Life",
|
||||||
|
Artists: "Queen",
|
||||||
|
AlbumName: "A Night at the Opera",
|
||||||
|
ISRC: "GBUM71029604",
|
||||||
|
ReleaseDate: "1975-11-21",
|
||||||
|
})
|
||||||
|
|
||||||
|
if result["spotify_id"] != "deezer:3135556" {
|
||||||
|
t.Fatalf("unexpected spotify_id: %v", result["spotify_id"])
|
||||||
|
}
|
||||||
|
if result["id"] != "3135556" {
|
||||||
|
t.Fatalf("unexpected id: %v", result["id"])
|
||||||
|
}
|
||||||
|
if result["track_id"] != "3135556" {
|
||||||
|
t.Fatalf("unexpected track_id: %v", result["track_id"])
|
||||||
|
}
|
||||||
|
if result["success"] != true {
|
||||||
|
t.Fatalf("expected success=true, got %v", result["success"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
|
||||||
|
req := DownloadRequest{
|
||||||
|
TrackName: "Bonus Track",
|
||||||
|
ArtistName: "Artist",
|
||||||
|
AlbumName: "Album (Deluxe)",
|
||||||
|
AlbumArtist: "Artist",
|
||||||
|
ReleaseDate: "2024-01-01",
|
||||||
|
TrackNumber: 14,
|
||||||
|
DiscNumber: 1,
|
||||||
|
ISRC: "REQ123",
|
||||||
|
CoverURL: "https://example.com/cover.jpg",
|
||||||
|
Genre: "Pop",
|
||||||
|
Label: "Label",
|
||||||
|
Copyright: "Copyright",
|
||||||
|
}
|
||||||
|
|
||||||
|
result := DownloadResult{
|
||||||
|
Title: "Bonus Track",
|
||||||
|
Artist: "Artist",
|
||||||
|
Album: "Album",
|
||||||
|
ReleaseDate: "2023-12-01",
|
||||||
|
TrackNumber: 2,
|
||||||
|
DiscNumber: 9,
|
||||||
|
ISRC: "RES456",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := buildDownloadSuccessResponse(
|
||||||
|
req,
|
||||||
|
result,
|
||||||
|
"tidal",
|
||||||
|
"ok",
|
||||||
|
"/tmp/test.flac",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.Album != req.AlbumName {
|
||||||
|
t.Fatalf("album = %q, want %q", resp.Album, req.AlbumName)
|
||||||
|
}
|
||||||
|
if resp.ReleaseDate != req.ReleaseDate {
|
||||||
|
t.Fatalf("release date = %q, want %q", resp.ReleaseDate, req.ReleaseDate)
|
||||||
|
}
|
||||||
|
if resp.TrackNumber != req.TrackNumber {
|
||||||
|
t.Fatalf("track number = %d, want %d", resp.TrackNumber, req.TrackNumber)
|
||||||
|
}
|
||||||
|
if resp.DiscNumber != req.DiscNumber {
|
||||||
|
t.Fatalf("disc number = %d, want %d", resp.DiscNumber, req.DiscNumber)
|
||||||
|
}
|
||||||
|
if resp.Artist != result.Artist {
|
||||||
|
t.Fatalf("artist = %q, want provider artist %q", resp.Artist, result.Artist)
|
||||||
|
}
|
||||||
|
if resp.ISRC != result.ISRC {
|
||||||
|
t.Fatalf("isrc = %q, want provider isrc %q", resp.ISRC, result.ISRC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreferredReleaseMetadataPrefersRequestValues(t *testing.T) {
|
||||||
|
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
|
||||||
|
DownloadRequest{
|
||||||
|
AlbumName: "Album (Deluxe Edition)",
|
||||||
|
ReleaseDate: "2024-01-01",
|
||||||
|
TrackNumber: 13,
|
||||||
|
DiscNumber: 2,
|
||||||
|
},
|
||||||
|
"Album",
|
||||||
|
"2023-01-01",
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
if album != "Album (Deluxe Edition)" {
|
||||||
|
t.Fatalf("album = %q", album)
|
||||||
|
}
|
||||||
|
if releaseDate != "2024-01-01" {
|
||||||
|
t.Fatalf("release date = %q", releaseDate)
|
||||||
|
}
|
||||||
|
if trackNumber != 13 {
|
||||||
|
t.Fatalf("track number = %d", trackNumber)
|
||||||
|
}
|
||||||
|
if discNumber != 2 {
|
||||||
|
t.Fatalf("disc number = %d", discNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
|
||||||
|
req := DownloadRequest{
|
||||||
|
TrackName: "Track",
|
||||||
|
ArtistName: "Artist",
|
||||||
|
AlbumName: "Album",
|
||||||
|
AlbumArtist: "Artist",
|
||||||
|
}
|
||||||
|
|
||||||
|
result := DownloadResult{
|
||||||
|
Title: "Track",
|
||||||
|
Artist: "Artist",
|
||||||
|
Album: "Album",
|
||||||
|
CoverURL: "https://cdn.qobuz.test/cover.jpg",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := buildDownloadSuccessResponse(
|
||||||
|
req,
|
||||||
|
result,
|
||||||
|
"qobuz",
|
||||||
|
"ok",
|
||||||
|
"/tmp/test.flac",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.CoverURL != result.CoverURL {
|
||||||
|
t.Fatalf("cover url = %q, want %q", resp.CoverURL, result.CoverURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -151,7 +151,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
if exists {
|
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)
|
||||||
@@ -429,7 +428,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 {
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
@@ -327,6 +328,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,7 +491,7 @@ 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 string, onProgress func(percent int)) (*ExtDownloadResult, error) {
|
||||||
if !p.extension.Manifest.IsDownloadProvider() {
|
if !p.extension.Manifest.IsDownloadProvider() {
|
||||||
@@ -600,8 +607,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 +650,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 +676,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 +689,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 +706,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 +723,165 @@ func isBuiltInProvider(providerID string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeBuiltInMetadataTrack(track TrackMetadata, providerID string) ExtTrackMetadata {
|
||||||
|
deezerID := ""
|
||||||
|
tidalID := ""
|
||||||
|
qobuzID := ""
|
||||||
|
prefixedID := strings.TrimSpace(track.SpotifyID)
|
||||||
|
|
||||||
|
switch providerID {
|
||||||
|
case "deezer":
|
||||||
|
deezerID = strings.TrimPrefix(prefixedID, "deezer:")
|
||||||
|
case "tidal":
|
||||||
|
tidalID = strings.TrimPrefix(prefixedID, "tidal:")
|
||||||
|
case "qobuz":
|
||||||
|
qobuzID = strings.TrimPrefix(prefixedID, "qobuz:")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExtTrackMetadata{
|
||||||
|
ID: prefixedID,
|
||||||
|
Name: track.Name,
|
||||||
|
Artists: track.Artists,
|
||||||
|
AlbumName: track.AlbumName,
|
||||||
|
AlbumArtist: track.AlbumArtist,
|
||||||
|
DurationMS: track.DurationMS,
|
||||||
|
CoverURL: track.Images,
|
||||||
|
Images: track.Images,
|
||||||
|
ReleaseDate: track.ReleaseDate,
|
||||||
|
TrackNumber: track.TrackNumber,
|
||||||
|
DiscNumber: track.DiscNumber,
|
||||||
|
ISRC: track.ISRC,
|
||||||
|
ProviderID: providerID,
|
||||||
|
SpotifyID: prefixedID,
|
||||||
|
DeezerID: deezerID,
|
||||||
|
TidalID: tidalID,
|
||||||
|
QobuzID: qobuzID,
|
||||||
|
AlbumType: track.AlbumType,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func metadataTrackDedupKey(track ExtTrackMetadata) string {
|
||||||
|
if isrc := strings.TrimSpace(track.ISRC); isrc != "" {
|
||||||
|
return "isrc:" + strings.ToUpper(isrc)
|
||||||
|
}
|
||||||
|
if spotifyID := strings.TrimSpace(track.SpotifyID); spotifyID != "" {
|
||||||
|
return "spotify:" + spotifyID
|
||||||
|
}
|
||||||
|
if providerID := strings.TrimSpace(track.ProviderID); providerID != "" && strings.TrimSpace(track.ID) != "" {
|
||||||
|
return providerID + ":" + strings.TrimSpace(track.ID)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(track.Name) + "|" + strings.TrimSpace(track.Artists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchBuiltInMetadataTracks(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
|
||||||
|
switch providerID {
|
||||||
|
case "deezer":
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
results, err := GetDeezerClient().SearchAll(ctx, query, limit, 0, "track")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks := make([]ExtTrackMetadata, 0, len(results.Tracks))
|
||||||
|
for _, track := range results.Tracks {
|
||||||
|
tracks = append(tracks, normalizeBuiltInMetadataTrack(track, "deezer"))
|
||||||
|
}
|
||||||
|
return tracks, nil
|
||||||
|
case "qobuz":
|
||||||
|
return NewQobuzDownloader().SearchTracks(query, limit)
|
||||||
|
case "tidal":
|
||||||
|
return NewTidalDownloader().SearchTracks(query, limit)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported built-in metadata provider: %s", providerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManager) SearchTracksWithMetadataProviders(query string, limit int, includeExtensions bool) ([]ExtTrackMetadata, error) {
|
||||||
|
priority := GetMetadataProviderPriority()
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
extensionProviders := make(map[string]*ExtensionProviderWrapper)
|
||||||
|
if includeExtensions {
|
||||||
|
for _, provider := range m.GetMetadataProviders() {
|
||||||
|
extensionProviders[provider.extension.ID] = provider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
orderedProviderIDs := make([]string, 0, len(priority)+len(extensionProviders))
|
||||||
|
seenProviderIDs := make(map[string]struct{}, len(priority)+len(extensionProviders))
|
||||||
|
for _, providerID := range priority {
|
||||||
|
providerID = strings.TrimSpace(providerID)
|
||||||
|
if providerID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
orderedProviderIDs = append(orderedProviderIDs, providerID)
|
||||||
|
seenProviderIDs[providerID] = struct{}{}
|
||||||
|
}
|
||||||
|
if includeExtensions {
|
||||||
|
remainingIDs := make([]string, 0, len(extensionProviders))
|
||||||
|
for providerID := range extensionProviders {
|
||||||
|
if _, exists := seenProviderIDs[providerID]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
remainingIDs = append(remainingIDs, providerID)
|
||||||
|
}
|
||||||
|
sort.Strings(remainingIDs)
|
||||||
|
orderedProviderIDs = append(orderedProviderIDs, remainingIDs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks := make([]ExtTrackMetadata, 0, limit)
|
||||||
|
seenTracks := make(map[string]struct{})
|
||||||
|
for _, providerID := range orderedProviderIDs {
|
||||||
|
var (
|
||||||
|
providerTracks []ExtTrackMetadata
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
if isBuiltInProvider(providerID) {
|
||||||
|
providerTracks, err = searchBuiltInMetadataTracksFunc(providerID, query, limit)
|
||||||
|
} else {
|
||||||
|
if !includeExtensions {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
provider := extensionProviders[providerID]
|
||||||
|
if provider == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var result *ExtSearchResult
|
||||||
|
result, err = provider.SearchTracks(query, limit)
|
||||||
|
if result != nil {
|
||||||
|
providerTracks = result.Tracks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[MetadataSearch] Search error from %s: %v\n", providerID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, track := range providerTracks {
|
||||||
|
key := metadataTrackDedupKey(track)
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seenTracks[key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenTracks[key] = struct{}{}
|
||||||
|
tracks = append(tracks, track)
|
||||||
|
if len(tracks) >= limit {
|
||||||
|
return tracks, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tracks, nil
|
||||||
|
}
|
||||||
|
|
||||||
func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) {
|
func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) {
|
||||||
priority := GetProviderPriority()
|
priority := GetProviderPriority()
|
||||||
extManager := GetExtensionManager()
|
extManager := GetExtensionManager()
|
||||||
@@ -783,6 +977,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 +1015,77 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If key metadata is still missing after extension enrichment, search
|
||||||
|
// configured metadata providers (Spotify/Deezer/Tidal/Qobuz) — same
|
||||||
|
// logic that ReEnrichFile uses.
|
||||||
|
if req.Source != "" && !isBuiltInProvider(strings.ToLower(req.Source)) &&
|
||||||
|
req.TrackName != "" && req.ArtistName != "" &&
|
||||||
|
(req.AlbumName == "" || req.ReleaseDate == "" || req.ISRC == "") {
|
||||||
|
|
||||||
|
searchQuery := req.TrackName + " " + req.ArtistName
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Metadata incomplete, searching providers for: %s\n", searchQuery)
|
||||||
|
|
||||||
|
tracks, searchErr := extManager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
|
||||||
|
if searchErr == nil && len(tracks) > 0 {
|
||||||
|
track := tracks[0]
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Metadata match (%s): %s - %s (album: %s, date: %s, isrc: %s)\n",
|
||||||
|
track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate, track.ISRC)
|
||||||
|
|
||||||
|
if track.AlbumName != "" && req.AlbumName == "" {
|
||||||
|
req.AlbumName = track.AlbumName
|
||||||
|
}
|
||||||
|
if track.AlbumArtist != "" && req.AlbumArtist == "" {
|
||||||
|
req.AlbumArtist = track.AlbumArtist
|
||||||
|
}
|
||||||
|
if track.ReleaseDate != "" && req.ReleaseDate == "" {
|
||||||
|
req.ReleaseDate = track.ReleaseDate
|
||||||
|
}
|
||||||
|
if track.ISRC != "" && req.ISRC == "" {
|
||||||
|
req.ISRC = track.ISRC
|
||||||
|
}
|
||||||
|
if track.TrackNumber > 0 && req.TrackNumber == 0 {
|
||||||
|
req.TrackNumber = track.TrackNumber
|
||||||
|
}
|
||||||
|
if track.DiscNumber > 0 && req.DiscNumber == 0 {
|
||||||
|
req.DiscNumber = track.DiscNumber
|
||||||
|
}
|
||||||
|
if track.CoverURL != "" && req.CoverURL == "" {
|
||||||
|
req.CoverURL = track.CoverURL
|
||||||
|
}
|
||||||
|
if track.Genre != "" && req.Genre == "" {
|
||||||
|
req.Genre = track.Genre
|
||||||
|
}
|
||||||
|
if track.Label != "" && req.Label == "" {
|
||||||
|
req.Label = track.Label
|
||||||
|
}
|
||||||
|
if track.Copyright != "" && req.Copyright == "" {
|
||||||
|
req.Copyright = track.Copyright
|
||||||
|
}
|
||||||
|
} else if searchErr != nil {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Metadata provider search failed (non-fatal): %v\n", searchErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Deezer extended metadata if we have ISRC
|
||||||
|
if req.ISRC != "" &&
|
||||||
|
(req.Genre == "" || req.Label == "" || req.Copyright == "") {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
extMeta, err := GetDeezerClient().GetExtendedMetadataByISRC(ctx, req.ISRC)
|
||||||
|
cancel()
|
||||||
|
if err == nil && extMeta != nil {
|
||||||
|
if req.Genre == "" && extMeta.Genre != "" {
|
||||||
|
req.Genre = extMeta.Genre
|
||||||
|
}
|
||||||
|
if req.Label == "" && extMeta.Label != "" {
|
||||||
|
req.Label = extMeta.Label
|
||||||
|
}
|
||||||
|
if req.Copyright == "" && extMeta.Copyright != "" {
|
||||||
|
req.Copyright = extMeta.Copyright
|
||||||
|
}
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Extended metadata from Deezer: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if req.Source != "" &&
|
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)) {
|
||||||
@@ -896,6 +1179,30 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always pass enriched metadata from req so Flutter can
|
||||||
|
// embed it — fills gaps from metadata provider search.
|
||||||
|
if req.AlbumName != "" && resp.Album == "" {
|
||||||
|
resp.Album = req.AlbumName
|
||||||
|
}
|
||||||
|
if req.AlbumArtist != "" && resp.AlbumArtist == "" {
|
||||||
|
resp.AlbumArtist = req.AlbumArtist
|
||||||
|
}
|
||||||
|
if req.ReleaseDate != "" && resp.ReleaseDate == "" {
|
||||||
|
resp.ReleaseDate = req.ReleaseDate
|
||||||
|
}
|
||||||
|
if req.ISRC != "" && resp.ISRC == "" {
|
||||||
|
resp.ISRC = req.ISRC
|
||||||
|
}
|
||||||
|
if req.TrackNumber > 0 && resp.TrackNumber == 0 {
|
||||||
|
resp.TrackNumber = req.TrackNumber
|
||||||
|
}
|
||||||
|
if req.DiscNumber > 0 && resp.DiscNumber == 0 {
|
||||||
|
resp.DiscNumber = req.DiscNumber
|
||||||
|
}
|
||||||
|
if req.CoverURL != "" && resp.CoverURL == "" {
|
||||||
|
resp.CoverURL = req.CoverURL
|
||||||
|
}
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -946,7 +1253,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 +1269,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)
|
||||||
}
|
}
|
||||||
@@ -1168,6 +1480,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 +1523,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,
|
||||||
@@ -1449,6 +1763,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,6 +85,7 @@ type ExtensionRuntime struct {
|
|||||||
manifest *ExtensionManifest
|
manifest *ExtensionManifest
|
||||||
settings map[string]interface{}
|
settings map[string]interface{}
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
downloadClient *http.Client
|
||||||
cookieJar http.CookieJar
|
cookieJar http.CookieJar
|
||||||
dataDir string
|
dataDir string
|
||||||
vm *goja.Runtime
|
vm *goja.Runtime
|
||||||
@@ -132,13 +133,20 @@ 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 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 +173,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,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -129,7 +130,6 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
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"
|
||||||
)
|
)
|
||||||
@@ -140,7 +140,7 @@ func InitExtensionStore(cacheDir string) *ExtensionStore {
|
|||||||
|
|
||||||
if extensionStore == nil {
|
if extensionStore == nil {
|
||||||
extensionStore = &ExtensionStore{
|
extensionStore = &ExtensionStore{
|
||||||
registryURL: defaultRegistryURL,
|
registryURL: "", // No default - user must provide a registry URL
|
||||||
cacheDir: cacheDir,
|
cacheDir: cacheDir,
|
||||||
cacheTTL: cacheTTL,
|
cacheTTL: cacheTTL,
|
||||||
}
|
}
|
||||||
@@ -149,6 +149,36 @@ func InitExtensionStore(cacheDir string) *ExtensionStore {
|
|||||||
return extensionStore
|
return extensionStore
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetRegistryURL updates the registry URL and clears the in-memory cache
|
||||||
|
// so the next fetch will use the new URL. Disk cache is also cleared.
|
||||||
|
func (s *ExtensionStore) SetRegistryURL(registryURL string) {
|
||||||
|
s.cacheMu.Lock()
|
||||||
|
defer s.cacheMu.Unlock()
|
||||||
|
|
||||||
|
if s.registryURL == registryURL {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.registryURL = registryURL
|
||||||
|
s.cache = nil
|
||||||
|
s.cacheTime = time.Time{}
|
||||||
|
|
||||||
|
// Clear disk cache since it's from a different registry
|
||||||
|
if s.cacheDir != "" {
|
||||||
|
cachePath := filepath.Join(s.cacheDir, cacheFileName)
|
||||||
|
os.Remove(cachePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
LogInfo("ExtensionStore", "Registry URL updated to: %s", registryURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRegistryURL returns the currently configured registry URL.
|
||||||
|
func (s *ExtensionStore) GetRegistryURL() string {
|
||||||
|
s.cacheMu.RLock()
|
||||||
|
defer s.cacheMu.RUnlock()
|
||||||
|
return s.registryURL
|
||||||
|
}
|
||||||
|
|
||||||
func GetExtensionStore() *ExtensionStore {
|
func GetExtensionStore() *ExtensionStore {
|
||||||
extensionStoreMu.Lock()
|
extensionStoreMu.Lock()
|
||||||
defer extensionStoreMu.Unlock()
|
defer extensionStoreMu.Unlock()
|
||||||
@@ -206,6 +236,11 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
|
|||||||
s.cacheMu.Lock()
|
s.cacheMu.Lock()
|
||||||
defer s.cacheMu.Unlock()
|
defer s.cacheMu.Unlock()
|
||||||
|
|
||||||
|
// Check if a registry URL has been configured
|
||||||
|
if s.registryURL == "" {
|
||||||
|
return nil, fmt.Errorf("no registry URL configured. Please add a repository URL first")
|
||||||
|
}
|
||||||
|
|
||||||
if !forceRefresh && s.cache != nil && time.Since(s.cacheTime) < s.cacheTTL {
|
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
|
||||||
@@ -336,6 +371,81 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to match https://github.com/<owner>/<repo>[/...]
|
||||||
|
const ghPrefix = "https://github.com/"
|
||||||
|
if !strings.HasPrefix(input, ghPrefix) {
|
||||||
|
// Also accept http:// and upgrade silently.
|
||||||
|
const ghPrefixHTTP = "http://github.com/"
|
||||||
|
if strings.HasPrefix(input, ghPrefixHTTP) {
|
||||||
|
input = "https://github.com/" + input[len(ghPrefixHTTP):]
|
||||||
|
} else {
|
||||||
|
// Not a GitHub URL – return as-is.
|
||||||
|
return input, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path := input[len(ghPrefix):]
|
||||||
|
parts := strings.SplitN(path, "/", 3) // owner, repo, [rest]
|
||||||
|
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
|
||||||
|
return "", fmt.Errorf("invalid GitHub URL: expected github.com/<owner>/<repo>")
|
||||||
|
}
|
||||||
|
owner := parts[0]
|
||||||
|
repo := strings.TrimSuffix(parts[1], ".git")
|
||||||
|
|
||||||
|
branch := resolveGitHubDefaultBranch(owner, repo)
|
||||||
|
|
||||||
|
resolved := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/registry.json", owner, repo, branch)
|
||||||
|
LogInfo("ExtensionStore", "Resolved %s → %s (branch: %s)", input, resolved, branch)
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveGitHubDefaultBranch calls the GitHub API to discover the repository's
|
||||||
|
// default branch. Falls back to "main" on any error.
|
||||||
|
func resolveGitHubDefaultBranch(owner, repo string) string {
|
||||||
|
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repo)
|
||||||
|
client := NewHTTPClientWithTimeout(10 * time.Second)
|
||||||
|
|
||||||
|
resp, err := client.Get(apiURL)
|
||||||
|
if err != nil {
|
||||||
|
LogWarn("ExtensionStore", "GitHub API request failed for %s/%s: %v – falling back to main", owner, repo, err)
|
||||||
|
return "main"
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
LogWarn("ExtensionStore", "GitHub API returned %d for %s/%s – falling back to main", resp.StatusCode, owner, repo)
|
||||||
|
return "main"
|
||||||
|
}
|
||||||
|
|
||||||
|
var info struct {
|
||||||
|
DefaultBranch string `json:"default_branch"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil || info.DefaultBranch == "" {
|
||||||
|
LogWarn("ExtensionStore", "Could not parse default_branch for %s/%s – falling back to main", owner, repo)
|
||||||
|
return "main"
|
||||||
|
}
|
||||||
|
|
||||||
|
return info.DefaultBranch
|
||||||
|
}
|
||||||
|
|
||||||
func requireHTTPSURL(rawURL string, context string) error {
|
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)
|
||||||
@@ -374,12 +484,10 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
|
|||||||
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) &&
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -346,11 +346,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 +365,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) {
|
||||||
|
|||||||
+187
-63
@@ -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)
|
||||||
@@ -219,7 +241,7 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
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 +267,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 +284,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 +308,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 +345,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 +367,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 +430,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 +462,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 +492,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 +539,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
|
||||||
}
|
}
|
||||||
@@ -490,7 +560,43 @@ func ReadAudioMetadata(filePath string) (string, error) {
|
|||||||
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
|
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
|
||||||
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
|
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
|
||||||
// Only files that are new or have changed modification time will be scanned
|
// Only files that are new or have changed modification time will be scanned
|
||||||
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
|
func loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) {
|
||||||
|
existingFiles := make(map[string]int64)
|
||||||
|
if snapshotPath == "" {
|
||||||
|
return existingFiles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(snapshotPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(line, "\t", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
modTime, err := strconv.ParseInt(parts[0], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
existingFiles[parts[1]] = modTime
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return existingFiles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFiles map[string]int64) (string, error) {
|
||||||
if folderPath == "" {
|
if folderPath == "" {
|
||||||
return "{}", fmt.Errorf("folder path is empty")
|
return "{}", fmt.Errorf("folder path is empty")
|
||||||
}
|
}
|
||||||
@@ -503,13 +609,6 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
return "{}", fmt.Errorf("path is not a folder: %s", folderPath)
|
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()
|
||||||
@@ -541,14 +640,13 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
// Find files to scan (new or modified)
|
// 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,26 +655,13 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
if !exists {
|
if !exists {
|
||||||
// For .cue files, also check if any virtual path entries exist
|
// 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 strings.HasPrefix(existingPath, f.path+"#track") {
|
|
||||||
hasCueTracks = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if hasCueTracks {
|
|
||||||
// CUE file exists in DB via virtual paths; check if modTime changed
|
// 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)
|
if f.modTime == cueTrackModTime {
|
||||||
for existingPath, modTime := range existingFiles {
|
|
||||||
if strings.HasPrefix(existingPath, f.path+"#track") {
|
|
||||||
if f.modTime == modTime {
|
|
||||||
skippedCount++
|
skippedCount++
|
||||||
} else {
|
} else {
|
||||||
filesToScan = append(filesToScan, f)
|
filesToScan = append(filesToScan, f)
|
||||||
}
|
}
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -630,6 +715,7 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
|
|
||||||
// Track audio files referenced by .cue sheets to avoid duplicates (incremental)
|
// 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 +723,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -660,7 +750,20 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
|
|
||||||
// Handle .cue files: produce multiple track results
|
// 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)
|
||||||
@@ -675,7 +778,7 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
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 +812,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
-64
@@ -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"
|
||||||
@@ -121,12 +122,12 @@ func GetLyricsProviderOrder() []string {
|
|||||||
// GetAvailableLyricsProviders returns metadata about all available providers.
|
// 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"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,6 +432,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 +543,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 +556,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 +578,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 {
|
||||||
|
|||||||
+62
-123
@@ -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 {
|
|
||||||
mu sync.Mutex
|
|
||||||
token string
|
|
||||||
}
|
|
||||||
|
|
||||||
var globalAppleTokenManager = &appleTokenManager{}
|
|
||||||
|
|
||||||
func (m *appleTokenManager) getToken(client *http.Client) (string, error) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
if m.token != "" {
|
|
||||||
return m.token, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Fetch the Apple Music beta page
|
|
||||||
req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to fetch Apple Music page: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read Apple Music page: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Find the index JS file URL
|
|
||||||
indexJsRegex := regexp.MustCompile(`/assets/index~[^/]+\.js`)
|
|
||||||
match := indexJsRegex.Find(body)
|
|
||||||
if match == nil {
|
|
||||||
return "", fmt.Errorf("could not find index JS script URL on Apple Music page")
|
|
||||||
}
|
|
||||||
|
|
||||||
indexJsURL := "https://beta.music.apple.com" + string(match)
|
|
||||||
|
|
||||||
// Step 3: Fetch the JS file
|
|
||||||
jsReq, err := http.NewRequest("GET", indexJsURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create JS request: %w", err)
|
|
||||||
}
|
|
||||||
jsReq.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
|
|
||||||
jsResp, err := client.Do(jsReq)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to fetch Apple Music JS: %w", err)
|
|
||||||
}
|
|
||||||
defer jsResp.Body.Close()
|
|
||||||
|
|
||||||
jsBody, err := io.ReadAll(jsResp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read Apple Music JS: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Extract JWT token (starts with eyJh)
|
|
||||||
tokenRegex := regexp.MustCompile(`eyJh[^"]*`)
|
|
||||||
tokenMatch := tokenRegex.Find(jsBody)
|
|
||||||
if tokenMatch == nil {
|
|
||||||
return "", fmt.Errorf("could not find JWT token in Apple Music JS")
|
|
||||||
}
|
|
||||||
|
|
||||||
m.token = string(tokenMatch)
|
|
||||||
GoLog("[AppleMusic] Token obtained successfully (length: %d)\n", len(m.token))
|
|
||||||
return m.token, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *appleTokenManager) clearToken() {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
m.token = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
type appleMusicSearchResponse struct {
|
|
||||||
Results struct {
|
|
||||||
Songs *struct {
|
|
||||||
Data []struct {
|
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"`
|
SongName string `json:"songName"`
|
||||||
} `json:"data"`
|
|
||||||
} `json:"songs"`
|
|
||||||
} `json:"results"`
|
|
||||||
Resources *struct {
|
|
||||||
Songs map[string]struct {
|
|
||||||
Attributes struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
ArtistName string `json:"artistName"`
|
ArtistName string `json:"artistName"`
|
||||||
AlbumName string `json:"albumName"`
|
AlbumName string `json:"albumName"`
|
||||||
URL string `json:"url"`
|
Duration int `json:"duration"`
|
||||||
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,145 +47,146 @@ 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 {
|
|
||||||
return nil, fmt.Errorf("failed to decode musixmatch language response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
|
||||||
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
|
||||||
if len(lines) > 0 {
|
if len(lines) > 0 {
|
||||||
return &LyricsResponse{
|
return &LyricsResponse{
|
||||||
Lines: lines,
|
Lines: lines,
|
||||||
SyncType: "LINE_SYNCED",
|
SyncType: "LINE_SYNCED",
|
||||||
|
PlainLyrics: plainLyricsFromTimedLines(lines),
|
||||||
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) != "" {
|
plainLines := plainTextLyricsLines(lrcText)
|
||||||
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
|
if len(plainLines) > 0 {
|
||||||
|
|
||||||
if len(lines) > 0 {
|
|
||||||
return &LyricsResponse{
|
return &LyricsResponse{
|
||||||
Lines: lines,
|
Lines: plainLines,
|
||||||
SyncType: "UNSYNCED",
|
SyncType: "UNSYNCED",
|
||||||
PlainLyrics: result.UnsyncedLyrics.Lyrics,
|
PlainLyrics: lrcText,
|
||||||
Provider: "Musixmatch",
|
Provider: "Musixmatch",
|
||||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||||
}, nil
|
}, 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := parseSyncedLyrics(lrcText)
|
||||||
if len(lines) > 0 {
|
if len(lines) > 0 {
|
||||||
return &LyricsResponse{
|
return &LyricsResponse{
|
||||||
Lines: lines,
|
Lines: lines,
|
||||||
SyncType: "LINE_SYNCED",
|
SyncType: "LINE_SYNCED",
|
||||||
|
PlainLyrics: plainLyricsFromTimedLines(lines),
|
||||||
Provider: "Musixmatch",
|
Provider: "Musixmatch",
|
||||||
Source: "Musixmatch",
|
Source: "Musixmatch",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
plainLines := plainTextLyricsLines(lrcText)
|
||||||
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
|
if len(plainLines) > 0 {
|
||||||
|
|
||||||
if len(lines) > 0 {
|
|
||||||
return &LyricsResponse{
|
return &LyricsResponse{
|
||||||
Lines: lines,
|
Lines: plainLines,
|
||||||
SyncType: "UNSYNCED",
|
SyncType: "UNSYNCED",
|
||||||
PlainLyrics: result.UnsyncedLyrics.Lyrics,
|
PlainLyrics: lrcText,
|
||||||
Provider: "Musixmatch",
|
Provider: "Musixmatch",
|
||||||
Source: "Musixmatch",
|
Source: "Musixmatch",
|
||||||
}, nil
|
}, 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,12 +108,14 @@ 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 = fallback
|
||||||
|
} else {
|
||||||
lrcText = rawLyrics
|
lrcText = rawLyrics
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
lines := parseSyncedLyrics(lrcText)
|
lines := parseSyncedLyrics(lrcText)
|
||||||
if len(lines) > 0 {
|
if len(lines) > 0 {
|
||||||
|
|||||||
+318
-4
@@ -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,16 +1044,29 @@ 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")
|
||||||
|
// [4:10] SampleEntry.reserved
|
||||||
|
// [10:12] data_reference_index
|
||||||
|
// [12:20] reserved[8]
|
||||||
|
// [20:22] channelcount
|
||||||
|
// [22:24] samplesize (bit depth)
|
||||||
|
// [24:26] pre_defined
|
||||||
|
// [26:28] reserved
|
||||||
|
// [28:32] samplerate (16.16 fixed-point)
|
||||||
|
sampleRate := int(buf[28])<<8 | int(buf[29])
|
||||||
|
bitDepth := int(buf[22])<<8 | int(buf[23])
|
||||||
|
if bitDepth <= 0 {
|
||||||
|
bitDepth = 16
|
||||||
if atomType == "alac" {
|
if atomType == "alac" {
|
||||||
bitDepth = 24
|
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
|
||||||
|
|||||||
+29
-2
@@ -36,8 +36,14 @@ var (
|
|||||||
|
|
||||||
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 {
|
||||||
|
|||||||
+895
-43
File diff suppressed because it is too large
Load Diff
+306
-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,174 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+142
-41
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
func (s *SongLinkClient) checkTrackAvailabilityFromSpotifyPage(spotifyTrackID string) (*TrackAvailability, error) {
|
||||||
availability.Tidal = true
|
pageURL := fmt.Sprintf("https://song.link/s/%s", spotifyTrackID)
|
||||||
availability.TidalURL = tidalLink.URL
|
req, err := http.NewRequest("GET", pageURL, nil)
|
||||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create song.link page request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
||||||
availability.Amazon = true
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
availability.AmazonURL = amazonLink.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 deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
body, err := ReadResponseBody(resp)
|
||||||
availability.Deezer = true
|
if err != nil {
|
||||||
availability.DeezerURL = deezerLink.URL
|
return nil, fmt.Errorf("failed to read song.link page: %w", err)
|
||||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
nextDataJSON, err := extractSongLinkNextDataJSON(body)
|
||||||
availability.Qobuz = true
|
if err != nil {
|
||||||
availability.QobuzURL = qobuzLink.URL
|
return nil, err
|
||||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer youtubeMusic URLs — they bypass Cobalt login requirements
|
var pageData struct {
|
||||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
Props struct {
|
||||||
availability.YouTube = true
|
PageProps struct {
|
||||||
availability.YouTubeURL = ytMusicLink.URL
|
PageData struct {
|
||||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to regular youtube if youtubeMusic not available
|
linksByPlatform := make(map[string]songLinkPlatformLink)
|
||||||
if !availability.YouTube {
|
for _, section := range pageData.Props.PageProps.PageData.Sections {
|
||||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
for _, link := range section.Links {
|
||||||
availability.YouTube = true
|
if !link.Show || strings.TrimSpace(link.URL) == "" {
|
||||||
availability.YouTubeURL = youtubeLink.URL
|
continue
|
||||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
}
|
||||||
|
linksByPlatform[link.Platform] = songLinkPlatformLink{URL: link.URL}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return availability, nil
|
if len(linksByPlatform) == 0 {
|
||||||
|
return nil, fmt.Errorf("song.link page contained no usable platform links")
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, linksByPlatform), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractSongLinkNextDataJSON(body []byte) ([]byte, error) {
|
||||||
|
const startMarker = `<script id="__NEXT_DATA__" type="application/json">`
|
||||||
|
const endMarker = `</script>`
|
||||||
|
|
||||||
|
start := bytes.Index(body, []byte(startMarker))
|
||||||
|
if start < 0 {
|
||||||
|
return nil, fmt.Errorf("song.link page missing __NEXT_DATA__")
|
||||||
|
}
|
||||||
|
start += len(startMarker)
|
||||||
|
|
||||||
|
end := bytes.Index(body[start:], []byte(endMarker))
|
||||||
|
if end < 0 {
|
||||||
|
return nil, fmt.Errorf("song.link page has unterminated __NEXT_DATA__")
|
||||||
|
}
|
||||||
|
|
||||||
|
return body[start : start+end], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SongLinkClient) checkTrackAvailabilityFromISRC(isrc string) (*TrackAvailability, error) {
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const DefaultSpotFetchAPIBaseURL = "https://spotify.afkarxyz.fun/api"
|
const DefaultSpotFetchAPIBaseURL = "https://sp.afkarxyz.qzz.io/api"
|
||||||
|
|
||||||
// GetSpotifyDataWithAPI fetches Spotify metadata through SpotFetch-compatible API.
|
// GetSpotifyDataWithAPI fetches Spotify metadata through SpotFetch-compatible API.
|
||||||
// This is used as a fallback when direct Spotify API access is blocked/limited.
|
// This is used as a fallback when direct Spotify API access is blocked/limited.
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
+1039
-25
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,222 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseTidalURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantType string
|
||||||
|
wantID string
|
||||||
|
expectErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "track url",
|
||||||
|
input: "https://tidal.com/track/77616174",
|
||||||
|
wantType: "track",
|
||||||
|
wantID: "77616174",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "browse album url",
|
||||||
|
input: "https://listen.tidal.com/browse/album/77616169",
|
||||||
|
wantType: "album",
|
||||||
|
wantID: "77616169",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "artist url",
|
||||||
|
input: "https://www.tidal.com/artist/3852143",
|
||||||
|
wantType: "artist",
|
||||||
|
wantID: "3852143",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "playlist url",
|
||||||
|
input: "https://tidal.com/playlist/edf3b7d2-cb42-41d7-93c0-afa2a395521b",
|
||||||
|
wantType: "playlist",
|
||||||
|
wantID: "edf3b7d2-cb42-41d7-93c0-afa2a395521b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unsupported host",
|
||||||
|
input: "https://example.com/track/123",
|
||||||
|
expectErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
gotType, gotID, err := parseTidalURL(test.input)
|
||||||
|
if test.expectErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, got none")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if gotType != test.wantType || gotID != test.wantID {
|
||||||
|
t.Fatalf("parseTidalURL(%q) = (%q, %q), want (%q, %q)", test.input, gotType, gotID, test.wantType, test.wantID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTidalRequestTrackID(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want int64
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{input: "40681594", want: 40681594, ok: true},
|
||||||
|
{input: "tidal:40681594", want: 40681594, ok: true},
|
||||||
|
{input: " tidal:40681594 ", want: 40681594, ok: true},
|
||||||
|
{input: "", want: 0, ok: false},
|
||||||
|
{input: "tidal:not-a-number", want: 0, ok: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
got, ok := parseTidalRequestTrackID(test.input)
|
||||||
|
if got != test.want || ok != test.ok {
|
||||||
|
t.Fatalf("parseTidalRequestTrackID(%q) = (%d, %v), want (%d, %v)", test.input, got, ok, test.want, test.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTidalImageURL(t *testing.T) {
|
||||||
|
got := tidalImageURL("fc18a64b-d76b-4582-962a-224cb05193f3", "1280x1280")
|
||||||
|
want := "https://resources.tidal.com/images/fc18a64b/d76b/4582/962a/224cb05193f3/1280x1280.jpg"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("tidalImageURL() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTidalTrackToTrackMetadata(t *testing.T) {
|
||||||
|
track := &TidalTrack{
|
||||||
|
ID: 77616174,
|
||||||
|
Title: "Bruckner: Symphony No. 5",
|
||||||
|
ISRC: "GBUM71507433",
|
||||||
|
Duration: 1172,
|
||||||
|
TrackNumber: 5,
|
||||||
|
VolumeNumber: 1,
|
||||||
|
URL: "http://www.tidal.com/track/77616174",
|
||||||
|
}
|
||||||
|
track.Artist.ID = 3852143
|
||||||
|
track.Artist.Name = "Staatskapelle Berlin"
|
||||||
|
track.Artists = []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Picture string `json:"picture"`
|
||||||
|
}{
|
||||||
|
{ID: 3852143, Name: "Staatskapelle Berlin", Type: "MAIN"},
|
||||||
|
{ID: 12430, Name: "Daniel Barenboim", Type: "FEATURED"},
|
||||||
|
}
|
||||||
|
track.Album.ID = 77616169
|
||||||
|
track.Album.Title = "Bruckner: Symphonies 4-9"
|
||||||
|
track.Album.Cover = "fc18a64b-d76b-4582-962a-224cb05193f3"
|
||||||
|
track.Album.ReleaseDate = "2016-02-26"
|
||||||
|
|
||||||
|
got := tidalTrackToTrackMetadata(track)
|
||||||
|
if got.SpotifyID != "tidal:77616174" {
|
||||||
|
t.Fatalf("unexpected track ID: %q", got.SpotifyID)
|
||||||
|
}
|
||||||
|
if got.Artists != "Staatskapelle Berlin, Daniel Barenboim" {
|
||||||
|
t.Fatalf("unexpected artists: %q", got.Artists)
|
||||||
|
}
|
||||||
|
if got.AlbumID != "tidal:77616169" {
|
||||||
|
t.Fatalf("unexpected album ID: %q", got.AlbumID)
|
||||||
|
}
|
||||||
|
if got.ArtistID != "tidal:3852143" {
|
||||||
|
t.Fatalf("unexpected artist ID: %q", got.ArtistID)
|
||||||
|
}
|
||||||
|
if got.Images == "" || got.ExternalURL != "https://www.tidal.com/track/77616174" {
|
||||||
|
t.Fatalf("unexpected image/url: %q / %q", got.Images, got.ExternalURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTidalAlbumToArtistAlbum(t *testing.T) {
|
||||||
|
album := &tidalPublicAlbum{
|
||||||
|
ID: 77616169,
|
||||||
|
Title: "Bruckner: Symphonies 4-9",
|
||||||
|
Type: "ALBUM",
|
||||||
|
Cover: "fc18a64b-d76b-4582-962a-224cb05193f3",
|
||||||
|
ReleaseDate: "2016-02-26",
|
||||||
|
NumberOfTracks: 23,
|
||||||
|
Artists: []tidalPublicArtist{
|
||||||
|
{ID: 3852143, Name: "Staatskapelle Berlin", Type: "MAIN"},
|
||||||
|
{ID: 12430, Name: "Daniel Barenboim", Type: "FEATURED"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := tidalAlbumToArtistAlbum(album)
|
||||||
|
if got.ID != "tidal:77616169" {
|
||||||
|
t.Fatalf("unexpected album ID: %q", got.ID)
|
||||||
|
}
|
||||||
|
if got.AlbumType != "album" {
|
||||||
|
t.Fatalf("unexpected album type: %q", got.AlbumType)
|
||||||
|
}
|
||||||
|
if got.Artists != "Staatskapelle Berlin, Daniel Barenboim" {
|
||||||
|
t.Fatalf("unexpected artists: %q", got.Artists)
|
||||||
|
}
|
||||||
|
if got.Images == "" {
|
||||||
|
t.Fatalf("expected image URL, got empty string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTidalAlbumToArtistAlbumWithFallbackType(t *testing.T) {
|
||||||
|
album := &tidalPublicAlbum{
|
||||||
|
ID: 490623904,
|
||||||
|
Title: "LET 'EM KNOW",
|
||||||
|
Cover: "fc18a64b-d76b-4582-962a-224cb05193f3",
|
||||||
|
NumberOfTracks: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
got := tidalAlbumToArtistAlbumWithType(album, "single")
|
||||||
|
if got.AlbumType != "single" {
|
||||||
|
t.Fatalf("unexpected fallback album type: %q", got.AlbumType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTidalArtistAlbumTypeFromModuleTitle(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
title string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{title: "Albums", want: "album"},
|
||||||
|
{title: "EP & Singles", want: "single"},
|
||||||
|
{title: "Compilations", want: "album"},
|
||||||
|
{title: "Appears On", want: "album"},
|
||||||
|
{title: "Unknown", want: ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
if got := tidalArtistAlbumTypeFromModuleTitle(test.title); got != test.want {
|
||||||
|
t.Fatalf("tidalArtistAlbumTypeFromModuleTitle(%q) = %q, want %q", test.title, got, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTidalPlaylistImageUsesOrigin(t *testing.T) {
|
||||||
|
got := tidalImageURL("e6b59fd3-6995-40f0-8a32-174db3a8f4f2", "origin")
|
||||||
|
want := "https://resources.tidal.com/images/e6b59fd3/6995/40f0/8a32/174db3a8f4f2/origin.jpg"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("unexpected origin playlist image URL: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTidalPlaylistOwnerName(t *testing.T) {
|
||||||
|
editorial := &tidalPublicPlaylist{Type: "EDITORIAL"}
|
||||||
|
if got := tidalPlaylistOwnerName(editorial); got != "TIDAL" {
|
||||||
|
t.Fatalf("unexpected editorial owner: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
artist := &tidalPublicPlaylist{Type: "ARTIST"}
|
||||||
|
if got := tidalPlaylistOwnerName(artist); got != "Artist" {
|
||||||
|
t.Fatalf("unexpected artist owner: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &tidalPublicPlaylist{}
|
||||||
|
user.Creator.Name = "djtest"
|
||||||
|
if got := tidalPlaylistOwnerName(user); got != "djtest" {
|
||||||
|
t.Fatalf("unexpected creator owner: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
|
"golang.org/x/text/unicode/norm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// normalizeLooseTitle collapses separators/punctuation so titles like
|
// normalizeLooseTitle collapses separators/punctuation so titles like
|
||||||
@@ -33,6 +35,37 @@ func normalizeLooseTitle(title string) string {
|
|||||||
return strings.Join(strings.Fields(b.String()), " ")
|
return strings.Join(strings.Fields(b.String()), " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeLooseArtistName folds diacritics and common separators so artist
|
||||||
|
// verification is resilient to variants like "Özkent" vs "Ozkent".
|
||||||
|
func normalizeLooseArtistName(name string) string {
|
||||||
|
trimmed := strings.TrimSpace(strings.ToLower(name))
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
decomposed := norm.NFD.String(trimmed)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(decomposed))
|
||||||
|
|
||||||
|
for _, r := range decomposed {
|
||||||
|
switch {
|
||||||
|
case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Mc, r), unicode.Is(unicode.Me, r):
|
||||||
|
continue
|
||||||
|
case unicode.IsLetter(r), unicode.IsNumber(r):
|
||||||
|
b.WriteRune(r)
|
||||||
|
case unicode.IsSpace(r):
|
||||||
|
b.WriteByte(' ')
|
||||||
|
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
|
||||||
|
b.WriteByte(' ')
|
||||||
|
default:
|
||||||
|
// Drop remaining punctuation/symbols for loose artist matching.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(strings.Fields(b.String()), " ")
|
||||||
|
}
|
||||||
|
|
||||||
func hasAlphaNumericRunes(value string) bool {
|
func hasAlphaNumericRunes(value string) bool {
|
||||||
for _, r := range value {
|
for _, r := range value {
|
||||||
if unicode.IsLetter(r) || unicode.IsNumber(r) {
|
if unicode.IsLetter(r) || unicode.IsNumber(r) {
|
||||||
@@ -68,3 +101,45 @@ func normalizeSymbolOnlyTitle(title string) string {
|
|||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Shared Track Verification ====================
|
||||||
|
|
||||||
|
// resolvedTrackInfo holds the metadata fetched from a provider for verification.
|
||||||
|
type resolvedTrackInfo struct {
|
||||||
|
Title string
|
||||||
|
ArtistName string
|
||||||
|
Duration int // seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// trackMatchesRequest checks whether a resolved track from a provider matches
|
||||||
|
// the original download request. Returns true if the track is a plausible match.
|
||||||
|
func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool {
|
||||||
|
if req.ArtistName != "" && resolved.ArtistName != "" &&
|
||||||
|
!artistsMatch(req.ArtistName, resolved.ArtistName) {
|
||||||
|
GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n",
|
||||||
|
logPrefix, req.ArtistName, resolved.ArtistName)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.TrackName != "" && resolved.Title != "" &&
|
||||||
|
!titlesMatch(req.TrackName, resolved.Title) {
|
||||||
|
GoLog("[%s] Verification failed: title mismatch — expected '%s', got '%s'\n",
|
||||||
|
logPrefix, req.TrackName, resolved.Title)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedDurationSec := req.DurationMS / 1000
|
||||||
|
if expectedDurationSec > 0 && resolved.Duration > 0 {
|
||||||
|
diff := expectedDurationSec - resolved.Duration
|
||||||
|
if diff < 0 {
|
||||||
|
diff = -diff
|
||||||
|
}
|
||||||
|
if diff > 10 {
|
||||||
|
GoLog("[%s] Verification failed: duration mismatch — expected %ds, got %ds\n",
|
||||||
|
logPrefix, expectedDurationSec, resolved.Duration)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type YouTubeDownloader struct {
|
type YouTubeDownloader struct {
|
||||||
@@ -30,6 +29,7 @@ var (
|
|||||||
type YouTubeQuality string
|
type YouTubeQuality string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
YouTubeQualityOpus320 YouTubeQuality = "opus_320"
|
||||||
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
|
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
|
||||||
YouTubeQualityOpus128 YouTubeQuality = "opus_128"
|
YouTubeQualityOpus128 YouTubeQuality = "opus_128"
|
||||||
YouTubeQualityMP3128 YouTubeQuality = "mp3_128"
|
YouTubeQualityMP3128 YouTubeQuality = "mp3_128"
|
||||||
@@ -38,7 +38,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
youtubeOpusSupportedBitrates = []int{128, 256}
|
youtubeOpusSupportedBitrates = []int{128, 256, 320}
|
||||||
youtubeMp3SupportedBitrates = []int{128, 256, 320}
|
youtubeMp3SupportedBitrates = []int{128, 256, 320}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ type YouTubeDownloadResult struct {
|
|||||||
func NewYouTubeDownloader() *YouTubeDownloader {
|
func NewYouTubeDownloader() *YouTubeDownloader {
|
||||||
youtubeDownloaderOnce.Do(func() {
|
youtubeDownloaderOnce.Do(func() {
|
||||||
globalYouTubeDownloader = &YouTubeDownloader{
|
globalYouTubeDownloader = &YouTubeDownloader{
|
||||||
client: NewHTTPClientWithTimeout(120 * time.Second),
|
client: NewHTTPClientWithTimeout(DownloadTimeout),
|
||||||
apiURL: "https://api.qwkuns.me",
|
apiURL: "https://api.qwkuns.me",
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -147,6 +147,8 @@ func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalize
|
|||||||
switch normalizedRaw {
|
switch normalizedRaw {
|
||||||
case "opus_256", "opus256", "opus":
|
case "opus_256", "opus256", "opus":
|
||||||
return "opus", 256, YouTubeQualityOpus256
|
return "opus", 256, YouTubeQualityOpus256
|
||||||
|
case "opus_320", "opus320":
|
||||||
|
return "opus", 320, YouTubeQualityOpus320
|
||||||
case "opus_128", "opus128":
|
case "opus_128", "opus128":
|
||||||
return "opus", 128, YouTubeQualityOpus128
|
return "opus", 128, YouTubeQualityOpus128
|
||||||
case "mp3_320", "mp3320", "mp3", "":
|
case "mp3_320", "mp3320", "mp3", "":
|
||||||
@@ -511,12 +513,10 @@ func ExtractYouTubeVideoID(urlStr string) (string, error) {
|
|||||||
return "", fmt.Errorf("invalid URL: %w", err)
|
return "", fmt.Errorf("invalid URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// /watch?v=
|
|
||||||
if v := parsed.Query().Get("v"); v != "" {
|
if v := parsed.Query().Get("v"); v != "" {
|
||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// /embed/
|
|
||||||
if strings.Contains(parsed.Path, "/embed/") {
|
if strings.Contains(parsed.Path, "/embed/") {
|
||||||
parts := strings.Split(parsed.Path, "/embed/")
|
parts := strings.Split(parsed.Path, "/embed/")
|
||||||
if len(parts) >= 2 {
|
if len(parts) >= 2 {
|
||||||
@@ -524,7 +524,6 @@ func ExtractYouTubeVideoID(urlStr string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// /v/
|
|
||||||
if strings.Contains(parsed.Path, "/v/") {
|
if strings.Contains(parsed.Path, "/v/") {
|
||||||
parts := strings.Split(parsed.Path, "/v/")
|
parts := strings.Split(parsed.Path, "/v/")
|
||||||
if len(parts) >= 2 {
|
if len(parts) >= 2 {
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ func TestParseYouTubeQualityInput_Mp3NormalizesToSupportedBitrates(t *testing.T)
|
|||||||
|
|
||||||
func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
|
func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
|
||||||
_, opusBitrate, _ := parseYouTubeQualityInput("opus_999")
|
_, opusBitrate, _ := parseYouTubeQualityInput("opus_999")
|
||||||
if opusBitrate != 256 {
|
if opusBitrate != 320 {
|
||||||
t.Fatalf("expected opus normalization to 256, got %d", opusBitrate)
|
t.Fatalf("expected opus normalization to 320, got %d", opusBitrate)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1")
|
_, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1")
|
||||||
@@ -39,3 +39,16 @@ func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
|
|||||||
t.Fatalf("expected mp3 normalization to 128, got %d", mp3Bitrate)
|
t.Fatalf("expected mp3 normalization to 128, got %d", mp3Bitrate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseYouTubeQualityInput_Opus320(t *testing.T) {
|
||||||
|
format, bitrate, normalized := parseYouTubeQualityInput("opus_320")
|
||||||
|
if format != "opus" {
|
||||||
|
t.Fatalf("expected opus format, got %s", format)
|
||||||
|
}
|
||||||
|
if bitrate != 320 {
|
||||||
|
t.Fatalf("expected 320 bitrate, got %d", bitrate)
|
||||||
|
}
|
||||||
|
if normalized != YouTubeQualityOpus320 {
|
||||||
|
t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus320, normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -160,38 +160,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
case "getSpotifyMetadata":
|
|
||||||
let args = call.arguments as! [String: Any]
|
|
||||||
let url = args["url"] as! String
|
|
||||||
let response = GobackendGetSpotifyMetadata(url, &error)
|
|
||||||
if let error = error { throw error }
|
|
||||||
return response
|
|
||||||
|
|
||||||
case "searchSpotify":
|
|
||||||
let args = call.arguments as! [String: Any]
|
|
||||||
let query = args["query"] as! String
|
|
||||||
let limit = args["limit"] as? Int ?? 10
|
|
||||||
let response = GobackendSearchSpotify(query, Int(limit), &error)
|
|
||||||
if let error = error { throw error }
|
|
||||||
return response
|
|
||||||
|
|
||||||
case "searchSpotifyAll":
|
|
||||||
let args = call.arguments as! [String: Any]
|
|
||||||
let query = args["query"] as! String
|
|
||||||
let trackLimit = args["track_limit"] as? Int ?? 15
|
|
||||||
let artistLimit = args["artist_limit"] as? Int ?? 3
|
|
||||||
let response = GobackendSearchSpotifyAll(query, Int(trackLimit), Int(artistLimit), &error)
|
|
||||||
if let error = error { throw error }
|
|
||||||
return response
|
|
||||||
|
|
||||||
case "getSpotifyRelatedArtists":
|
|
||||||
let args = call.arguments as! [String: Any]
|
|
||||||
let artistId = args["artist_id"] as! String
|
|
||||||
let limit = args["limit"] as? Int ?? 12
|
|
||||||
let response = GobackendGetSpotifyRelatedArtists(artistId, Int(limit), &error)
|
|
||||||
if let error = error { throw error }
|
|
||||||
return response
|
|
||||||
|
|
||||||
case "checkAvailability":
|
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
|
||||||
@@ -399,6 +367,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
|
||||||
@@ -415,6 +403,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
|
||||||
@@ -422,6 +426,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
|
||||||
@@ -510,17 +521,6 @@ import Gobackend // Import Go framework
|
|||||||
GobackendClearTrackCache()
|
GobackendClearTrackCache()
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
case "setSpotifyCredentials":
|
|
||||||
let args = call.arguments as! [String: Any]
|
|
||||||
let clientId = args["client_id"] as! String
|
|
||||||
let clientSecret = args["client_secret"] as! String
|
|
||||||
GobackendSetSpotifyAPICredentials(clientId, clientSecret)
|
|
||||||
return nil
|
|
||||||
|
|
||||||
case "hasSpotifyCredentials":
|
|
||||||
let hasCredentials = GobackendCheckSpotifyCredentials()
|
|
||||||
return hasCredentials
|
|
||||||
|
|
||||||
// Log methods
|
// Log methods
|
||||||
case "getLogs":
|
case "getLogs":
|
||||||
let response = GobackendGetLogs()
|
let response = GobackendGetLogs()
|
||||||
@@ -644,6 +644,20 @@ import Gobackend // Import Go framework
|
|||||||
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]
|
||||||
let extensionId = args["extension_id"] as! String
|
let extensionId = args["extension_id"] as! String
|
||||||
@@ -834,6 +848,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,10 +1,14 @@
|
|||||||
|
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 = '3.9.0';
|
||||||
static const String buildNumber = '105';
|
static const String buildNumber = '115';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
/// Shows "Internal" in debug builds, actual version in release.
|
||||||
|
static String get displayVersion => kDebugMode ? 'Internal' : version;
|
||||||
|
|
||||||
static const String appName = 'SpotiFLAC';
|
static const String appName = 'SpotiFLAC';
|
||||||
static const String copyright = '© 2026 SpotiFLAC';
|
static const String copyright = '© 2026 SpotiFLAC';
|
||||||
@@ -14,7 +18,8 @@ class AppInfo {
|
|||||||
|
|
||||||
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/';
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ abstract class AppLocalizations {
|
|||||||
/// **'Filename Format'**
|
/// **'Filename Format'**
|
||||||
String get downloadFilenameFormat;
|
String get downloadFilenameFormat;
|
||||||
|
|
||||||
/// Setting for folder structure
|
/// Title of the folder organization picker bottom sheet
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Folder Organization'**
|
/// **'Folder Organization'**
|
||||||
@@ -1066,6 +1066,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Import'**
|
/// **'Import'**
|
||||||
String get dialogImport;
|
String get dialogImport;
|
||||||
|
|
||||||
|
/// Confirm button in Download All dialog
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Download'**
|
||||||
|
String get dialogDownload;
|
||||||
|
|
||||||
/// Dialog button - discard changes
|
/// Dialog button - discard changes
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -2236,10 +2242,88 @@ abstract class AppLocalizations {
|
|||||||
/// **'Clear filters'**
|
/// **'Clear filters'**
|
||||||
String get storeClearFilters;
|
String get storeClearFilters;
|
||||||
|
|
||||||
|
/// Store setup screen - heading when no repo is configured
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add Extension Repository'**
|
||||||
|
String get storeAddRepoTitle;
|
||||||
|
|
||||||
|
/// Store setup screen - explanatory text
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.'**
|
||||||
|
String get storeAddRepoDescription;
|
||||||
|
|
||||||
|
/// Label for the repository URL input field
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Repository URL'**
|
||||||
|
String get storeRepoUrlLabel;
|
||||||
|
|
||||||
|
/// Hint/placeholder for the repository URL input field
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'https://github.com/user/repo'**
|
||||||
|
String get storeRepoUrlHint;
|
||||||
|
|
||||||
|
/// Helper text below the repository URL input field
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'e.g. https://github.com/user/extensions-repo'**
|
||||||
|
String get storeRepoUrlHelper;
|
||||||
|
|
||||||
|
/// Button to submit a new repository URL
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add Repository'**
|
||||||
|
String get storeAddRepoButton;
|
||||||
|
|
||||||
|
/// Tooltip for the change-repository icon button in the app bar
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Change repository'**
|
||||||
|
String get storeChangeRepoTooltip;
|
||||||
|
|
||||||
|
/// Title of the change/remove repository dialog
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Extension Repository'**
|
||||||
|
String get storeRepoDialogTitle;
|
||||||
|
|
||||||
|
/// Label shown above the current repository URL in the dialog
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Current repository:'**
|
||||||
|
String get storeRepoDialogCurrent;
|
||||||
|
|
||||||
|
/// Label for the new repository URL field inside the dialog
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'New Repository URL'**
|
||||||
|
String get storeNewRepoUrlLabel;
|
||||||
|
|
||||||
|
/// Error heading when the store cannot be loaded
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Failed to load store'**
|
||||||
|
String get storeLoadError;
|
||||||
|
|
||||||
|
/// Message when store has no extensions
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No extensions available'**
|
||||||
|
String get storeEmptyNoExtensions;
|
||||||
|
|
||||||
|
/// Message when search/filter returns no results
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No extensions found'**
|
||||||
|
String get storeEmptyNoResults;
|
||||||
|
|
||||||
/// Default search provider option
|
/// Default search provider option
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Default (Deezer/Spotify)'**
|
/// **'Default (Deezer)'**
|
||||||
String get extensionDefaultProvider;
|
String get extensionDefaultProvider;
|
||||||
|
|
||||||
/// Subtitle for default provider
|
/// Subtitle for default provider
|
||||||
@@ -2512,6 +2596,66 @@ abstract class AppLocalizations {
|
|||||||
/// **'24-bit / up to 192kHz'**
|
/// **'24-bit / up to 192kHz'**
|
||||||
String get qualityHiResFlacMaxSubtitle;
|
String get qualityHiResFlacMaxSubtitle;
|
||||||
|
|
||||||
|
/// Quality option label for Tidal lossy 320kbps
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Lossy 320kbps'**
|
||||||
|
String get downloadLossy320;
|
||||||
|
|
||||||
|
/// Setting title to pick output format for Tidal lossy downloads
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Lossy Format'**
|
||||||
|
String get downloadLossyFormat;
|
||||||
|
|
||||||
|
/// Title of the Tidal lossy format picker bottom sheet
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Lossy 320kbps Format'**
|
||||||
|
String get downloadLossy320Format;
|
||||||
|
|
||||||
|
/// Description in the Tidal lossy format picker
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'**
|
||||||
|
String get downloadLossy320FormatDesc;
|
||||||
|
|
||||||
|
/// Tidal lossy format option - MP3 320kbps
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'MP3 320kbps'**
|
||||||
|
String get downloadLossyMp3;
|
||||||
|
|
||||||
|
/// Subtitle for MP3 320kbps Tidal lossy option
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Best compatibility, ~10MB per track'**
|
||||||
|
String get downloadLossyMp3Subtitle;
|
||||||
|
|
||||||
|
/// Tidal lossy format option - Opus 256kbps
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Opus 256kbps'**
|
||||||
|
String get downloadLossyOpus256;
|
||||||
|
|
||||||
|
/// Subtitle for Opus 256kbps Tidal lossy option
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Best quality Opus, ~8MB per track'**
|
||||||
|
String get downloadLossyOpus256Subtitle;
|
||||||
|
|
||||||
|
/// Tidal lossy format option - Opus 128kbps
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Opus 128kbps'**
|
||||||
|
String get downloadLossyOpus128;
|
||||||
|
|
||||||
|
/// Subtitle for Opus 128kbps Tidal lossy option
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Smallest size, ~4MB per track'**
|
||||||
|
String get downloadLossyOpus128Subtitle;
|
||||||
|
|
||||||
/// Note about quality availability
|
/// Note about quality availability
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -3022,6 +3166,42 @@ abstract class AppLocalizations {
|
|||||||
/// **'Show when searching for existing tracks'**
|
/// **'Show when searching for existing tracks'**
|
||||||
String get libraryShowDuplicateIndicatorSubtitle;
|
String get libraryShowDuplicateIndicatorSubtitle;
|
||||||
|
|
||||||
|
/// Setting for automatic library scanning
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Auto Scan'**
|
||||||
|
String get libraryAutoScan;
|
||||||
|
|
||||||
|
/// Subtitle for auto scan setting
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Automatically scan your library for new files'**
|
||||||
|
String get libraryAutoScanSubtitle;
|
||||||
|
|
||||||
|
/// Auto scan disabled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Off'**
|
||||||
|
String get libraryAutoScanOff;
|
||||||
|
|
||||||
|
/// Auto scan when app opens
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Every app open'**
|
||||||
|
String get libraryAutoScanOnOpen;
|
||||||
|
|
||||||
|
/// Auto scan once per day
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Daily'**
|
||||||
|
String get libraryAutoScanDaily;
|
||||||
|
|
||||||
|
/// Auto scan once per week
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Weekly'**
|
||||||
|
String get libraryAutoScanWeekly;
|
||||||
|
|
||||||
/// Section header for library actions
|
/// Section header for library actions
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -3754,6 +3934,36 @@ abstract class AppLocalizations {
|
|||||||
/// **'FFmpeg metadata embed failed'**
|
/// **'FFmpeg metadata embed failed'**
|
||||||
String get trackReEnrichFfmpegFailed;
|
String get trackReEnrichFfmpegFailed;
|
||||||
|
|
||||||
|
/// Action/button label for queueing FLAC redownloads for local tracks
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Queue FLAC'**
|
||||||
|
String get queueFlacAction;
|
||||||
|
|
||||||
|
/// Confirmation dialog body before queueing FLAC redownloads for local tracks
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n{count} selected'**
|
||||||
|
String queueFlacConfirmMessage(int count);
|
||||||
|
|
||||||
|
/// Snackbar while resolving remote matches for local FLAC redownloads
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Finding FLAC matches... ({current}/{total})'**
|
||||||
|
String queueFlacFindingProgress(int current, int total);
|
||||||
|
|
||||||
|
/// Snackbar when no safe FLAC redownload matches were found
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No reliable online matches found for the selection'**
|
||||||
|
String get queueFlacNoReliableMatches;
|
||||||
|
|
||||||
|
/// Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Added {addedCount} tracks to queue, skipped {skippedCount}'**
|
||||||
|
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount);
|
||||||
|
|
||||||
/// Snackbar when save operation fails
|
/// Snackbar when save operation fails
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -3769,7 +3979,7 @@ abstract class AppLocalizations {
|
|||||||
/// Subtitle for convert format menu item
|
/// Subtitle for convert format menu item
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Convert to MP3 or Opus'**
|
/// **'Convert to MP3, Opus, ALAC, or FLAC'**
|
||||||
String get trackConvertFormatSubtitle;
|
String get trackConvertFormatSubtitle;
|
||||||
|
|
||||||
/// Title of convert bottom sheet
|
/// Title of convert bottom sheet
|
||||||
@@ -3806,6 +4016,21 @@ abstract class AppLocalizations {
|
|||||||
String bitrate,
|
String bitrate,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Confirmation dialog message for lossless-to-lossless conversion
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Convert from {sourceFormat} to {targetFormat}? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'**
|
||||||
|
String trackConvertConfirmMessageLossless(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Hint shown when converting between lossless formats
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Lossless conversion — no quality loss'**
|
||||||
|
String get trackConvertLosslessHint;
|
||||||
|
|
||||||
/// Snackbar while converting
|
/// Snackbar while converting
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -4176,6 +4401,12 @@ abstract class AppLocalizations {
|
|||||||
String bitrate,
|
String bitrate,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Confirmation dialog message for lossless batch conversion
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Convert {count} {count, plural, =1{track} other{tracks}} to {format}? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'**
|
||||||
|
String selectionBatchConvertConfirmMessageLossless(int count, String format);
|
||||||
|
|
||||||
/// Snackbar during batch conversion progress
|
/// Snackbar during batch conversion progress
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -4205,6 +4436,654 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Artist folders use Track Artist only'**
|
/// **'Artist folders use Track Artist only'**
|
||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle;
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle;
|
||||||
|
|
||||||
|
/// Title for the lyrics provider priority page
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Lyrics Providers'**
|
||||||
|
String get lyricsProvidersTitle;
|
||||||
|
|
||||||
|
/// Description on the lyrics provider priority page
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.'**
|
||||||
|
String get lyricsProvidersDescription;
|
||||||
|
|
||||||
|
/// Info tip on lyrics provider priority page
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.'**
|
||||||
|
String get lyricsProvidersInfoText;
|
||||||
|
|
||||||
|
/// Section header for enabled providers
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Enabled ({count})'**
|
||||||
|
String lyricsProvidersEnabledSection(int count);
|
||||||
|
|
||||||
|
/// Section header for disabled providers
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Disabled ({count})'**
|
||||||
|
String lyricsProvidersDisabledSection(int count);
|
||||||
|
|
||||||
|
/// Snackbar when user tries to disable the last enabled provider
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'At least one provider must remain enabled'**
|
||||||
|
String get lyricsProvidersAtLeastOne;
|
||||||
|
|
||||||
|
/// Snackbar after saving lyrics provider priority
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Lyrics provider priority saved'**
|
||||||
|
String get lyricsProvidersSaved;
|
||||||
|
|
||||||
|
/// Body text of the discard-changes dialog on lyrics provider page
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'You have unsaved changes that will be lost.'**
|
||||||
|
String get lyricsProvidersDiscardContent;
|
||||||
|
|
||||||
|
/// Description for Spotify Lyrics API provider
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Spotify-sourced synced lyrics via community API'**
|
||||||
|
String get lyricsProviderSpotifyApiDesc;
|
||||||
|
|
||||||
|
/// Description for LRCLIB provider
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Open-source synced lyrics database'**
|
||||||
|
String get lyricsProviderLrclibDesc;
|
||||||
|
|
||||||
|
/// Description for Netease provider
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'NetEase Cloud Music (good for Asian songs)'**
|
||||||
|
String get lyricsProviderNeteaseDesc;
|
||||||
|
|
||||||
|
/// Description for Musixmatch provider
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Largest lyrics database (multi-language)'**
|
||||||
|
String get lyricsProviderMusixmatchDesc;
|
||||||
|
|
||||||
|
/// Description for Apple Music provider
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Word-by-word synced lyrics (via proxy)'**
|
||||||
|
String get lyricsProviderAppleMusicDesc;
|
||||||
|
|
||||||
|
/// Description for QQ Music provider
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'QQ Music (good for Chinese songs, via proxy)'**
|
||||||
|
String get lyricsProviderQqMusicDesc;
|
||||||
|
|
||||||
|
/// Generic description for extension-based lyrics providers
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Extension provider'**
|
||||||
|
String get lyricsProviderExtensionDesc;
|
||||||
|
|
||||||
|
/// Title of SAF migration dialog
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Storage Update Required'**
|
||||||
|
String get safMigrationTitle;
|
||||||
|
|
||||||
|
/// First paragraph of SAF migration dialog
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.'**
|
||||||
|
String get safMigrationMessage1;
|
||||||
|
|
||||||
|
/// Second paragraph of SAF migration dialog
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Please select your download folder again to switch to the new storage system.'**
|
||||||
|
String get safMigrationMessage2;
|
||||||
|
|
||||||
|
/// Snackbar after successfully migrating to SAF
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Download folder updated to SAF mode'**
|
||||||
|
String get safMigrationSuccess;
|
||||||
|
|
||||||
|
/// Settings menu item - donate
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Donate'**
|
||||||
|
String get settingsDonate;
|
||||||
|
|
||||||
|
/// Subtitle for donate menu item
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Support SpotiFLAC-Mobile development'**
|
||||||
|
String get settingsDonateSubtitle;
|
||||||
|
|
||||||
|
/// Tooltip for the Love All button on album/playlist screens
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Love All'**
|
||||||
|
String get tooltipLoveAll;
|
||||||
|
|
||||||
|
/// Tooltip for the Add to Playlist button
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add to Playlist'**
|
||||||
|
String get tooltipAddToPlaylist;
|
||||||
|
|
||||||
|
/// Snackbar after removing multiple tracks from Loved folder
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Removed {count} tracks from Loved'**
|
||||||
|
String snackbarRemovedTracksFromLoved(int count);
|
||||||
|
|
||||||
|
/// Snackbar after adding multiple tracks to Loved folder
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Added {count} tracks to Loved'**
|
||||||
|
String snackbarAddedTracksToLoved(int count);
|
||||||
|
|
||||||
|
/// Dialog title for bulk download confirmation
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Download All'**
|
||||||
|
String get dialogDownloadAllTitle;
|
||||||
|
|
||||||
|
/// Body of the Download All confirmation dialog
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Download {count} tracks?'**
|
||||||
|
String dialogDownloadAllMessage(int count);
|
||||||
|
|
||||||
|
/// Checkbox label in import dialog to skip already-downloaded songs
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Skip already downloaded songs'**
|
||||||
|
String get homeSkipAlreadyDownloaded;
|
||||||
|
|
||||||
|
/// Context menu item to navigate to the album page
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Go to Album'**
|
||||||
|
String get homeGoToAlbum;
|
||||||
|
|
||||||
|
/// Snackbar when album info cannot be loaded
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Album info not available'**
|
||||||
|
String get homeAlbumInfoUnavailable;
|
||||||
|
|
||||||
|
/// Snackbar while loading a CUE sheet file
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Loading CUE sheet...'**
|
||||||
|
String get snackbarLoadingCueSheet;
|
||||||
|
|
||||||
|
/// Snackbar after successfully saving track metadata
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Metadata saved successfully'**
|
||||||
|
String get snackbarMetadataSaved;
|
||||||
|
|
||||||
|
/// Snackbar when lyrics embedding fails
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Failed to embed lyrics'**
|
||||||
|
String get snackbarFailedToEmbedLyrics;
|
||||||
|
|
||||||
|
/// Snackbar when writing metadata back to file fails
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Failed to write back to storage'**
|
||||||
|
String get snackbarFailedToWriteStorage;
|
||||||
|
|
||||||
|
/// Generic error snackbar with error detail
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Error: {error}'**
|
||||||
|
String snackbarError(String error);
|
||||||
|
|
||||||
|
/// Snackbar when an extension button has no action configured
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No action defined for this button'**
|
||||||
|
String get snackbarNoActionDefined;
|
||||||
|
|
||||||
|
/// Empty state message when an album has no tracks
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No tracks found for this album'**
|
||||||
|
String get noTracksFoundForAlbum;
|
||||||
|
|
||||||
|
/// Subtitle text in Android download location bottom sheet
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Choose storage mode for downloaded files.'**
|
||||||
|
String get downloadLocationSubtitle;
|
||||||
|
|
||||||
|
/// Storage mode option - use legacy app folder
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'App folder (non-SAF)'**
|
||||||
|
String get storageModeAppFolder;
|
||||||
|
|
||||||
|
/// Subtitle for app folder storage mode
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Use default Music/SpotiFLAC path'**
|
||||||
|
String get storageModeAppFolderSubtitle;
|
||||||
|
|
||||||
|
/// Storage mode option - use Android SAF picker
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'SAF folder'**
|
||||||
|
String get storageModeSaf;
|
||||||
|
|
||||||
|
/// Subtitle for SAF storage mode
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Pick folder via Android Storage Access Framework'**
|
||||||
|
String get storageModeSafSubtitle;
|
||||||
|
|
||||||
|
/// Description text in filename format bottom sheet
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Customize how your files are named.'**
|
||||||
|
String get downloadFilenameDescription;
|
||||||
|
|
||||||
|
/// Label above filename tag chips
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Tap to insert tag:'**
|
||||||
|
String get downloadFilenameInsertTag;
|
||||||
|
|
||||||
|
/// Subtitle when separate singles folder is enabled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Albums/ and Singles/ folders'**
|
||||||
|
String get downloadSeparateSinglesEnabled;
|
||||||
|
|
||||||
|
/// Subtitle when separate singles folder is disabled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'All files in same structure'**
|
||||||
|
String get downloadSeparateSinglesDisabled;
|
||||||
|
|
||||||
|
/// Setting title for artist folder filter options
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Artist Name Filters'**
|
||||||
|
String get downloadArtistNameFilters;
|
||||||
|
|
||||||
|
/// Setting title for adding a playlist folder prefix before the normal organization structure
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Create playlist source folder'**
|
||||||
|
String get downloadCreatePlaylistSourceFolder;
|
||||||
|
|
||||||
|
/// Subtitle when playlist source folder prefix is enabled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Playlist downloads use Playlist/ plus your normal folder structure.'**
|
||||||
|
String get downloadCreatePlaylistSourceFolderEnabled;
|
||||||
|
|
||||||
|
/// Subtitle when playlist source folder prefix is disabled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Playlist downloads use the normal folder structure only.'**
|
||||||
|
String get downloadCreatePlaylistSourceFolderDisabled;
|
||||||
|
|
||||||
|
/// Subtitle when playlist folder prefix setting is redundant because folder organization is already by playlist
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'By Playlist already places downloads inside a playlist folder.'**
|
||||||
|
String get downloadCreatePlaylistSourceFolderRedundant;
|
||||||
|
|
||||||
|
/// Setting title for SongLink country region
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'SongLink Region'**
|
||||||
|
String get downloadSongLinkRegion;
|
||||||
|
|
||||||
|
/// Setting title for network compatibility toggle
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Network compatibility mode'**
|
||||||
|
String get downloadNetworkCompatibilityMode;
|
||||||
|
|
||||||
|
/// Subtitle when network compatibility mode is enabled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Enabled: try HTTP + accept invalid TLS certificates (unsafe)'**
|
||||||
|
String get downloadNetworkCompatibilityModeEnabled;
|
||||||
|
|
||||||
|
/// Subtitle when network compatibility mode is disabled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Off: strict HTTPS certificate validation (recommended)'**
|
||||||
|
String get downloadNetworkCompatibilityModeDisabled;
|
||||||
|
|
||||||
|
/// Hint shown instead of Ask-quality subtitle when no built-in service selected
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Select a built-in service to enable'**
|
||||||
|
String get downloadSelectServiceToEnable;
|
||||||
|
|
||||||
|
/// Info hint when non-Tidal/Qobuz service is selected
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Select Tidal or Qobuz above to configure quality'**
|
||||||
|
String get downloadSelectTidalQobuz;
|
||||||
|
|
||||||
|
/// Subtitle for Embed Lyrics when Embed Metadata is disabled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Disabled while Embed Metadata is turned off'**
|
||||||
|
String get downloadEmbedLyricsDisabled;
|
||||||
|
|
||||||
|
/// Toggle title for including Netease translated lyrics
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Netease: Include Translation'**
|
||||||
|
String get downloadNeteaseIncludeTranslation;
|
||||||
|
|
||||||
|
/// Subtitle when Netease translation is enabled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Append translated lyrics when available'**
|
||||||
|
String get downloadNeteaseIncludeTranslationEnabled;
|
||||||
|
|
||||||
|
/// Subtitle when Netease translation is disabled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Use original lyrics only'**
|
||||||
|
String get downloadNeteaseIncludeTranslationDisabled;
|
||||||
|
|
||||||
|
/// Toggle title for including Netease romanized lyrics
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Netease: Include Romanization'**
|
||||||
|
String get downloadNeteaseIncludeRomanization;
|
||||||
|
|
||||||
|
/// Subtitle when Netease romanization is enabled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Append romanized lyrics when available'**
|
||||||
|
String get downloadNeteaseIncludeRomanizationEnabled;
|
||||||
|
|
||||||
|
/// Subtitle when Netease romanization is disabled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Disabled'**
|
||||||
|
String get downloadNeteaseIncludeRomanizationDisabled;
|
||||||
|
|
||||||
|
/// Toggle title for Apple/QQ multi-person word-by-word lyrics
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Apple/QQ Multi-Person Word-by-Word'**
|
||||||
|
String get downloadAppleQqMultiPerson;
|
||||||
|
|
||||||
|
/// Subtitle when multi-person word-by-word is enabled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Enable v1/v2 speaker and [bg:] tags'**
|
||||||
|
String get downloadAppleQqMultiPersonEnabled;
|
||||||
|
|
||||||
|
/// Subtitle when multi-person word-by-word is disabled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Simplified word-by-word formatting'**
|
||||||
|
String get downloadAppleQqMultiPersonDisabled;
|
||||||
|
|
||||||
|
/// Setting title for Musixmatch language preference
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Musixmatch Language'**
|
||||||
|
String get downloadMusixmatchLanguage;
|
||||||
|
|
||||||
|
/// Option label when Musixmatch uses original language
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Auto (original)'**
|
||||||
|
String get downloadMusixmatchLanguageAuto;
|
||||||
|
|
||||||
|
/// Toggle title for filtering contributing artists in Album Artist metadata
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Filter contributing artists in Album Artist'**
|
||||||
|
String get downloadFilterContributing;
|
||||||
|
|
||||||
|
/// Subtitle when contributing artist filter is enabled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Album Artist metadata uses primary artist only'**
|
||||||
|
String get downloadFilterContributingEnabled;
|
||||||
|
|
||||||
|
/// Subtitle when contributing artist filter is disabled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Keep full Album Artist metadata value'**
|
||||||
|
String get downloadFilterContributingDisabled;
|
||||||
|
|
||||||
|
/// Subtitle for lyrics providers setting when no providers are enabled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'None enabled'**
|
||||||
|
String get downloadProvidersNoneEnabled;
|
||||||
|
|
||||||
|
/// Label for the Musixmatch language code text field
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Language code'**
|
||||||
|
String get downloadMusixmatchLanguageCode;
|
||||||
|
|
||||||
|
/// Hint text for the Musixmatch language code field
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'auto / en / es / ja'**
|
||||||
|
String get downloadMusixmatchLanguageHint;
|
||||||
|
|
||||||
|
/// Description in the Musixmatch language picker
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Set preferred language code (example: en, es, ja). Leave empty for auto.'**
|
||||||
|
String get downloadMusixmatchLanguageDesc;
|
||||||
|
|
||||||
|
/// Button to reset Musixmatch language to automatic
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Auto'**
|
||||||
|
String get downloadMusixmatchAuto;
|
||||||
|
|
||||||
|
/// Subtitle for 'Any' network mode option
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'WiFi + Mobile Data'**
|
||||||
|
String get downloadNetworkAnySubtitle;
|
||||||
|
|
||||||
|
/// Subtitle for 'WiFi only' network mode option
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Pause downloads on mobile data'**
|
||||||
|
String get downloadNetworkWifiOnlySubtitle;
|
||||||
|
|
||||||
|
/// Description in the SongLink region picker
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Used as userCountry for SongLink API lookup.'**
|
||||||
|
String get downloadSongLinkRegionDesc;
|
||||||
|
|
||||||
|
/// Snackbar when the audio format is not supported for the requested operation
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Unsupported audio format'**
|
||||||
|
String get snackbarUnsupportedAudioFormat;
|
||||||
|
|
||||||
|
/// Tooltip for refresh button on cache management page
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Refresh'**
|
||||||
|
String get cacheRefresh;
|
||||||
|
|
||||||
|
/// Dialog message for bulk playlist download confirmation
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Download {trackCount} {trackCount, plural, =1{track} other{tracks}} from {playlistCount} {playlistCount, plural, =1{playlist} other{playlists}}?'**
|
||||||
|
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount);
|
||||||
|
|
||||||
|
/// Button label for bulk downloading selected playlists
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Download {count} {count, plural, =1{playlist} other{playlists}}'**
|
||||||
|
String bulkDownloadPlaylistsButton(int count);
|
||||||
|
|
||||||
|
/// Button label when no playlists are selected for download
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Select playlists to download'**
|
||||||
|
String get bulkDownloadSelectPlaylists;
|
||||||
|
|
||||||
|
/// Snackbar when selected playlists contain no tracks
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Selected playlists have no tracks'**
|
||||||
|
String get snackbarSelectedPlaylistsEmpty;
|
||||||
|
|
||||||
|
/// Playlist count display
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count, plural, =1{1 playlist} other{{count} playlists}}'**
|
||||||
|
String playlistsCount(int count);
|
||||||
|
|
||||||
|
/// Section title for selective online metadata auto-fill in the edit metadata sheet
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Auto-fill from online'**
|
||||||
|
String get editMetadataAutoFill;
|
||||||
|
|
||||||
|
/// Description for the auto-fill section
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Select fields to fill automatically from online metadata'**
|
||||||
|
String get editMetadataAutoFillDesc;
|
||||||
|
|
||||||
|
/// Button label to fetch online metadata and fill selected fields
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Fetch & Fill'**
|
||||||
|
String get editMetadataAutoFillFetch;
|
||||||
|
|
||||||
|
/// Snackbar shown while searching for online metadata
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Searching online...'**
|
||||||
|
String get editMetadataAutoFillSearching;
|
||||||
|
|
||||||
|
/// Snackbar when online metadata search returns no results
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No matching metadata found online'**
|
||||||
|
String get editMetadataAutoFillNoResults;
|
||||||
|
|
||||||
|
/// Snackbar confirming how many fields were auto-filled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Filled {count} {count, plural, =1{field} other{fields}} from online metadata'**
|
||||||
|
String editMetadataAutoFillDone(int count);
|
||||||
|
|
||||||
|
/// Snackbar when user taps Fetch without selecting any fields
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Select at least one field to auto-fill'**
|
||||||
|
String get editMetadataAutoFillNoneSelected;
|
||||||
|
|
||||||
|
/// Chip label for title field in auto-fill selector
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Title'**
|
||||||
|
String get editMetadataFieldTitle;
|
||||||
|
|
||||||
|
/// Chip label for artist field in auto-fill selector
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Artist'**
|
||||||
|
String get editMetadataFieldArtist;
|
||||||
|
|
||||||
|
/// Chip label for album field in auto-fill selector
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Album'**
|
||||||
|
String get editMetadataFieldAlbum;
|
||||||
|
|
||||||
|
/// Chip label for album artist field in auto-fill selector
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Album Artist'**
|
||||||
|
String get editMetadataFieldAlbumArtist;
|
||||||
|
|
||||||
|
/// Chip label for date field in auto-fill selector
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Date'**
|
||||||
|
String get editMetadataFieldDate;
|
||||||
|
|
||||||
|
/// Chip label for track number field in auto-fill selector
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Track #'**
|
||||||
|
String get editMetadataFieldTrackNum;
|
||||||
|
|
||||||
|
/// Chip label for disc number field in auto-fill selector
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Disc #'**
|
||||||
|
String get editMetadataFieldDiscNum;
|
||||||
|
|
||||||
|
/// Chip label for genre field in auto-fill selector
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Genre'**
|
||||||
|
String get editMetadataFieldGenre;
|
||||||
|
|
||||||
|
/// Chip label for ISRC field in auto-fill selector
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'ISRC'**
|
||||||
|
String get editMetadataFieldIsrc;
|
||||||
|
|
||||||
|
/// Chip label for label field in auto-fill selector
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Label'**
|
||||||
|
String get editMetadataFieldLabel;
|
||||||
|
|
||||||
|
/// Chip label for copyright field in auto-fill selector
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Copyright'**
|
||||||
|
String get editMetadataFieldCopyright;
|
||||||
|
|
||||||
|
/// Chip label for cover art field in auto-fill selector
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Cover Art'**
|
||||||
|
String get editMetadataFieldCover;
|
||||||
|
|
||||||
|
/// Button to select all fields for auto-fill
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'All'**
|
||||||
|
String get editMetadataSelectAll;
|
||||||
|
|
||||||
|
/// Button to select only fields that are currently empty
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Empty only'**
|
||||||
|
String get editMetadataSelectEmpty;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -365,7 +365,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutAppDescription =>
|
String get aboutAppDescription =>
|
||||||
'Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.';
|
'Lade Spotify-Titel in verlustfreier Qualität von Tidal und Qobuz herunter.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get artistAlbums => 'Alben';
|
String get artistAlbums => 'Alben';
|
||||||
@@ -441,7 +441,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupDownloadLocationIosMessage =>
|
String get setupDownloadLocationIosMessage =>
|
||||||
'Auf iOS werden Downloads im Dokumentenverzeichnis der App gespeichert. Du kannst sie über die Datei-App aufrufen.';
|
'Auf iOS werden Downloads im Dokumentenordner der App gespeichert. Du kannst sie über die Datei-App aufrufen.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupAppDocumentsFolder => 'App-Dokumentenordner';
|
String get setupAppDocumentsFolder => 'App-Dokumentenordner';
|
||||||
@@ -536,6 +536,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get dialogImport => 'Importieren';
|
String get dialogImport => 'Importieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Verwerfen';
|
String get dialogDiscard => 'Verwerfen';
|
||||||
|
|
||||||
@@ -702,15 +705,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get errorNoTracksFound => 'Keine Titel gefunden';
|
String get errorNoTracksFound => 'Keine Titel gefunden';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get errorUrlNotRecognized => 'Link not recognized';
|
String get errorUrlNotRecognized => 'Link wurde nicht erkannt';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get errorUrlNotRecognizedMessage =>
|
String get errorUrlNotRecognizedMessage =>
|
||||||
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
|
'Dieser Link ist inkompatibel. Prüfe die URL und stelle sicher, dass eine kompatible Erweiterung installiert ist.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get errorUrlFetchFailed =>
|
String get errorUrlFetchFailed =>
|
||||||
'Failed to load content from this link. Please try again.';
|
'Laden fehlgeschlagen. Bitte erneut versuchen.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String errorMissingExtensionSource(String item) {
|
String errorMissingExtensionSource(String item) {
|
||||||
@@ -747,7 +750,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get selectionAllSelected => 'Alle Titel sind ausgewählt';
|
String get selectionAllSelected => 'Alle Titel sind ausgewählt';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get selectionSelectToDelete => 'Titel zum Löschen auswählen';
|
String get selectionSelectToDelete => 'Titel zum Löschen wählen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String progressFetchingMetadata(int current, int total) {
|
String progressFetchingMetadata(int current, int total) {
|
||||||
@@ -764,7 +767,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get searchArtists => 'Künstler';
|
String get searchArtists => 'Künstler';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get searchAlbums => 'Albums';
|
String get searchAlbums => 'Alben';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Playlisten';
|
String get searchPlaylists => 'Playlisten';
|
||||||
@@ -786,11 +789,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get folderOrganizationNone => 'Keine Organisation';
|
String get folderOrganizationNone => 'Keine Organisation';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganizationByPlaylist => 'By Playlist';
|
String get folderOrganizationByPlaylist => 'Nach Playlist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganizationByPlaylistSubtitle =>
|
String get folderOrganizationByPlaylistSubtitle =>
|
||||||
'Separate folder for each playlist';
|
'Ordner für jede Playlist trennen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganizationByArtist => 'Nach Künstler';
|
String get folderOrganizationByArtist => 'Nach Künstler';
|
||||||
@@ -807,7 +810,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganizationNoneSubtitle =>
|
String get folderOrganizationNoneSubtitle =>
|
||||||
'Alle Dateien im Download-Verzeichnis';
|
'Alle Dateien im Download-Ordner';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganizationByArtistSubtitle =>
|
String get folderOrganizationByArtistSubtitle =>
|
||||||
@@ -1215,6 +1218,47 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get storeClearFilters => 'Filter entfernen';
|
String get storeClearFilters => 'Filter entfernen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeAddRepoTitle => 'Add Extension Repository';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeAddRepoDescription =>
|
||||||
|
'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRepoUrlLabel => 'Repository URL';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRepoUrlHint => 'https://github.com/user/repo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRepoUrlHelper =>
|
||||||
|
'e.g. https://github.com/user/extensions-repo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeAddRepoButton => 'Add Repository';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeChangeRepoTooltip => 'Change repository';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRepoDialogTitle => 'Extension Repository';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRepoDialogCurrent => 'Current repository:';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeLoadError => 'Failed to load store';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeEmptyNoResults => 'No extensions found';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionDefaultProvider => 'Standard (Deezer/Spotify)';
|
String get extensionDefaultProvider => 'Standard (Deezer/Spotify)';
|
||||||
|
|
||||||
@@ -1369,6 +1413,38 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get qualityHiResFlacMaxSubtitle => '24-Bit / bis 192kHz';
|
String get qualityHiResFlacMaxSubtitle => '24-Bit / bis 192kHz';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossy320 => 'Lossy 320kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyFormat => 'Lossy Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossy320FormatDesc =>
|
||||||
|
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus256Subtitle =>
|
||||||
|
'Best quality Opus, ~8MB per track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Die eigentliche Qualität hängt von der Verfügbarkeit des Dienstes ab';
|
'Die eigentliche Qualität hängt von der Verfügbarkeit des Dienstes ab';
|
||||||
@@ -1387,19 +1463,20 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get downloadAskBeforeDownload => 'Qualität vor Download fragen';
|
String get downloadAskBeforeDownload => 'Qualität vor Download fragen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadDirectory => 'Downloadverzeichnis';
|
String get downloadDirectory => 'Download-Ordner';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSeparateSinglesFolder => 'Singles Ordner trennen';
|
String get downloadSeparateSinglesFolder => 'Singles Ordner trennen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
String get downloadAlbumFolderStructure => 'Album-Ordnerstruktur';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
String get downloadUseAlbumArtistForFolders =>
|
||||||
|
'Album-Künstler für Ordner verwenden';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
String get downloadUsePrimaryArtistOnly => 'Primärer Künstler nur für Ordner';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||||
@@ -1407,7 +1484,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||||
'Full artist string used for folder name';
|
'Vollständiger Künstler für Ordnername';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectQuality => 'Qualität wählen';
|
String get downloadSelectQuality => 'Qualität wählen';
|
||||||
@@ -1429,7 +1506,8 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
'Bist du dir sicher, dass du alle Downloads löschen möchten?';
|
'Bist du dir sicher, dass du alle Downloads löschen möchten?';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
String get settingsAutoExportFailed =>
|
||||||
|
'Auto-Export fehlgeschlagener Downloads';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsAutoExportFailedSubtitle =>
|
String get settingsAutoExportFailedSubtitle =>
|
||||||
@@ -1452,14 +1530,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbum => 'Künstler/Album';
|
String get albumFolderArtistAlbum => 'Künstler/Album';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get albumFolderArtistAlbumSubtitle => 'Albums/Artist Name/Album Name/';
|
String get albumFolderArtistAlbumSubtitle => 'Alben/Künster Name/Album Name/';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get albumFolderArtistYearAlbum => 'Artist / [Year] Album';
|
String get albumFolderArtistYearAlbum => 'Künstler / [Year] Album';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get albumFolderArtistYearAlbumSubtitle =>
|
String get albumFolderArtistYearAlbumSubtitle =>
|
||||||
'Albums/Künster Name/[2005] Album Name/';
|
'Alben/Künster Name/[2005] Album Name/';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get albumFolderAlbumOnly => 'Nur Alben';
|
String get albumFolderAlbumOnly => 'Nur Alben';
|
||||||
@@ -1471,14 +1549,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get albumFolderYearAlbum => '[Year] Album';
|
String get albumFolderYearAlbum => '[Year] Album';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
String get albumFolderYearAlbumSubtitle => 'Alben/[2005] Album Name/';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
String get albumFolderArtistAlbumSingles => 'Künstler / Album + Singles';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Künstler/Album/ und Künstler/Singles/';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Ausgewählte löschen';
|
String get downloadedAlbumDeleteSelected => 'Ausgewählte löschen';
|
||||||
@@ -1517,7 +1595,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
String get downloadedAlbumSelectToDelete => 'Titel zum Löschen wählen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String downloadedAlbumDiscHeader(int discNumber) {
|
String downloadedAlbumDiscHeader(int discNumber) {
|
||||||
@@ -1563,7 +1641,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||||
return '$count Titel von $albumCount Albums';
|
return '$count Titel aus $albumCount Alben';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -1579,14 +1657,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get discographySelectAlbumsSubtitle =>
|
String get discographySelectAlbumsSubtitle =>
|
||||||
'Choose specific albums or singles';
|
'Wähle bestimmte Alben oder Singles';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get discographyFetchingTracks => 'Lade Titel...';
|
String get discographyFetchingTracks => 'Lade Titel...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String discographyFetchingAlbum(int current, int total) {
|
String discographyFetchingAlbum(int current, int total) {
|
||||||
return 'Fetching $current of $total...';
|
return 'Lade $current von $total...';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -1599,7 +1677,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String discographyAddedToQueue(int count) {
|
String discographyAddedToQueue(int count) {
|
||||||
return 'Added $count tracks to queue';
|
return '$count Titel zur Warteschlange hinzugefügt';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -1611,7 +1689,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get discographyNoAlbums => 'Es sind keine Alben verfügbar';
|
String get discographyNoAlbums => 'Es sind keine Alben verfügbar';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
String get discographyFailedToFetch => 'Fehler beim Abrufen einiger Alben';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get sectionStorageAccess => 'Speicherzugriff';
|
String get sectionStorageAccess => 'Speicherzugriff';
|
||||||
@@ -1620,14 +1698,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get allFilesAccess => 'Zugriff auf alle Dateien';
|
String get allFilesAccess => 'Zugriff auf alle Dateien';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
|
String get allFilesAccessEnabledSubtitle => 'Darf in jeden Ordner schreiben';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
|
String get allFilesAccessDisabledSubtitle => 'Nur auf Medienordner begrenzt';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get allFilesAccessDescription =>
|
String get allFilesAccessDescription =>
|
||||||
'Aktiviere die Option, wenn beim Speichern in benutzerdefinierten Ordnern Schreibfehler auftreten. Weil Android 13+ standardmäßig den Zugriff auf bestimmte Verzeichnisse einschränkt.';
|
'Option bei Schreibfehlern bitte aktivieren (erforderlich ab Android 13).';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get allFilesAccessDeniedMessage =>
|
String get allFilesAccessDeniedMessage =>
|
||||||
@@ -1641,13 +1719,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get settingsLocalLibrary => 'Lokale Bibliothek';
|
String get settingsLocalLibrary => 'Lokale Bibliothek';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
String get settingsLocalLibrarySubtitle =>
|
||||||
|
'Musik scannen & Duplikate erkennen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsCache => 'Speicher & Cache';
|
String get settingsCache => 'Speicher & Cache';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsCacheSubtitle => 'View size and clear cached data';
|
String get settingsCacheSubtitle =>
|
||||||
|
'Größe anzeigen und Daten im Cache leeren';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryTitle => 'Lokale Bibliothek';
|
String get libraryTitle => 'Lokale Bibliothek';
|
||||||
@@ -1660,7 +1740,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryEnableLocalLibrarySubtitle =>
|
String get libraryEnableLocalLibrarySubtitle =>
|
||||||
'Scan and track your existing music';
|
'Scan und verfolge deine bestehende Musik';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFolder => 'Bibliotheksordner';
|
String get libraryFolder => 'Bibliotheksordner';
|
||||||
@@ -1669,12 +1749,31 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get libraryFolderHint => 'Tippe um Ordner auszuwählen';
|
String get libraryFolderHint => 'Tippe um Ordner auszuwählen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
String get libraryShowDuplicateIndicator => 'Duplikat Indikator anzeigen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||||
'Bei der Suche nach vorhandenen Titeln anzeigen';
|
'Bei der Suche nach vorhandenen Titeln anzeigen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScan => 'Auto Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanSubtitle =>
|
||||||
|
'Automatically scan your library for new files';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanOff => 'Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanOnOpen => 'Every app open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanDaily => 'Daily';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanWeekly => 'Weekly';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryActions => 'Aktionen';
|
String get libraryActions => 'Aktionen';
|
||||||
|
|
||||||
@@ -1851,7 +1950,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip2 =>
|
String get tutorialWelcomeTip2 =>
|
||||||
'Hole dir FLAC Audio von Tidal, Qobuz oder Amazon Musik';
|
'Hole dir FLAC Audio von Tidal, Qobuz oder Deezer';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip3 =>
|
String get tutorialWelcomeTip3 =>
|
||||||
@@ -1918,7 +2017,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialSettingsTip1 =>
|
String get tutorialSettingsTip1 =>
|
||||||
'Downloadverzeichnis und Ordnerorganisation ändern';
|
'Download-Ordner und Ordner-Organisation ändern';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialSettingsTip2 =>
|
String get tutorialSettingsTip2 =>
|
||||||
@@ -1976,14 +2075,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get cacheSectionMaintenance => 'Wartung';
|
String get cacheSectionMaintenance => 'Wartung';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheAppDirectory => 'App-Cache Verzeichnis';
|
String get cacheAppDirectory => 'App-Cache Ordner';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheAppDirectoryDesc =>
|
String get cacheAppDirectoryDesc =>
|
||||||
'HTTP-Antworten, WebView Daten und andere temporäre App-Daten.';
|
'HTTP-Antworten, WebView Daten und andere temporäre App-Daten.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheTempDirectory => 'Temporäres Verzeichnis';
|
String get cacheTempDirectory => 'Temporärer Ordner';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheTempDirectoryDesc =>
|
String get cacheTempDirectoryDesc =>
|
||||||
@@ -2104,11 +2203,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String trackCoverSaved(String fileName) {
|
String trackCoverSaved(String fileName) {
|
||||||
return 'Cover art saved to $fileName';
|
return 'Cover in $fileName gespeichert';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackCoverNoSource => 'No cover art source available';
|
String get trackCoverNoSource => 'Keine Cover Quelle vorhanden';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String trackLyricsSaved(String fileName) {
|
String trackLyricsSaved(String fileName) {
|
||||||
@@ -2128,6 +2227,28 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get trackReEnrichFfmpegFailed =>
|
String get trackReEnrichFfmpegFailed =>
|
||||||
'FFmpeg Metadaten-Einbettung fehlgeschlagen';
|
'FFmpeg Metadaten-Einbettung fehlgeschlagen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFlacAction => 'Queue FLAC';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueFlacConfirmMessage(int count) {
|
||||||
|
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueFlacFindingProgress(int current, int total) {
|
||||||
|
return 'Finding FLAC matches... ($current/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFlacNoReliableMatches =>
|
||||||
|
'No reliable online matches found for the selection';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||||
|
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Fehler: $error';
|
return 'Fehler: $error';
|
||||||
@@ -2160,6 +2281,18 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
return 'Konvertieren von $sourceFormat in $targetFormat bei $bitrate?\n\nDie Originaldatei wird nach der Konvertierung gelöscht.';
|
return 'Konvertieren von $sourceFormat in $targetFormat bei $bitrate?\n\nDie Originaldatei wird nach der Konvertierung gelöscht.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessageLossless(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertLosslessHint =>
|
||||||
|
'Lossless conversion — no quality loss';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertConverting => 'Konvertiere Audio...';
|
String get trackConvertConverting => 'Konvertiere Audio...';
|
||||||
|
|
||||||
@@ -2172,10 +2305,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get trackConvertFailed => 'Konvertierung fehlgeschlagen';
|
String get trackConvertFailed => 'Konvertierung fehlgeschlagen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cueSplitTitle => 'Split CUE Sheet';
|
String get cueSplitTitle => 'CUE-Sheet aufteilen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
|
String get cueSplitSubtitle => 'CUE+FLAC in einzelne Titel aufteilen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String cueSplitAlbum(String album) {
|
String cueSplitAlbum(String album) {
|
||||||
@@ -2184,40 +2317,41 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String cueSplitArtist(String artist) {
|
String cueSplitArtist(String artist) {
|
||||||
return 'Artist: $artist';
|
return 'Künstler: $artist';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String cueSplitTrackCount(int count) {
|
String cueSplitTrackCount(int count) {
|
||||||
return '$count tracks';
|
return '$count Titel';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cueSplitConfirmTitle => 'Split CUE Album';
|
String get cueSplitConfirmTitle => 'CUE-Album aufteilen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String cueSplitConfirmMessage(String album, int count) {
|
String cueSplitConfirmMessage(String album, int count) {
|
||||||
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
|
return 'Soll „$album“ in $count einzelne FLAC-Dateien aufgeteilt werden?\n\nDie Dateien werden im selben Ordner gespeichert.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String cueSplitSplitting(int current, int total) {
|
String cueSplitSplitting(int current, int total) {
|
||||||
return 'Splitting CUE sheet... ($current/$total)';
|
return 'CUE-Sheet wird geteilt... ($current/$total)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String cueSplitSuccess(int count) {
|
String cueSplitSuccess(int count) {
|
||||||
return 'Split into $count tracks successfully';
|
return '$count Titel erfolgreich aufgeteilt';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cueSplitFailed => 'CUE split failed';
|
String get cueSplitFailed => 'CUE-Aufteilung fehlgeschlagen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
|
String get cueSplitNoAudioFile =>
|
||||||
|
'Audiodatei für dieses CUE-Sheet nicht gefunden';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cueSplitButton => 'Split into Tracks';
|
String get cueSplitButton => 'In Titel aufteilen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get actionCreate => 'Erstellen';
|
String get actionCreate => 'Erstellen';
|
||||||
@@ -2414,6 +2548,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
return 'Konvertiere $count $format $_temp0 zu $bitrate?\n\nOriginaldateien werden nach der Konvertierung gelöscht.';
|
return 'Konvertiere $count $format $_temp0 zu $bitrate?\n\nOriginaldateien werden nach der Konvertierung gelöscht.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String selectionBatchConvertProgress(int current, int total) {
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
return 'Konvertiere $current von $total...';
|
return 'Konvertiere $current von $total...';
|
||||||
@@ -2431,9 +2576,423 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||||
'Künstlerordner verwenden den Album-Interpreten, wenn verfügbar';
|
'Interpret-Ordner verwenden Album-Interpret, sofern vorhanden';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
'Artist folders use Track Artist only';
|
'Künstler-Ordner nur für Titel-Künstler';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsProvidersTitle => 'Lyrics Providers';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsProvidersDescription =>
|
||||||
|
'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsProvidersInfoText =>
|
||||||
|
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String lyricsProvidersEnabledSection(int count) {
|
||||||
|
return 'Enabled ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String lyricsProvidersDisabledSection(int count) {
|
||||||
|
return 'Disabled ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsProvidersAtLeastOne =>
|
||||||
|
'At least one provider must remain enabled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsProvidersSaved => 'Lyrics provider priority saved';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsProvidersDiscardContent =>
|
||||||
|
'You have unsaved changes that will be lost.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsProviderSpotifyApiDesc =>
|
||||||
|
'Spotify-sourced synced lyrics via community API';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsProviderNeteaseDesc =>
|
||||||
|
'NetEase Cloud Music (good for Asian songs)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsProviderMusixmatchDesc =>
|
||||||
|
'Largest lyrics database (multi-language)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsProviderAppleMusicDesc =>
|
||||||
|
'Word-by-word synced lyrics (via proxy)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsProviderQqMusicDesc =>
|
||||||
|
'QQ Music (good for Chinese songs, via proxy)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsProviderExtensionDesc => 'Extension provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get safMigrationTitle => 'Storage Update Required';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get safMigrationMessage1 =>
|
||||||
|
'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get safMigrationMessage2 =>
|
||||||
|
'Please select your download folder again to switch to the new storage system.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get safMigrationSuccess => 'Download folder updated to SAF mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDonate => 'Donate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tooltipLoveAll => 'Love All';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tooltipAddToPlaylist => 'Add to Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarRemovedTracksFromLoved(int count) {
|
||||||
|
return 'Removed $count tracks from Loved';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarAddedTracksToLoved(int count) {
|
||||||
|
return 'Added $count tracks to Loved';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dialogDownloadAllTitle => 'Download All';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String dialogDownloadAllMessage(int count) {
|
||||||
|
return 'Download $count tracks?';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get homeGoToAlbum => 'Go to Album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get homeAlbumInfoUnavailable => 'Album info not available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarMetadataSaved => 'Metadata saved successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarError(String error) {
|
||||||
|
return 'Error: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarNoActionDefined => 'No action defined for this button';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noTracksFoundForAlbum => 'No tracks found for this album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLocationSubtitle =>
|
||||||
|
'Choose storage mode for downloaded files.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageModeAppFolder => 'App folder (non-SAF)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageModeSaf => 'SAF folder';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageModeSafSubtitle =>
|
||||||
|
'Pick folder via Android Storage Access Framework';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadFilenameDescription =>
|
||||||
|
'Customize how your files are named.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadFilenameInsertTag => 'Tap to insert tag:';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSeparateSinglesDisabled => 'All files in same structure';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadCreatePlaylistSourceFolder =>
|
||||||
|
'Create playlist source folder';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||||
|
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||||
|
'Playlist downloads use the normal folder structure only.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||||
|
'By Playlist already places downloads inside a playlist folder.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSongLinkRegion => 'SongLink Region';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNetworkCompatibilityModeEnabled =>
|
||||||
|
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNetworkCompatibilityModeDisabled =>
|
||||||
|
'Off: strict HTTPS certificate validation (recommended)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSelectServiceToEnable =>
|
||||||
|
'Select a built-in service to enable';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSelectTidalQobuz =>
|
||||||
|
'Select Tidal or Qobuz above to configure quality';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadEmbedLyricsDisabled =>
|
||||||
|
'Disabled while Embed Metadata is turned off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNeteaseIncludeTranslation =>
|
||||||
|
'Netease: Include Translation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNeteaseIncludeTranslationEnabled =>
|
||||||
|
'Append translated lyrics when available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNeteaseIncludeTranslationDisabled =>
|
||||||
|
'Use original lyrics only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNeteaseIncludeRomanization =>
|
||||||
|
'Netease: Include Romanization';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNeteaseIncludeRomanizationEnabled =>
|
||||||
|
'Append romanized lyrics when available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAppleQqMultiPersonEnabled =>
|
||||||
|
'Enable v1/v2 speaker and [bg:] tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAppleQqMultiPersonDisabled =>
|
||||||
|
'Simplified word-by-word formatting';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadFilterContributing =>
|
||||||
|
'Filter contributing artists in Album Artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadFilterContributingEnabled =>
|
||||||
|
'Album Artist metadata uses primary artist only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadFilterContributingDisabled =>
|
||||||
|
'Keep full Album Artist metadata value';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadProvidersNoneEnabled => 'None enabled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadMusixmatchLanguageCode => 'Language code';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadMusixmatchLanguageDesc =>
|
||||||
|
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadMusixmatchAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNetworkWifiOnlySubtitle =>
|
||||||
|
'Pause downloads on mobile data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSongLinkRegionDesc =>
|
||||||
|
'Used as userCountry for SongLink API lookup.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheRefresh => 'Refresh';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
trackCount,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
String _temp1 = intl.Intl.pluralLogic(
|
||||||
|
playlistCount,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'playlists',
|
||||||
|
one: 'playlist',
|
||||||
|
);
|
||||||
|
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String bulkDownloadPlaylistsButton(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'playlists',
|
||||||
|
one: 'playlist',
|
||||||
|
);
|
||||||
|
return 'Download $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarSelectedPlaylistsEmpty =>
|
||||||
|
'Selected playlists have no tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playlistsCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count playlists',
|
||||||
|
one: '1 playlist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataAutoFillDesc =>
|
||||||
|
'Select fields to fill automatically from online metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataAutoFillNoResults =>
|
||||||
|
'No matching metadata found online';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String editMetadataAutoFillDone(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'fields',
|
||||||
|
one: 'field',
|
||||||
|
);
|
||||||
|
return 'Filled $count $_temp0 from online metadata';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataAutoFillNoneSelected =>
|
||||||
|
'Select at least one field to auto-fill';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldTitle => 'Title';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldArtist => 'Artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldAlbum => 'Album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldDate => 'Date';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldTrackNum => 'Track #';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldDiscNum => 'Disc #';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldGenre => 'Genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldIsrc => 'ISRC';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldLabel => 'Label';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldCopyright => 'Copyright';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldCover => 'Cover Art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataSelectAll => 'All';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataSelectEmpty => 'Empty only';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -525,6 +525,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get dialogImport => 'Import';
|
String get dialogImport => 'Import';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Discard';
|
String get dialogDiscard => 'Discard';
|
||||||
|
|
||||||
@@ -1196,7 +1199,48 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get storeClearFilters => 'Clear filters';
|
String get storeClearFilters => 'Clear filters';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
|
String get storeAddRepoTitle => 'Add Extension Repository';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeAddRepoDescription =>
|
||||||
|
'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRepoUrlLabel => 'Repository URL';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRepoUrlHint => 'https://github.com/user/repo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRepoUrlHelper =>
|
||||||
|
'e.g. https://github.com/user/extensions-repo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeAddRepoButton => 'Add Repository';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeChangeRepoTooltip => 'Change repository';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRepoDialogTitle => 'Extension Repository';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRepoDialogCurrent => 'Current repository:';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeLoadError => 'Failed to load store';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeEmptyNoResults => 'No extensions found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionDefaultProvider => 'Default (Deezer)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionDefaultProviderSubtitle => 'Use built-in search';
|
String get extensionDefaultProviderSubtitle => 'Use built-in search';
|
||||||
@@ -1345,6 +1389,38 @@ class AppLocalizationsEn 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';
|
||||||
@@ -1651,6 +1727,25 @@ class AppLocalizationsEn 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';
|
||||||
|
|
||||||
@@ -2101,6 +2196,28 @@ class AppLocalizationsEn 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';
|
||||||
@@ -2110,7 +2227,8 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get trackConvertFormat => 'Convert Format';
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
String get trackConvertFormatSubtitle =>
|
||||||
|
'Convert to MP3, Opus, ALAC, or FLAC';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertTitle => 'Convert Audio';
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
@@ -2133,6 +2251,18 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
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 +2516,17 @@ class AppLocalizationsEn 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 +2549,418 @@ class AppLocalizationsEn 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';
|
||||||
}
|
}
|
||||||
|
|||||||
+2986
-2137
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';
|
||||||
|
|
||||||
@@ -1197,6 +1200,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 store';
|
||||||
|
|
||||||
|
@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,6 +1391,38 @@ 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';
|
||||||
@@ -1653,6 +1729,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 +1924,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 +2198,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 +2252,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 +2517,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 +2550,418 @@ 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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -1195,6 +1198,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 store';
|
||||||
|
|
||||||
|
@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,6 +1389,38 @@ 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';
|
||||||
@@ -1651,6 +1727,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 +1922,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 +2196,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 +2250,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 +2515,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 +2548,418 @@ 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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -359,7 +359,7 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutAppDescription =>
|
String get aboutAppDescription =>
|
||||||
'Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.';
|
'Unduh lagu-lagu Spotify dalam kualitas lossless dari Tidal dan Qobuz.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get artistAlbums => 'Album';
|
String get artistAlbums => 'Album';
|
||||||
@@ -528,6 +528,9 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get dialogImport => 'Impor';
|
String get dialogImport => 'Impor';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Buang';
|
String get dialogDiscard => 'Buang';
|
||||||
|
|
||||||
@@ -766,21 +769,21 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get filenameFormat => 'Format Nama File';
|
String get filenameFormat => 'Format Nama File';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get filenameShowAdvancedTags => 'Show advanced tags';
|
String get filenameShowAdvancedTags => 'Tampilkan tag lanjutan';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get filenameShowAdvancedTagsDescription =>
|
String get filenameShowAdvancedTagsDescription =>
|
||||||
'Enable formatted tags for track padding and date patterns';
|
'Aktifkan tag yang diformat untuk padding trek dan pola tanggal';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganizationNone => 'Tidak ada';
|
String get folderOrganizationNone => 'Tidak ada';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganizationByPlaylist => 'By Playlist';
|
String get folderOrganizationByPlaylist => 'Berdasarkan Daftar Putar';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganizationByPlaylistSubtitle =>
|
String get folderOrganizationByPlaylistSubtitle =>
|
||||||
'Separate folder for each playlist';
|
'Setiap daftar putar memerlukan folder terpisah';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganizationByArtist => 'Berdasarkan Artis';
|
String get folderOrganizationByArtist => 'Berdasarkan Artis';
|
||||||
@@ -936,13 +939,13 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
'Masukkan Client ID dan Secret Anda untuk menggunakan kuota aplikasi Spotify Anda sendiri.';
|
'Masukkan Client ID dan Secret Anda untuk menggunakan kuota aplikasi Spotify Anda sendiri.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get credentialsClientId => 'Client ID';
|
String get credentialsClientId => 'ID Klien';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get credentialsClientIdHint => 'Tempel Client ID';
|
String get credentialsClientIdHint => 'Tempel Client ID';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get credentialsClientSecret => 'Client Secret';
|
String get credentialsClientSecret => 'Rahasia Klien';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get credentialsClientSecretHint => 'Tempel Client Secret';
|
String get credentialsClientSecretHint => 'Tempel Client Secret';
|
||||||
@@ -951,7 +954,7 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get channelStable => 'Stabil';
|
String get channelStable => 'Stabil';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get channelPreview => 'Preview';
|
String get channelPreview => 'Pratinjau';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get sectionSearchSource => 'Sumber Pencarian';
|
String get sectionSearchSource => 'Sumber Pencarian';
|
||||||
@@ -981,33 +984,34 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get sectionFileSettings => 'Pengaturan File';
|
String get sectionFileSettings => 'Pengaturan File';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get sectionLyrics => 'Lyrics';
|
String get sectionLyrics => 'Lirik';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get lyricsMode => 'Lyrics Mode';
|
String get lyricsMode => 'Mode Lirik';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get lyricsModeDescription =>
|
String get lyricsModeDescription =>
|
||||||
'Choose how lyrics are saved with your downloads';
|
'Pilih cara lirik disimpan bersama unduhan Anda';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get lyricsModeEmbed => 'Embed in file';
|
String get lyricsModeEmbed => 'Sematkan dalam file';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
|
String get lyricsModeEmbedSubtitle =>
|
||||||
|
'Lirik tersimpan di dalam metadata FLAC';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get lyricsModeExternal => 'External .lrc file';
|
String get lyricsModeExternal => 'File .lrc eksternal';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get lyricsModeExternalSubtitle =>
|
String get lyricsModeExternalSubtitle =>
|
||||||
'Separate .lrc file for players like Samsung Music';
|
'File .lrc terpisah untuk pemutar musik seperti Samsung Music';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get lyricsModeBoth => 'Both';
|
String get lyricsModeBoth => 'Keduanya';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
|
String get lyricsModeBothSubtitle => 'Sematkan dan simpan file .lrc';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get sectionColor => 'Warna';
|
String get sectionColor => 'Warna';
|
||||||
@@ -1119,10 +1123,10 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get trackGenre => 'Genre';
|
String get trackGenre => 'Genre';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackLabel => 'Label';
|
String get trackLabel => 'Lebel';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackCopyright => 'Copyright';
|
String get trackCopyright => 'Hak cipta';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackDownloaded => 'Diunduh';
|
String get trackDownloaded => 'Diunduh';
|
||||||
@@ -1140,13 +1144,13 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get trackLyricsLoadFailed => 'Gagal memuat lirik';
|
String get trackLyricsLoadFailed => 'Gagal memuat lirik';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackEmbedLyrics => 'Embed Lyrics';
|
String get trackEmbedLyrics => 'Sematkan Lirik';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
String get trackLyricsEmbedded => 'Lirik berhasil disematkan';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackInstrumental => 'Instrumental track';
|
String get trackInstrumental => 'Lagu instrumental';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackCopiedToClipboard => 'Disalin ke clipboard';
|
String get trackCopiedToClipboard => 'Disalin ke clipboard';
|
||||||
@@ -1201,7 +1205,48 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get storeClearFilters => 'Hapus filter';
|
String get storeClearFilters => 'Hapus filter';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
|
String get storeAddRepoTitle => 'Add Extension Repository';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeAddRepoDescription =>
|
||||||
|
'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRepoUrlLabel => 'Repository URL';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRepoUrlHint => 'https://github.com/user/repo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRepoUrlHelper =>
|
||||||
|
'e.g. https://github.com/user/extensions-repo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeAddRepoButton => 'Add Repository';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeChangeRepoTooltip => 'Change repository';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRepoDialogTitle => 'Extension Repository';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRepoDialogCurrent => 'Current repository:';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeLoadError => 'Failed to load store';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeEmptyNoResults => 'No extensions found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionDefaultProvider => 'Bawaan (Deezer/Spotify)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionDefaultProviderSubtitle => 'Gunakan pencarian bawaan';
|
String get extensionDefaultProviderSubtitle => 'Gunakan pencarian bawaan';
|
||||||
@@ -1213,7 +1258,7 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get extensionId => 'ID';
|
String get extensionId => 'ID';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionError => 'Error';
|
String get extensionError => 'Terjadi kesalahan';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionCapabilities => 'Kemampuan';
|
String get extensionCapabilities => 'Kemampuan';
|
||||||
@@ -1352,19 +1397,51 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get qualityHiResFlacMaxSubtitle => '24-bit / hingga 192kHz';
|
String get qualityHiResFlacMaxSubtitle => '24-bit / hingga 192kHz';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossy320 => 'Lossy 320kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyFormat => 'Lossy Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossy320FormatDesc =>
|
||||||
|
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus256Subtitle =>
|
||||||
|
'Best quality Opus, ~8MB per track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan';
|
'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get youtubeQualityNote =>
|
String get youtubeQualityNote =>
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
'YouTube hanya menyediakan audio terkompresi (lossy). Bukan bagian dari fallback lossless.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
String get youtubeOpusBitrateTitle => 'Bitrate YouTube Opus';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
String get youtubeMp3BitrateTitle => 'Kecepatan Bit MP3 YouTube';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
|
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
|
||||||
@@ -1379,18 +1456,19 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get downloadAlbumFolderStructure => 'Struktur Folder Album';
|
String get downloadAlbumFolderStructure => 'Struktur Folder Album';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
String get downloadUseAlbumArtistForFolders =>
|
||||||
|
'Gunakan Artis Album untuk folder';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
String get downloadUsePrimaryArtistOnly => 'Hanya artis utama untuk folder';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
'Artis unggulan dihapus dari nama folder (misalnya Justin Bieber, Quavo → Justin Bieber)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||||
'Full artist string used for folder name';
|
'Nama lengkap artis digunakan untuk nama folder';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectQuality => 'Pilih Kualitas';
|
String get downloadSelectQuality => 'Pilih Kualitas';
|
||||||
@@ -1412,24 +1490,24 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
'Apakah Anda yakin ingin menghapus semua unduhan?';
|
'Apakah Anda yakin ingin menghapus semua unduhan?';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
String get settingsAutoExportFailed => 'Unduhan yang gagal diekspor otomatis';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsAutoExportFailedSubtitle =>
|
String get settingsAutoExportFailedSubtitle =>
|
||||||
'Save failed downloads to TXT file automatically';
|
'Simpan unduhan yang gagal ke file TXT secara otomatis';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsDownloadNetwork => 'Download Network';
|
String get settingsDownloadNetwork => 'Jaringan Unduhan';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
String get settingsDownloadNetworkAny => 'WiFi + Data Seluler';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
String get settingsDownloadNetworkWifiOnly => 'Hanya WiFi';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsDownloadNetworkSubtitle =>
|
String get settingsDownloadNetworkSubtitle =>
|
||||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
'Pilih jaringan mana yang akan digunakan untuk mengunduh. Jika diatur ke Hanya WiFi, unduhan akan berhenti sementara dan menggunakan data seluler.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get albumFolderArtistAlbum => 'Artis / Album';
|
String get albumFolderArtistAlbum => 'Artis / Album';
|
||||||
@@ -1457,11 +1535,11 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Nama Album/';
|
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Nama Album/';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
String get albumFolderArtistAlbumSingles => 'Artis / Album + Singel';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artis/Album/ dan Artis/Single/';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih';
|
String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih';
|
||||||
@@ -1517,21 +1595,21 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get recentTypeSong => 'Lagu';
|
String get recentTypeSong => 'Lagu';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recentTypePlaylist => 'Playlist';
|
String get recentTypePlaylist => 'Daftar putar';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recentEmpty => 'No recent items yet';
|
String get recentEmpty => 'Belum ada item terbaru';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recentShowAllDownloads => 'Show All Downloads';
|
String get recentShowAllDownloads => 'Tampilkan Semua Unduhan';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String recentPlaylistInfo(String name) {
|
String recentPlaylistInfo(String name) {
|
||||||
return 'Playlist: $name';
|
return 'Daftar Putar: $name';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get discographyDownload => 'Download Discography';
|
String get discographyDownload => 'Unduh Diskografi';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get discographyDownloadAll => 'Unduh Semua';
|
String get discographyDownloadAll => 'Unduh Semua';
|
||||||
@@ -1658,6 +1736,25 @@ class AppLocalizationsId 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';
|
||||||
|
|
||||||
@@ -1822,44 +1919,44 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
String get tutorialWelcomeTitle => 'Selamat Datang di SpotiFLAC!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeDesc =>
|
String get tutorialWelcomeDesc =>
|
||||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
'Mari kita pelajari cara mengunduh musik favorit Anda dalam kualitas lossless. Tutorial singkat ini akan menunjukkan dasar-dasarnya.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip1 =>
|
String get tutorialWelcomeTip1 =>
|
||||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
'Unduh musik dari Spotify, Deezer, atau tempel URL yang didukung';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip2 =>
|
String get tutorialWelcomeTip2 =>
|
||||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
'Dapatkan audio berkualitas FLAC dari Tidal, Qobuz, atau Deezer';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip3 =>
|
String get tutorialWelcomeTip3 =>
|
||||||
'Automatic metadata, cover art, and lyrics embedding';
|
'Penyematan metadata, sampul album, dan lirik secara otomatis';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialSearchTitle => 'Finding Music';
|
String get tutorialSearchTitle => 'Menemukan Musik';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialSearchDesc =>
|
String get tutorialSearchDesc =>
|
||||||
'There are two easy ways to find music you want to download.';
|
'Ada dua cara mudah untuk menemukan musik yang ingin Anda unduh.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialDownloadTitle => 'Downloading Music';
|
String get tutorialDownloadTitle => 'Mengunduh Musik';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialDownloadDesc =>
|
String get tutorialDownloadDesc =>
|
||||||
'Downloading music is simple and fast. Here\'s how it works.';
|
'Mengunduh musik itu mudah dan cepat. Begini cara kerjanya.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialLibraryTitle => 'Your Library';
|
String get tutorialLibraryTitle => 'Perpustakaan Anda';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialLibraryDesc =>
|
String get tutorialLibraryDesc =>
|
||||||
'All your downloaded music is organized in the Library tab.';
|
'Semua musik yang Anda unduh tersusun rapi di tab Perpustakaan.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialLibraryTip1 =>
|
String get tutorialLibraryTip1 =>
|
||||||
@@ -2108,6 +2205,28 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFlacAction => 'Antrekan FLAC';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueFlacConfirmMessage(int count) {
|
||||||
|
return 'Cari kecocokan online untuk track yang dipilih lalu antrekan download FLAC.\n\nFile yang sudah ada tidak akan diubah atau dihapus.\n\nHanya kecocokan dengan keyakinan tinggi yang akan diantrikan otomatis.\n\n$count dipilih';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueFlacFindingProgress(int current, int total) {
|
||||||
|
return 'Mencari kecocokan FLAC... ($current/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFlacNoReliableMatches =>
|
||||||
|
'Tidak ada kecocokan online yang cukup meyakinkan untuk pilihan ini';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||||
|
return 'Menambahkan $addedCount track ke antrean, melewati $skippedCount';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
@@ -2117,7 +2236,8 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get trackConvertFormat => 'Convert Format';
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
String get trackConvertFormatSubtitle =>
|
||||||
|
'Konversi ke MP3, Opus, ALAC, atau FLAC';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertTitle => 'Convert Audio';
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
@@ -2140,6 +2260,18 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessageLossless(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
) {
|
||||||
|
return 'Konversi dari $sourceFormat ke $targetFormat? (Lossless — tanpa kehilangan kualitas)\n\nFile asli akan dihapus setelah konversi.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertLosslessHint =>
|
||||||
|
'Konversi lossless — tanpa kehilangan kualitas';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertConverting => 'Converting audio...';
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
@@ -2393,6 +2525,17 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
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...';
|
||||||
@@ -2415,4 +2558,418 @@ class AppLocalizationsId 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 =>
|
||||||
|
'Buat folder sumber playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||||
|
'Unduhan dari playlist memakai Playlist/ lalu struktur folder normal Anda.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||||
|
'Unduhan dari playlist hanya memakai struktur folder normal.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||||
|
'Mode Berdasarkan Playlist sudah menaruh unduhan ke dalam folder playlist.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSongLinkRegion => 'SongLink Region';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNetworkCompatibilityModeEnabled =>
|
||||||
|
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNetworkCompatibilityModeDisabled =>
|
||||||
|
'Off: strict HTTPS certificate validation (recommended)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSelectServiceToEnable =>
|
||||||
|
'Select a built-in service to enable';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSelectTidalQobuz =>
|
||||||
|
'Select Tidal or Qobuz above to configure quality';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadEmbedLyricsDisabled =>
|
||||||
|
'Disabled while Embed Metadata is turned off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNeteaseIncludeTranslation =>
|
||||||
|
'Netease: Include Translation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNeteaseIncludeTranslationEnabled =>
|
||||||
|
'Append translated lyrics when available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNeteaseIncludeTranslationDisabled =>
|
||||||
|
'Use original lyrics only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNeteaseIncludeRomanization =>
|
||||||
|
'Netease: Include Romanization';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNeteaseIncludeRomanizationEnabled =>
|
||||||
|
'Append romanized lyrics when available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAppleQqMultiPersonEnabled =>
|
||||||
|
'Enable v1/v2 speaker and [bg:] tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAppleQqMultiPersonDisabled =>
|
||||||
|
'Simplified word-by-word formatting';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadFilterContributing =>
|
||||||
|
'Filter contributing artists in Album Artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadFilterContributingEnabled =>
|
||||||
|
'Album Artist metadata uses primary artist only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadFilterContributingDisabled =>
|
||||||
|
'Keep full Album Artist metadata value';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadProvidersNoneEnabled => 'None enabled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadMusixmatchLanguageCode => 'Language code';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadMusixmatchLanguageDesc =>
|
||||||
|
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadMusixmatchAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNetworkWifiOnlySubtitle =>
|
||||||
|
'Pause downloads on mobile data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSongLinkRegionDesc =>
|
||||||
|
'Used as userCountry for SongLink API lookup.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheRefresh => 'Refresh';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
trackCount,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
String _temp1 = intl.Intl.pluralLogic(
|
||||||
|
playlistCount,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'playlists',
|
||||||
|
one: 'playlist',
|
||||||
|
);
|
||||||
|
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String bulkDownloadPlaylistsButton(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'playlists',
|
||||||
|
one: 'playlist',
|
||||||
|
);
|
||||||
|
return 'Download $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarSelectedPlaylistsEmpty =>
|
||||||
|
'Selected playlists have no tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playlistsCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count playlists',
|
||||||
|
one: '1 playlist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataAutoFillDesc =>
|
||||||
|
'Select fields to fill automatically from online metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataAutoFillNoResults =>
|
||||||
|
'No matching metadata found online';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String editMetadataAutoFillDone(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'fields',
|
||||||
|
one: 'field',
|
||||||
|
);
|
||||||
|
return 'Filled $count $_temp0 from online metadata';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataAutoFillNoneSelected =>
|
||||||
|
'Select at least one field to auto-fill';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldTitle => 'Title';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldArtist => 'Artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldAlbum => 'Album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldDate => 'Date';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldTrackNum => 'Track #';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldDiscNum => 'Disc #';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldGenre => 'Genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldIsrc => 'ISRC';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldLabel => 'Label';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldCopyright => 'Copyright';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldCover => 'Cover Art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataSelectAll => 'All';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataSelectEmpty => 'Empty only';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -352,7 +352,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@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 => 'アルバム';
|
||||||
@@ -521,6 +521,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get dialogImport => 'インポート';
|
String get dialogImport => 'インポート';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => '破棄';
|
String get dialogDiscard => '破棄';
|
||||||
|
|
||||||
@@ -758,7 +761,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get filenameFormat => 'ファイル名の形式';
|
String get filenameFormat => 'ファイル名の形式';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get filenameShowAdvancedTags => 'Show advanced tags';
|
String get filenameShowAdvancedTags => '高度なタグを表示';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get filenameShowAdvancedTagsDescription =>
|
String get filenameShowAdvancedTagsDescription =>
|
||||||
@@ -1135,7 +1138,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackInstrumental => 'Instrumental track';
|
String get trackInstrumental => 'インストゥルメンタルのトラック';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackCopiedToClipboard => 'クリップボードにコピーしました';
|
String get trackCopiedToClipboard => 'クリップボードにコピーしました';
|
||||||
@@ -1189,6 +1192,47 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get storeClearFilters => 'フィルターを消去';
|
String get storeClearFilters => 'フィルターを消去';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeAddRepoTitle => 'Add Extension Repository';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeAddRepoDescription =>
|
||||||
|
'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRepoUrlLabel => 'Repository URL';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRepoUrlHint => 'https://github.com/user/repo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRepoUrlHelper =>
|
||||||
|
'e.g. https://github.com/user/extensions-repo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeAddRepoButton => 'Add Repository';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeChangeRepoTooltip => 'Change repository';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRepoDialogTitle => 'Extension Repository';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRepoDialogCurrent => 'Current repository:';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeLoadError => 'Failed to load store';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeEmptyNoResults => 'No extensions found';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionDefaultProvider => 'デフォルト (Deezer/Spotify)';
|
String get extensionDefaultProvider => 'デフォルト (Deezer/Spotify)';
|
||||||
|
|
||||||
@@ -1335,6 +1379,38 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get qualityHiResFlacMaxSubtitle => '24-bit / 最大 192kHz';
|
String get qualityHiResFlacMaxSubtitle => '24-bit / 最大 192kHz';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossy320 => 'Lossy 320kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyFormat => 'Lossy Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossy320FormatDesc =>
|
||||||
|
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus256Subtitle =>
|
||||||
|
'Best quality Opus, ~8MB per track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します';
|
String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します';
|
||||||
|
|
||||||
@@ -1638,6 +1714,25 @@ class AppLocalizationsJa 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 => 'アクション';
|
String get libraryActions => 'アクション';
|
||||||
|
|
||||||
@@ -1814,7 +1909,7 @@ class AppLocalizationsJa 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 =>
|
||||||
@@ -2088,6 +2183,28 @@ class AppLocalizationsJa 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 '失敗: $error';
|
return '失敗: $error';
|
||||||
@@ -2120,6 +2237,18 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
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 => 'オーディオを変換中...';
|
String get trackConvertConverting => 'オーディオを変換中...';
|
||||||
|
|
||||||
@@ -2132,7 +2261,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get trackConvertFailed => '変換に失敗しました';
|
String get trackConvertFailed => '変換に失敗しました';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cueSplitTitle => 'Split CUE Sheet';
|
String get cueSplitTitle => '分割 CUE シート';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
|
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
|
||||||
@@ -2282,7 +2411,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get collectionRemoveFromPlaylist => 'Remove from playlist';
|
String get collectionRemoveFromPlaylist => 'Remove from playlist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionRemoveFromFolder => 'Remove from folder';
|
String get collectionRemoveFromFolder => 'フォルダから削除';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String collectionRemoved(String trackName) {
|
String collectionRemoved(String trackName) {
|
||||||
@@ -2316,26 +2445,26 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get trackOptionRemoveFromLoved => 'Remove from Loved';
|
String get trackOptionRemoveFromLoved => 'Remove from Loved';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackOptionAddToWishlist => 'Add to Wishlist';
|
String get trackOptionAddToWishlist => 'ウィッシュリストに追加';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
|
String get trackOptionRemoveFromWishlist => 'ウィッシュから削除';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionPlaylistChangeCover => 'Change cover image';
|
String get collectionPlaylistChangeCover => 'カバー画像を変更';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get collectionPlaylistRemoveCover => 'Remove cover image';
|
String get collectionPlaylistRemoveCover => 'カバー画像を削除';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String selectionShareCount(int count) {
|
String selectionShareCount(int count) {
|
||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
count,
|
count,
|
||||||
locale: localeName,
|
locale: localeName,
|
||||||
other: 'tracks',
|
other: '個のトラック',
|
||||||
one: 'track',
|
one: '個のトラック',
|
||||||
);
|
);
|
||||||
return 'Share $count $_temp0';
|
return '$count $_temp0を共有';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -2356,7 +2485,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
String get selectionBatchConvertConfirmTitle => '一括変換';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String selectionBatchConvertConfirmMessage(
|
String selectionBatchConvertConfirmMessage(
|
||||||
@@ -2373,6 +2502,17 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
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...';
|
||||||
@@ -2395,4 +2535,418 @@ class AppLocalizationsJa 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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 => '취소';
|
||||||
|
|
||||||
@@ -1175,6 +1178,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 store';
|
||||||
|
|
||||||
|
@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,6 +1369,38 @@ 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';
|
||||||
@@ -1631,6 +1707,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 +1902,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 +2176,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 +2230,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 +2495,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 +2528,418 @@ 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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -1195,6 +1198,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 store';
|
||||||
|
|
||||||
|
@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,6 +1389,38 @@ 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';
|
||||||
@@ -1651,6 +1727,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 +1922,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 +2196,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 +2250,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 +2515,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 +2548,418 @@ 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';
|
||||||
}
|
}
|
||||||
|
|||||||
+2983
-2134
File diff suppressed because it is too large
Load Diff
@@ -363,7 +363,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutAppDescription =>
|
String get aboutAppDescription =>
|
||||||
'Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.';
|
'Скачивайте треки Spotify в lossless качестве с Tidal и Qobuz.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get artistAlbums => 'Альбомы';
|
String get artistAlbums => 'Альбомы';
|
||||||
@@ -534,6 +534,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get dialogImport => 'Импорт';
|
String get dialogImport => 'Импорт';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Отменить';
|
String get dialogDiscard => 'Отменить';
|
||||||
|
|
||||||
@@ -703,15 +706,15 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get errorNoTracksFound => 'Треки не найдены';
|
String get errorNoTracksFound => 'Треки не найдены';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get errorUrlNotRecognized => 'Link not recognized';
|
String get errorUrlNotRecognized => 'Ссылка не распознана';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get errorUrlNotRecognizedMessage =>
|
String get errorUrlNotRecognizedMessage =>
|
||||||
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
|
'Эта ссылка не поддерживается. Убедитесь, что URL-адрес указан правильно и установлено совместимое расширение.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get errorUrlFetchFailed =>
|
String get errorUrlFetchFailed =>
|
||||||
'Failed to load content from this link. Please try again.';
|
'Не удалось загрузить контент по этой ссылке. Пожалуйста, попробуйте еще раз.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String errorMissingExtensionSource(String item) {
|
String errorMissingExtensionSource(String item) {
|
||||||
@@ -787,11 +790,11 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get folderOrganizationNone => 'Без организации';
|
String get folderOrganizationNone => 'Без организации';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganizationByPlaylist => 'By Playlist';
|
String get folderOrganizationByPlaylist => 'По плейлисту';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganizationByPlaylistSubtitle =>
|
String get folderOrganizationByPlaylistSubtitle =>
|
||||||
'Separate folder for each playlist';
|
'Отдельная папка для каждого плейлиста';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganizationByArtist => 'По исполнителю';
|
String get folderOrganizationByArtist => 'По исполнителю';
|
||||||
@@ -1216,6 +1219,47 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get storeClearFilters => 'Очистить фильтры';
|
String get storeClearFilters => 'Очистить фильтры';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeAddRepoTitle => 'Add Extension Repository';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeAddRepoDescription =>
|
||||||
|
'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRepoUrlLabel => 'Repository URL';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRepoUrlHint => 'https://github.com/user/repo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRepoUrlHelper =>
|
||||||
|
'e.g. https://github.com/user/extensions-repo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeAddRepoButton => 'Add Repository';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeChangeRepoTooltip => 'Change repository';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRepoDialogTitle => 'Extension Repository';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeRepoDialogCurrent => 'Current repository:';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeLoadError => 'Failed to load store';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeEmptyNoResults => 'No extensions found';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionDefaultProvider => 'По умолчанию (Deezer/Spotify)';
|
String get extensionDefaultProvider => 'По умолчанию (Deezer/Spotify)';
|
||||||
|
|
||||||
@@ -1370,6 +1414,38 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get qualityHiResFlacMaxSubtitle => '24-бит / до 192кГц';
|
String get qualityHiResFlacMaxSubtitle => '24-бит / до 192кГц';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossy320 => 'Lossy 320kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyFormat => 'Lossy Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossy320FormatDesc =>
|
||||||
|
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus256Subtitle =>
|
||||||
|
'Best quality Opus, ~8MB per track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Фактическое качество зависит от доступности треков в сервисе';
|
'Фактическое качество зависит от доступности треков в сервисе';
|
||||||
@@ -1406,7 +1482,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
'Список исполнителей, чьи работы были удалены из названия папки (например, Джастин Бибер, Quavo → Джастин Бибер)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||||
@@ -1687,6 +1763,25 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||||
'Показать при поиске существующих треков';
|
'Показать при поиске существующих треков';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScan => 'Auto Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanSubtitle =>
|
||||||
|
'Automatically scan your library for new files';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanOff => 'Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanOnOpen => 'Every app open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanDaily => 'Daily';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanWeekly => 'Weekly';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryActions => 'Действия';
|
String get libraryActions => 'Действия';
|
||||||
|
|
||||||
@@ -1877,7 +1972,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip2 =>
|
String get tutorialWelcomeTip2 =>
|
||||||
'Скачайте FLAC с Tidal, Qobuz или Amazon Music';
|
'Получите аудио в качестве FLAC от Tidal, Qobuz или Deezer';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip3 =>
|
String get tutorialWelcomeTip3 =>
|
||||||
@@ -1973,7 +2068,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String cleanupOrphanedDownloadsResult(int count) {
|
String cleanupOrphanedDownloadsResult(int count) {
|
||||||
return 'Removed $count orphaned entries from history';
|
return 'Удалено $count утерянных записей из истории';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -1998,7 +2093,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get cacheSectionStorage => 'Кэшированные данные';
|
String get cacheSectionStorage => 'Кэшированные данные';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheSectionMaintenance => 'Maintenance';
|
String get cacheSectionMaintenance => 'Обслуживание';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheAppDirectory => 'Папка кэша приложения';
|
String get cacheAppDirectory => 'Папка кэша приложения';
|
||||||
@@ -2044,7 +2139,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheCleanupUnusedDesc =>
|
String get cacheCleanupUnusedDesc =>
|
||||||
'Remove orphaned download history and library entries for missing files.';
|
'Удалить записи из истории загрузок и библиотеки, которые остались без файлов.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheNoData => 'Нет кэшированных данных';
|
String get cacheNoData => 'Нет кэшированных данных';
|
||||||
@@ -2092,7 +2187,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheCleanupUnusedSubtitle =>
|
String get cacheCleanupUnusedSubtitle =>
|
||||||
'Remove orphaned download history and missing library entries';
|
'Удалить историю загрузок, оставшихся без просмотра, и отсутствующие записи в библиотеке';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||||
@@ -2154,6 +2249,28 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get trackReEnrichFfmpegFailed =>
|
String get trackReEnrichFfmpegFailed =>
|
||||||
'Ошибка встраивания метаданных FFmpeg';
|
'Ошибка встраивания метаданных FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFlacAction => 'Queue FLAC';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueFlacConfirmMessage(int count) {
|
||||||
|
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueFlacFindingProgress(int current, int total) {
|
||||||
|
return 'Finding FLAC matches... ($current/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFlacNoReliableMatches =>
|
||||||
|
'No reliable online matches found for the selection';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||||
|
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Ошибка: $error';
|
return 'Ошибка: $error';
|
||||||
@@ -2186,6 +2303,18 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
return 'Конвертировать из $sourceFormat в $targetFormat $bitrate?\n\nОригинальный файл будет удален после конвертации.';
|
return 'Конвертировать из $sourceFormat в $targetFormat $bitrate?\n\nОригинальный файл будет удален после конвертации.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessageLossless(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertLosslessHint =>
|
||||||
|
'Lossless conversion — no quality loss';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertConverting => 'Конвертация аудио...';
|
String get trackConvertConverting => 'Конвертация аудио...';
|
||||||
|
|
||||||
@@ -2198,52 +2327,52 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get trackConvertFailed => 'Ошибка конвертации';
|
String get trackConvertFailed => 'Ошибка конвертации';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cueSplitTitle => 'Split CUE Sheet';
|
String get cueSplitTitle => 'Разделить CUE Sheet';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
|
String get cueSplitSubtitle => 'Разделить файл CUE+FLAC на отдельные треки';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String cueSplitAlbum(String album) {
|
String cueSplitAlbum(String album) {
|
||||||
return 'Album: $album';
|
return 'Альбом: $album';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String cueSplitArtist(String artist) {
|
String cueSplitArtist(String artist) {
|
||||||
return 'Artist: $artist';
|
return 'Артист: $artist';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String cueSplitTrackCount(int count) {
|
String cueSplitTrackCount(int count) {
|
||||||
return '$count tracks';
|
return '$count треков';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cueSplitConfirmTitle => 'Split CUE Album';
|
String get cueSplitConfirmTitle => 'Разделенный CUE-альбом';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String cueSplitConfirmMessage(String album, int count) {
|
String cueSplitConfirmMessage(String album, int count) {
|
||||||
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
|
return 'Разбить \"$album\" на $count отдельных FLAC-файлов?';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String cueSplitSplitting(int current, int total) {
|
String cueSplitSplitting(int current, int total) {
|
||||||
return 'Splitting CUE sheet... ($current/$total)';
|
return 'Разделение CUE sheet... ($current/$total)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String cueSplitSuccess(int count) {
|
String cueSplitSuccess(int count) {
|
||||||
return 'Split into $count tracks successfully';
|
return 'Успешно разделено на $count треков';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cueSplitFailed => 'CUE split failed';
|
String get cueSplitFailed => 'Разделение CUE не удалось';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
|
String get cueSplitNoAudioFile => 'Аудиофайл для этого CUE sheet не найден';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cueSplitButton => 'Split into Tracks';
|
String get cueSplitButton => 'Разделить на Треки';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get actionCreate => 'Создать';
|
String get actionCreate => 'Создать';
|
||||||
@@ -2409,7 +2538,8 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get selectionShareNoFiles => 'No shareable files found';
|
String get selectionShareNoFiles =>
|
||||||
|
'Файлы, доступные для совместного доступа, не найдены';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String selectionConvertCount(int count) {
|
String selectionConvertCount(int count) {
|
||||||
@@ -2442,7 +2572,18 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
other: 'tracks',
|
other: 'tracks',
|
||||||
one: 'track',
|
one: 'track',
|
||||||
);
|
);
|
||||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
return 'Преобразовать $count $_temp0 в $format с $bitrate?';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -2467,4 +2608,418 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
'Папки исполнителя используют только трек исполнителя';
|
'Папки исполнителя используют только трек исполнителя';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsProvidersTitle => 'Lyrics Providers';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsProvidersDescription =>
|
||||||
|
'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsProvidersInfoText =>
|
||||||
|
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String lyricsProvidersEnabledSection(int count) {
|
||||||
|
return 'Enabled ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String lyricsProvidersDisabledSection(int count) {
|
||||||
|
return 'Disabled ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsProvidersAtLeastOne =>
|
||||||
|
'At least one provider must remain enabled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsProvidersSaved => 'Lyrics provider priority saved';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsProvidersDiscardContent =>
|
||||||
|
'You have unsaved changes that will be lost.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsProviderSpotifyApiDesc =>
|
||||||
|
'Spotify-sourced synced lyrics via community API';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsProviderNeteaseDesc =>
|
||||||
|
'NetEase Cloud Music (good for Asian songs)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsProviderMusixmatchDesc =>
|
||||||
|
'Largest lyrics database (multi-language)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsProviderAppleMusicDesc =>
|
||||||
|
'Word-by-word synced lyrics (via proxy)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsProviderQqMusicDesc =>
|
||||||
|
'QQ Music (good for Chinese songs, via proxy)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsProviderExtensionDesc => 'Extension provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get safMigrationTitle => 'Storage Update Required';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get safMigrationMessage1 =>
|
||||||
|
'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get safMigrationMessage2 =>
|
||||||
|
'Please select your download folder again to switch to the new storage system.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get safMigrationSuccess => 'Download folder updated to SAF mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDonate => 'Donate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tooltipLoveAll => 'Love All';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tooltipAddToPlaylist => 'Add to Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarRemovedTracksFromLoved(int count) {
|
||||||
|
return 'Removed $count tracks from Loved';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarAddedTracksToLoved(int count) {
|
||||||
|
return 'Added $count tracks to Loved';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dialogDownloadAllTitle => 'Download All';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String dialogDownloadAllMessage(int count) {
|
||||||
|
return 'Download $count tracks?';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get homeGoToAlbum => 'Go to Album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get homeAlbumInfoUnavailable => 'Album info not available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarMetadataSaved => 'Metadata saved successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarError(String error) {
|
||||||
|
return 'Error: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarNoActionDefined => 'No action defined for this button';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noTracksFoundForAlbum => 'No tracks found for this album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLocationSubtitle =>
|
||||||
|
'Choose storage mode for downloaded files.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageModeAppFolder => 'App folder (non-SAF)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageModeSaf => 'SAF folder';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageModeSafSubtitle =>
|
||||||
|
'Pick folder via Android Storage Access Framework';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadFilenameDescription =>
|
||||||
|
'Customize how your files are named.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadFilenameInsertTag => 'Tap to insert tag:';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSeparateSinglesDisabled => 'All files in same structure';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadCreatePlaylistSourceFolder =>
|
||||||
|
'Create playlist source folder';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||||
|
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||||
|
'Playlist downloads use the normal folder structure only.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||||
|
'By Playlist already places downloads inside a playlist folder.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSongLinkRegion => 'SongLink Region';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNetworkCompatibilityModeEnabled =>
|
||||||
|
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNetworkCompatibilityModeDisabled =>
|
||||||
|
'Off: strict HTTPS certificate validation (recommended)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSelectServiceToEnable =>
|
||||||
|
'Select a built-in service to enable';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSelectTidalQobuz =>
|
||||||
|
'Select Tidal or Qobuz above to configure quality';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadEmbedLyricsDisabled =>
|
||||||
|
'Disabled while Embed Metadata is turned off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNeteaseIncludeTranslation =>
|
||||||
|
'Netease: Include Translation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNeteaseIncludeTranslationEnabled =>
|
||||||
|
'Append translated lyrics when available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNeteaseIncludeTranslationDisabled =>
|
||||||
|
'Use original lyrics only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNeteaseIncludeRomanization =>
|
||||||
|
'Netease: Include Romanization';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNeteaseIncludeRomanizationEnabled =>
|
||||||
|
'Append romanized lyrics when available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAppleQqMultiPersonEnabled =>
|
||||||
|
'Enable v1/v2 speaker and [bg:] tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAppleQqMultiPersonDisabled =>
|
||||||
|
'Simplified word-by-word formatting';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadFilterContributing =>
|
||||||
|
'Filter contributing artists in Album Artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadFilterContributingEnabled =>
|
||||||
|
'Album Artist metadata uses primary artist only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadFilterContributingDisabled =>
|
||||||
|
'Keep full Album Artist metadata value';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadProvidersNoneEnabled => 'None enabled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadMusixmatchLanguageCode => 'Language code';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadMusixmatchLanguageDesc =>
|
||||||
|
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadMusixmatchAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadNetworkWifiOnlySubtitle =>
|
||||||
|
'Pause downloads on mobile data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSongLinkRegionDesc =>
|
||||||
|
'Used as userCountry for SongLink API lookup.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheRefresh => 'Refresh';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
trackCount,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
String _temp1 = intl.Intl.pluralLogic(
|
||||||
|
playlistCount,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'playlists',
|
||||||
|
one: 'playlist',
|
||||||
|
);
|
||||||
|
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String bulkDownloadPlaylistsButton(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'playlists',
|
||||||
|
one: 'playlist',
|
||||||
|
);
|
||||||
|
return 'Download $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarSelectedPlaylistsEmpty =>
|
||||||
|
'Selected playlists have no tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String playlistsCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count playlists',
|
||||||
|
one: '1 playlist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataAutoFillDesc =>
|
||||||
|
'Select fields to fill automatically from online metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataAutoFillNoResults =>
|
||||||
|
'No matching metadata found online';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String editMetadataAutoFillDone(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'fields',
|
||||||
|
one: 'field',
|
||||||
|
);
|
||||||
|
return 'Filled $count $_temp0 from online metadata';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataAutoFillNoneSelected =>
|
||||||
|
'Select at least one field to auto-fill';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldTitle => 'Title';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldArtist => 'Artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldAlbum => 'Album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldDate => 'Date';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldTrackNum => 'Track #';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldDiscNum => 'Disc #';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldGenre => 'Genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldIsrc => 'ISRC';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldLabel => 'Label';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldCopyright => 'Copyright';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataFieldCover => 'Cover Art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataSelectAll => 'All';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataSelectEmpty => 'Empty only';
|
||||||
}
|
}
|
||||||
|
|||||||
+690
-142
File diff suppressed because it is too large
Load Diff
+5354
-4680
File diff suppressed because it is too large
Load Diff
+1479
-71
File diff suppressed because it is too large
Load Diff
+1355
-20
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+408
-2
@@ -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"
|
||||||
@@ -1749,6 +1777,14 @@
|
|||||||
"@youtubeQualityNote": {
|
"@youtubeQualityNote": {
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
"description": "Note for YouTube service explaining lossy-only quality"
|
||||||
},
|
},
|
||||||
|
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
||||||
|
"@youtubeOpusBitrateTitle": {
|
||||||
|
"description": "Title for YouTube Opus bitrate setting"
|
||||||
|
},
|
||||||
|
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
||||||
|
"@youtubeMp3BitrateTitle": {
|
||||||
|
"description": "Title for YouTube MP3 bitrate setting"
|
||||||
|
},
|
||||||
"downloadAskBeforeDownload": "Preguntar antes de descargar",
|
"downloadAskBeforeDownload": "Preguntar antes de descargar",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -2198,6 +2234,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 +2403,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 +2828,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",
|
||||||
|
|||||||
+1456
-48
File diff suppressed because it is too large
Load Diff
+1432
-24
File diff suppressed because it is too large
Load Diff
+1470
-74
File diff suppressed because it is too large
Load Diff
+1441
-33
File diff suppressed because it is too large
Load Diff
+1432
-24
File diff suppressed because it is too large
Load Diff
+1436
-28
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+408
-2
@@ -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"
|
||||||
@@ -1749,6 +1777,14 @@
|
|||||||
"@youtubeQualityNote": {
|
"@youtubeQualityNote": {
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
"description": "Note for YouTube service explaining lossy-only quality"
|
||||||
},
|
},
|
||||||
|
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
||||||
|
"@youtubeOpusBitrateTitle": {
|
||||||
|
"description": "Title for YouTube Opus bitrate setting"
|
||||||
|
},
|
||||||
|
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
||||||
|
"@youtubeMp3BitrateTitle": {
|
||||||
|
"description": "Title for YouTube MP3 bitrate setting"
|
||||||
|
},
|
||||||
"downloadAskBeforeDownload": "Perguntar qualidade antes de baixar",
|
"downloadAskBeforeDownload": "Perguntar qualidade antes de baixar",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -2198,6 +2234,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 +2403,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 +2828,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",
|
||||||
|
|||||||
+1450
-42
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+541
-135
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1525
-117
File diff suppressed because it is too large
Load Diff
+1433
-25
File diff suppressed because it is too large
Load Diff
+134
-3
@@ -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,142 @@ 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();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
_initializeAppServices();
|
_initializeAppServices();
|
||||||
_initializeExtensions();
|
_initializeExtensions();
|
||||||
ref.read(downloadHistoryProvider);
|
_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);
|
ref.read(localLibraryProvider);
|
||||||
ref.read(libraryCollectionsProvider);
|
// Trigger auto-scan after initial warmup on first app launch.
|
||||||
|
if (!_autoScanTriggeredOnLaunch) {
|
||||||
|
_autoScanTriggeredOnLaunch = true;
|
||||||
|
// Give the provider a moment to load existing data before scanning.
|
||||||
|
Future.delayed(const Duration(milliseconds: 500), () {
|
||||||
|
if (mounted) _maybeAutoScanLocalLibrary();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks whether an automatic incremental scan should be triggered based on
|
||||||
|
/// the user's auto-scan preference and the time since the last scan.
|
||||||
|
Future<void> _maybeAutoScanLocalLibrary() async {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
if (!settings.localLibraryEnabled) return;
|
||||||
|
if (settings.localLibraryPath.isEmpty) return;
|
||||||
|
if (settings.localLibraryAutoScan == 'off') return;
|
||||||
|
|
||||||
|
// Don't start a scan if one is already running.
|
||||||
|
final libraryState = ref.read(localLibraryProvider);
|
||||||
|
if (libraryState.isScanning) return;
|
||||||
|
|
||||||
|
// Determine cooldown based on auto-scan mode.
|
||||||
|
final now = DateTime.now();
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final lastScanned = readLocalLibraryLastScannedAt(prefs);
|
||||||
|
|
||||||
|
if (lastScanned != null) {
|
||||||
|
final elapsed = now.difference(lastScanned);
|
||||||
|
|
||||||
|
switch (settings.localLibraryAutoScan) {
|
||||||
|
case 'on_open':
|
||||||
|
// Cooldown of 10 minutes to prevent rapid re-scans.
|
||||||
|
if (elapsed.inMinutes < 10) return;
|
||||||
|
break;
|
||||||
|
case 'daily':
|
||||||
|
if (elapsed.inHours < 24) return;
|
||||||
|
break;
|
||||||
|
case 'weekly':
|
||||||
|
if (elapsed.inDays < 7) return;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All checks passed -- start an incremental scan.
|
||||||
|
final iosBookmark = settings.localLibraryBookmark;
|
||||||
|
ref
|
||||||
|
.read(localLibraryProvider.notifier)
|
||||||
|
.startScan(
|
||||||
|
settings.localLibraryPath,
|
||||||
|
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initializeAppServices() async {
|
Future<void> _initializeAppServices() async {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -41,7 +43,7 @@ class AppSettings {
|
|||||||
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
|
final int
|
||||||
youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256 kbps)
|
youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256/320 kbps)
|
||||||
final int
|
final int
|
||||||
youtubeMp3Bitrate; // YouTube MP3 bitrate (supported: 128/256/320 kbps)
|
youtubeMp3Bitrate; // YouTube MP3 bitrate (supported: 128/256/320 kbps)
|
||||||
final bool
|
final bool
|
||||||
@@ -61,6 +63,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 +100,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,6 +114,7 @@ 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,
|
||||||
@@ -126,6 +132,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 +166,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,6 +181,8 @@ 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,
|
||||||
@@ -190,6 +200,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 +226,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,6 +248,9 @@ 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,
|
||||||
@@ -256,6 +271,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',
|
||||||
@@ -58,6 +60,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 +103,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,6 +118,7 @@ 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,
|
||||||
@@ -131,6 +136,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,6 +4,7 @@ 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');
|
||||||
|
|
||||||
@@ -202,9 +203,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
|||||||
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');
|
||||||
@@ -237,16 +236,28 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
|||||||
|
|
||||||
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 user has a preference, use that
|
||||||
|
if (preferredId != null &&
|
||||||
|
preferredId.isNotEmpty &&
|
||||||
|
extension.id == preferredId) {
|
||||||
|
targetExt = extension;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Otherwise take the first available (fallback to spotify-web if found)
|
||||||
if (targetExt == null || extension.id == 'spotify-web') {
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -276,10 +287,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,7 +302,9 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
|||||||
|
|
||||||
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();
|
||||||
@@ -311,10 +321,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
|||||||
_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 +332,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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -504,6 +504,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 +524,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 +908,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 +927,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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -315,7 +312,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;
|
||||||
@@ -333,11 +329,16 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
if (items.isNotEmpty) {
|
if (items.isNotEmpty) {
|
||||||
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
|
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
|
||||||
}
|
}
|
||||||
|
final persistedItems =
|
||||||
|
(await _db.getAll())
|
||||||
|
.map(LocalLibraryItem.fromJson)
|
||||||
|
.toList(growable: false)
|
||||||
|
..sort(_compareLibraryItems);
|
||||||
|
|
||||||
final now = DateTime.now();
|
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 +346,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,11 +355,11 @@ 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,
|
||||||
);
|
);
|
||||||
@@ -379,19 +380,42 @@ 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;
|
||||||
|
final snapshotPath = useSnapshotBridge
|
||||||
|
? await _db.writeFileModTimesSnapshot()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
Map<String, dynamic> result;
|
||||||
|
try {
|
||||||
if (isSaf) {
|
if (isSaf) {
|
||||||
result = await PlatformBridge.scanSafTreeIncremental(
|
result = useSnapshotBridge && snapshotPath != null
|
||||||
|
? await PlatformBridge.scanSafTreeIncrementalFromSnapshot(
|
||||||
|
effectiveFolderPath,
|
||||||
|
snapshotPath,
|
||||||
|
)
|
||||||
|
: await PlatformBridge.scanSafTreeIncremental(
|
||||||
effectiveFolderPath,
|
effectiveFolderPath,
|
||||||
existingFiles,
|
existingFiles,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
result = await PlatformBridge.scanLibraryFolderIncremental(
|
result = useSnapshotBridge && snapshotPath != null
|
||||||
|
? await PlatformBridge.scanLibraryFolderIncrementalFromSnapshot(
|
||||||
|
effectiveFolderPath,
|
||||||
|
snapshotPath,
|
||||||
|
)
|
||||||
|
: await PlatformBridge.scanLibraryFolderIncremental(
|
||||||
effectiveFolderPath,
|
effectiveFolderPath,
|
||||||
existingFiles,
|
existingFiles,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
if (snapshotPath != null) {
|
||||||
|
try {
|
||||||
|
await File(snapshotPath).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (_scanCancelRequested) {
|
if (_scanCancelRequested) {
|
||||||
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
||||||
@@ -399,7 +423,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse incremental scan result
|
|
||||||
// SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths'
|
// 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>?) ??
|
||||||
@@ -421,8 +444,14 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total',
|
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Build the incremental merge base from SQLite, not the current
|
||||||
|
// provider state. Startup auto-scan can fire before `state.items` has
|
||||||
|
// finished loading, which would otherwise drop unchanged rows from the
|
||||||
|
// in-memory library until a manual full rescan.
|
||||||
|
final existingJson = await _db.getAll();
|
||||||
final currentByPath = <String, LocalLibraryItem>{
|
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, _) {
|
||||||
@@ -465,7 +494,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) {
|
||||||
@@ -474,13 +502,16 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
_log.i('Deleted $deleteCount items from database');
|
_log.i('Deleted $deleteCount items from database');
|
||||||
}
|
}
|
||||||
|
|
||||||
final items = currentByPath.values.toList(growable: false)
|
final items =
|
||||||
|
(await _db.getAll())
|
||||||
|
.map(LocalLibraryItem.fromJson)
|
||||||
|
.toList(growable: false)
|
||||||
..sort(_compareLibraryItems);
|
..sort(_compareLibraryItems);
|
||||||
|
|
||||||
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 +829,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');
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class RecentAccessItem {
|
|||||||
/// State for recent access history
|
/// 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({
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
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 = 6;
|
||||||
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> _youtubeOpusSupportedBitrates = [128, 256, 320];
|
||||||
static const List<int> _youtubeMp3SupportedBitrates = [128, 256, 320];
|
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}$');
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
state = AppSettings.fromJson(jsonDecode(json));
|
state = AppSettings.fromJson(jsonDecode(json));
|
||||||
|
|
||||||
await _runMigrations(prefs);
|
await _runMigrations(prefs);
|
||||||
|
await _normalizeIosDownloadDirectoryIfNeeded();
|
||||||
await _normalizeYouTubeBitratesIfNeeded();
|
await _normalizeYouTubeBitratesIfNeeded();
|
||||||
await _normalizeSongLinkRegionIfNeeded();
|
await _normalizeSongLinkRegionIfNeeded();
|
||||||
}
|
}
|
||||||
@@ -50,6 +53,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _syncLyricsSettingsToBackend() {
|
void _syncLyricsSettingsToBackend() {
|
||||||
|
if (!PlatformBridge.supportsCoreBackend) return;
|
||||||
|
|
||||||
PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError((e) {
|
PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError((e) {
|
||||||
_log.w('Failed to sync lyrics providers to backend: $e');
|
_log.w('Failed to sync lyrics providers to backend: $e');
|
||||||
});
|
});
|
||||||
@@ -65,6 +70,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _syncNetworkCompatibilitySettingsToBackend() {
|
void _syncNetworkCompatibilitySettingsToBackend() {
|
||||||
|
if (!PlatformBridge.supportsCoreBackend) return;
|
||||||
|
|
||||||
final compatibilityMode = state.networkCompatibilityMode;
|
final compatibilityMode = state.networkCompatibilityMode;
|
||||||
PlatformBridge.setNetworkCompatibilityOptions(
|
PlatformBridge.setNetworkCompatibilityOptions(
|
||||||
allowHttp: compatibilityMode,
|
allowHttp: compatibilityMode,
|
||||||
@@ -189,6 +196,20 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
await _saveSettings();
|
await _saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _normalizeIosDownloadDirectoryIfNeeded() async {
|
||||||
|
if (!Platform.isIOS) return;
|
||||||
|
|
||||||
|
final currentDir = state.downloadDirectory.trim();
|
||||||
|
if (currentDir.isEmpty) return;
|
||||||
|
|
||||||
|
final normalizedDir = await validateOrFixIosPath(currentDir);
|
||||||
|
if (normalizedDir == currentDir) return;
|
||||||
|
|
||||||
|
_log.i('Normalized iOS download directory: $currentDir -> $normalizedDir');
|
||||||
|
state = state.copyWith(downloadDirectory: normalizedDir);
|
||||||
|
await _saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
String _normalizeSongLinkRegion(String region) {
|
String _normalizeSongLinkRegion(String region) {
|
||||||
final normalized = region.trim().toUpperCase();
|
final normalized = region.trim().toUpperCase();
|
||||||
if (_isoRegionPattern.hasMatch(normalized)) return normalized;
|
if (_isoRegionPattern.hasMatch(normalized)) return normalized;
|
||||||
@@ -354,6 +375,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 +411,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 +424,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();
|
||||||
@@ -502,6 +536,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,6 +7,7 @@ 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('.');
|
||||||
@@ -125,6 +127,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 +138,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,6 +156,7 @@ 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,
|
||||||
@@ -159,6 +167,7 @@ class StoreState {
|
|||||||
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,15 +210,84 @@ class StoreNotifier extends Notifier<StoreState> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await PlatformBridge.initExtensionStore(cacheDir);
|
await PlatformBridge.initExtensionStore(cacheDir);
|
||||||
|
|
||||||
|
// Load saved registry URL from SharedPreferences
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final savedUrl = prefs.getString(_registryUrlPrefKey) ?? '';
|
||||||
|
|
||||||
|
if (savedUrl.isNotEmpty) {
|
||||||
|
await PlatformBridge.setStoreRegistryUrl(savedUrl);
|
||||||
|
state = state.copyWith(registryUrl: savedUrl);
|
||||||
await refresh();
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
state = state.copyWith(isInitialized: true, isLoading: false);
|
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();
|
||||||
|
|
||||||
|
// Persist to SharedPreferences
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_registryUrlPrefKey, resolvedUrl);
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
registryUrl: resolvedUrl,
|
||||||
|
extensions: const [], // Clear old extensions
|
||||||
|
);
|
||||||
|
|
||||||
|
_log.i('Registry URL set to: $resolvedUrl');
|
||||||
|
await refresh(forceRefresh: true);
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Failed to set registry URL: $e');
|
||||||
|
state = state.copyWith(isLoading: false, error: e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the saved registry URL and fully detaches the repo from backend.
|
||||||
|
Future<void> removeRegistryUrl() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_registryUrlPrefKey);
|
||||||
|
|
||||||
|
// Reset the URL in Go backend memory AND clear its cache
|
||||||
|
await PlatformBridge.clearStoreRegistryUrl();
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
registryUrl: '',
|
||||||
|
extensions: const [],
|
||||||
|
clearCategory: true,
|
||||||
|
searchQuery: '',
|
||||||
|
clearError: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
_log.i('Registry URL removed');
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Failed to remove registry URL: $e');
|
||||||
|
state = state.copyWith(error: e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> refresh({bool forceRefresh = false}) async {
|
Future<void> refresh({bool forceRefresh = false}) async {
|
||||||
state = state.copyWith(isLoading: true, clearError: true);
|
state = state.copyWith(isLoading: true, clearError: true);
|
||||||
|
|
||||||
|
|||||||
+228
-116
@@ -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';
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ class TrackState {
|
|||||||
final String? artistName;
|
final String? artistName;
|
||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
final String? headerImageUrl; // Artist header image for background
|
final String? headerImageUrl; // Artist header image for background
|
||||||
final int? monthlyListeners; // Artist monthly listeners
|
final int? monthlyListeners;
|
||||||
final List<ArtistAlbum>? artistAlbums; // For artist page
|
final List<ArtistAlbum>? artistAlbums; // For artist page
|
||||||
final List<Track>? artistTopTracks; // Artist's popular tracks
|
final List<Track>? artistTopTracks; // Artist's popular tracks
|
||||||
final List<SearchArtist>? searchArtists; // For search results
|
final List<SearchArtist>? searchArtists; // For search results
|
||||||
@@ -30,6 +31,8 @@ class TrackState {
|
|||||||
searchExtensionId; // Extension ID used for current search results
|
searchExtensionId; // Extension ID used for current search results
|
||||||
final String?
|
final String?
|
||||||
selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
|
selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
|
||||||
|
final String?
|
||||||
|
searchSource; // Built-in search provider used for current results (e.g., "deezer", "tidal", "qobuz")
|
||||||
|
|
||||||
const TrackState({
|
const TrackState({
|
||||||
this.tracks = const [],
|
this.tracks = const [],
|
||||||
@@ -52,6 +55,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 +87,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 +114,9 @@ class TrackState {
|
|||||||
selectedSearchFilter: clearSelectedSearchFilter
|
selectedSearchFilter: clearSelectedSearchFilter
|
||||||
? null
|
? null
|
||||||
: (selectedSearchFilter ?? this.selectedSearchFilter),
|
: (selectedSearchFilter ?? this.selectedSearchFilter),
|
||||||
|
searchSource: clearSearchSource
|
||||||
|
? null
|
||||||
|
: (searchSource ?? this.searchSource),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -278,7 +287,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
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 +316,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 +362,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 +376,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 +392,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,28 +478,10 @@ 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 {
|
|
||||||
_log.i('Converting Tidal track to Spotify/Deezer via SongLink...');
|
|
||||||
final conversion = await PlatformBridge.convertTidalToSpotifyDeezer(
|
|
||||||
url,
|
|
||||||
);
|
|
||||||
if (!_isRequestValid(requestId)) return;
|
|
||||||
|
|
||||||
final spotifyUrl = conversion['spotify_url'] as String?;
|
|
||||||
final deezerUrl = conversion['deezer_url'] as String?;
|
|
||||||
|
|
||||||
if (spotifyUrl != null && spotifyUrl.isNotEmpty) {
|
|
||||||
_log.i('Found Spotify URL: $spotifyUrl, fetching metadata...');
|
|
||||||
final metadata =
|
|
||||||
await PlatformBridge.getSpotifyMetadataWithFallback(
|
|
||||||
spotifyUrl,
|
|
||||||
);
|
|
||||||
if (!_isRequestValid(requestId)) return;
|
|
||||||
|
|
||||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||||
final track = _parseTrack(trackData);
|
final track = _parseTrack(trackData);
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
@@ -421,39 +489,55 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
coverUrl: track.coverUrl,
|
coverUrl: track.coverUrl,
|
||||||
);
|
);
|
||||||
return;
|
} else if (type == 'album') {
|
||||||
} else if (deezerUrl != null && deezerUrl.isNotEmpty) {
|
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
||||||
_log.i('Found Deezer URL: $deezerUrl, fetching metadata...');
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
final deezerParsed = await PlatformBridge.parseDeezerUrl(
|
final tracks = trackList
|
||||||
deezerUrl,
|
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||||
);
|
.toList();
|
||||||
final metadata = await PlatformBridge.getDeezerMetadata(
|
|
||||||
'track',
|
|
||||||
deezerParsed['id'] as String,
|
|
||||||
);
|
|
||||||
if (!_isRequestValid(requestId)) return;
|
|
||||||
|
|
||||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
|
||||||
final track = _parseTrack(trackData);
|
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: [track],
|
tracks: tracks,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
coverUrl: track.coverUrl,
|
albumId: 'tidal:$id',
|
||||||
|
albumName: albumInfo['name'] as String?,
|
||||||
|
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
|
||||||
|
);
|
||||||
|
_preWarmCacheForTracks(tracks);
|
||||||
|
} else if (type == 'playlist') {
|
||||||
|
final playlistInfo =
|
||||||
|
metadata['playlist_info'] as Map<String, dynamic>;
|
||||||
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
|
final tracks = trackList
|
||||||
|
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
|
||||||
|
final playlistName =
|
||||||
|
(playlistInfo['name'] ?? owner?['name']) as String?;
|
||||||
|
final coverUrl = normalizeRemoteHttpUrl(
|
||||||
|
(playlistInfo['images'] ?? owner?['images'])?.toString(),
|
||||||
);
|
);
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
_log.w('Failed to convert Tidal URL via SongLink: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// For album/artist/playlist, not yet supported
|
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
|
tracks: tracks,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error:
|
playlistName: playlistName,
|
||||||
'Tidal $type links are not fully supported yet. Only track links work via SongLink conversion.',
|
coverUrl: coverUrl,
|
||||||
hasSearchText: state.hasSearchText,
|
|
||||||
);
|
);
|
||||||
|
_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,
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,7 +589,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
albumId: parsed['id'] as String?,
|
albumId: parsed['id'] as String?,
|
||||||
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') {
|
||||||
@@ -515,11 +599,16 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
|
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(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
playlistName: owner?['name'] as String?,
|
playlistName: playlistName,
|
||||||
coverUrl: owner?['images'] as String?,
|
coverUrl: coverUrl,
|
||||||
);
|
);
|
||||||
_preWarmCacheForTracks(tracks);
|
_preWarmCacheForTracks(tracks);
|
||||||
} else if (type == 'artist') {
|
} else if (type == 'artist') {
|
||||||
@@ -533,7 +622,7 @@ 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,
|
artistAlbums: albums,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -547,7 +636,11 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> search(String query, {String? filterOverride}) async {
|
Future<void> search(
|
||||||
|
String query, {
|
||||||
|
String? filterOverride,
|
||||||
|
String? builtInSearchProvider,
|
||||||
|
}) async {
|
||||||
final requestId = ++_currentRequestId;
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
// Preserve selected filter during loading
|
// Preserve selected filter during loading
|
||||||
@@ -566,43 +659,60 @@ 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';
|
// Determine the effective search provider
|
||||||
|
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) {
|
// Only use metadata providers for Deezer search (default behavior)
|
||||||
|
if (effectiveProvider == 'deezer') {
|
||||||
try {
|
try {
|
||||||
_log.d('Calling extension search API...');
|
_log.d('Calling metadata provider search API...');
|
||||||
final extResults = await PlatformBridge.searchTracksWithExtensions(
|
metadataTrackResults =
|
||||||
|
await PlatformBridge.searchTracksWithMetadataProviders(
|
||||||
query,
|
query,
|
||||||
limit: 20,
|
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) {
|
} catch (e) {
|
||||||
_log.e('Failed to parse extension track: $e', e);
|
_log.w(
|
||||||
}
|
'Metadata provider search failed, falling back to Deezer tracks: $e',
|
||||||
}
|
);
|
||||||
} catch (e) {
|
|
||||||
_log.w('Extension search failed, falling back to Deezer: $e');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Call the appropriate search API
|
||||||
|
switch (effectiveProvider) {
|
||||||
|
case 'tidal':
|
||||||
|
_log.d('Calling Tidal search API...');
|
||||||
|
results = await PlatformBridge.searchTidalAll(
|
||||||
|
query,
|
||||||
|
trackLimit: 20,
|
||||||
|
artistLimit: 2,
|
||||||
|
filter: currentFilter,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'qobuz':
|
||||||
|
_log.d('Calling Qobuz search API...');
|
||||||
|
results = await PlatformBridge.searchQobuzAll(
|
||||||
|
query,
|
||||||
|
trackLimit: 20,
|
||||||
|
artistLimit: 2,
|
||||||
|
filter: currentFilter,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
_log.d('Calling Deezer search API...');
|
_log.d('Calling Deezer search API...');
|
||||||
results = await PlatformBridge.searchDeezerAll(
|
results = await PlatformBridge.searchDeezerAll(
|
||||||
query,
|
query,
|
||||||
@@ -610,8 +720,10 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
artistLimit: 2,
|
artistLimit: 2,
|
||||||
filter: currentFilter,
|
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 +734,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 +797,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(
|
||||||
@@ -709,6 +809,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
hasSearchText: state.hasSearchText,
|
hasSearchText: state.hasSearchText,
|
||||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||||
selectedSearchFilter: currentFilter, // Preserve filter in results
|
selectedSearchFilter: currentFilter, // Preserve filter in results
|
||||||
|
searchSource:
|
||||||
|
effectiveProvider, // Track which service was used for search
|
||||||
);
|
);
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
@@ -884,15 +986,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 +1011,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 +1074,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 +1087,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 +1098,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 +1110,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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/local_library_provider.dart';
|
|||||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
import 'package:spotiflac_android/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/providers/library_collections_provider.dart';
|
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||||
@@ -81,16 +82,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 +137,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)
|
||||||
@@ -175,6 +181,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
'album',
|
'album',
|
||||||
deezerAlbumId,
|
deezerAlbumId,
|
||||||
);
|
);
|
||||||
|
} else if (widget.albumId.startsWith('qobuz:')) {
|
||||||
|
final qobuzAlbumId = widget.albumId.replaceFirst('qobuz:', '');
|
||||||
|
metadata = await PlatformBridge.getQobuzMetadata('album', qobuzAlbumId);
|
||||||
|
} else if (widget.albumId.startsWith('tidal:')) {
|
||||||
|
final tidalAlbumId = widget.albumId.replaceFirst('tidal:', '');
|
||||||
|
metadata = await PlatformBridge.getTidalMetadata('album', tidalAlbumId);
|
||||||
} else {
|
} else {
|
||||||
final url = 'https://open.spotify.com/album/${widget.albumId}';
|
final url = 'https://open.spotify.com/album/${widget.albumId}';
|
||||||
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
||||||
@@ -218,7 +230,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?,
|
||||||
@@ -272,7 +284,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 +521,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());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,37 +575,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;
|
||||||
|
|
||||||
|
// Skip already-downloaded tracks
|
||||||
|
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,
|
||||||
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 +679,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 +704,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 +722,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 +739,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)),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+189
-17
@@ -14,6 +14,7 @@ import 'package:spotiflac_android/providers/local_library_provider.dart';
|
|||||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
import 'package:spotiflac_android/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;
|
||||||
@@ -38,12 +39,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 +57,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 +65,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 +102,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 +110,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 = {};
|
||||||
@@ -153,7 +161,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 +182,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 +203,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 +228,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 +236,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 +251,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);
|
||||||
@@ -270,6 +350,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 +359,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 +385,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 +399,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 +409,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 +453,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;
|
||||||
@@ -404,6 +499,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(
|
||||||
@@ -961,6 +1064,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);
|
||||||
@@ -1211,7 +1332,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 +1348,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),
|
||||||
|
),
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
(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;
|
||||||
@@ -363,7 +362,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)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -911,8 +910,45 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -924,7 +960,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,28 +996,31 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
children: formats.map((format) {
|
children: formats.map((format) {
|
||||||
final isSelected = format == selectedFormat;
|
final isSelected = format == selectedFormat;
|
||||||
return Padding(
|
return ChoiceChip(
|
||||||
padding: const EdgeInsets.only(right: 8),
|
|
||||||
child: ChoiceChip(
|
|
||||||
label: Text(format),
|
label: Text(format),
|
||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
onSelected: (selected) {
|
onSelected: (selected) {
|
||||||
if (selected) {
|
if (selected) {
|
||||||
setSheetState(() {
|
setSheetState(() {
|
||||||
selectedFormat = format;
|
selectedFormat = format;
|
||||||
|
isLosslessTarget =
|
||||||
|
format == 'ALAC' || format == 'FLAC';
|
||||||
|
if (!isLosslessTarget) {
|
||||||
selectedBitrate = format == 'Opus'
|
selectedBitrate = format == 'Opus'
|
||||||
? '128k'
|
? '128k'
|
||||||
: '320k';
|
: '320k';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
|
if (!isLosslessTarget) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
context.l10n.trackConvertBitrate,
|
context.l10n.trackConvertBitrate,
|
||||||
@@ -1006,6 +1044,25 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
);
|
);
|
||||||
}).toList(),
|
}).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 +1115,19 @@ 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;
|
||||||
|
// Skip lossy sources when target is lossless (pointless re-encoding)
|
||||||
|
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||||
|
final isLosslessSource = ext == 'FLAC' || ext == 'M4A';
|
||||||
|
if (isLosslessTarget && !isLosslessSource) continue;
|
||||||
|
selected.add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selected.isEmpty) {
|
if (selected.isEmpty) {
|
||||||
@@ -1075,12 +1139,18 @@ 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
|
||||||
|
? context.l10n.selectionBatchConvertConfirmMessageLossless(
|
||||||
|
selected.length,
|
||||||
|
targetFormat,
|
||||||
|
)
|
||||||
|
: context.l10n.selectionBatchConvertConfirmMessage(
|
||||||
selected.length,
|
selected.length,
|
||||||
targetFormat,
|
targetFormat,
|
||||||
bitrate,
|
bitrate,
|
||||||
@@ -1105,7 +1175,10 @@ 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';
|
||||||
@@ -1133,12 +1206,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 +1276,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,
|
||||||
|
|||||||
+139
-18
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -22,6 +23,7 @@ import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.da
|
|||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
import 'package:spotiflac_android/utils/app_bar_layout.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/playlist_screen.dart';
|
import 'package:spotiflac_android/screens/playlist_screen.dart';
|
||||||
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
|
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
|
||||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||||
@@ -81,6 +83,37 @@ class _SearchResultBuckets {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _homeHistoryPreviewLimit = 48;
|
||||||
|
|
||||||
|
class _HomeHistoryPreview {
|
||||||
|
final List<DownloadHistoryItem> items;
|
||||||
|
|
||||||
|
const _HomeHistoryPreview(this.items);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is _HomeHistoryPreview && listEquals(items, other.items);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hashAll(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
final _homeHistoryPreviewProvider = Provider<List<DownloadHistoryItem>>((ref) {
|
||||||
|
final preview = ref.watch(
|
||||||
|
downloadHistoryProvider.select((s) {
|
||||||
|
final items = s.items;
|
||||||
|
if (items.length <= _homeHistoryPreviewLimit) {
|
||||||
|
return _HomeHistoryPreview(items);
|
||||||
|
}
|
||||||
|
return _HomeHistoryPreview(
|
||||||
|
items.take(_homeHistoryPreviewLimit).toList(growable: false),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return preview.items;
|
||||||
|
});
|
||||||
|
|
||||||
_RecentAccessView _buildRecentAccessViewData(
|
_RecentAccessView _buildRecentAccessViewData(
|
||||||
List<RecentAccessItem> items,
|
List<RecentAccessItem> items,
|
||||||
List<DownloadHistoryItem> historyItems,
|
List<DownloadHistoryItem> historyItems,
|
||||||
@@ -164,9 +197,7 @@ _RecentAccessView _buildRecentAccessViewData(
|
|||||||
}
|
}
|
||||||
|
|
||||||
final recentAccessViewProvider = Provider<_RecentAccessView>((ref) {
|
final recentAccessViewProvider = Provider<_RecentAccessView>((ref) {
|
||||||
final historyItems = ref.watch(
|
final historyItems = ref.watch(_homeHistoryPreviewProvider);
|
||||||
downloadHistoryProvider.select((s) => s.items),
|
|
||||||
);
|
|
||||||
final recentAccessItems = ref.watch(
|
final recentAccessItems = ref.watch(
|
||||||
recentAccessProvider.select((s) => s.items),
|
recentAccessProvider.select((s) => s.items),
|
||||||
);
|
);
|
||||||
@@ -459,6 +490,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
|
|
||||||
if (searchProvider == null || searchProvider.isEmpty) return false;
|
if (searchProvider == null || searchProvider.isEmpty) return false;
|
||||||
|
|
||||||
|
// Built-in providers (tidal, qobuz) also support live search
|
||||||
|
if (_builtInSearchProviders.contains(searchProvider)) return true;
|
||||||
|
|
||||||
final extension = extState.extensions
|
final extension = extState.extensions
|
||||||
.where((e) => e.id == searchProvider && e.enabled)
|
.where((e) => e.id == searchProvider && e.enabled)
|
||||||
.firstOrNull;
|
.firstOrNull;
|
||||||
@@ -516,6 +550,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Built-in search providers that are not extensions
|
||||||
|
static const _builtInSearchProviders = {'tidal', 'qobuz'};
|
||||||
|
|
||||||
Future<void> _performSearch(String query, {String? filterOverride}) async {
|
Future<void> _performSearch(String query, {String? filterOverride}) async {
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
final extState = ref.read(extensionProvider);
|
final extState = ref.read(extensionProvider);
|
||||||
@@ -528,9 +565,14 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
if (_lastSearchQuery == searchKey) return;
|
if (_lastSearchQuery == searchKey) return;
|
||||||
_lastSearchQuery = searchKey;
|
_lastSearchQuery = searchKey;
|
||||||
|
|
||||||
|
final isBuiltInProvider =
|
||||||
|
searchProvider != null &&
|
||||||
|
_builtInSearchProviders.contains(searchProvider);
|
||||||
|
|
||||||
final isExtensionEnabled =
|
final isExtensionEnabled =
|
||||||
searchProvider != null &&
|
searchProvider != null &&
|
||||||
searchProvider.isNotEmpty &&
|
searchProvider.isNotEmpty &&
|
||||||
|
!isBuiltInProvider &&
|
||||||
extState.extensions.any((e) => e.id == searchProvider && e.enabled);
|
extState.extensions.any((e) => e.id == searchProvider && e.enabled);
|
||||||
|
|
||||||
if (isExtensionEnabled) {
|
if (isExtensionEnabled) {
|
||||||
@@ -541,10 +583,20 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
await ref
|
await ref
|
||||||
.read(trackProvider.notifier)
|
.read(trackProvider.notifier)
|
||||||
.customSearch(searchProvider, query, options: options);
|
.customSearch(searchProvider, query, options: options);
|
||||||
|
} else if (isBuiltInProvider) {
|
||||||
|
// Use built-in Tidal or Qobuz search
|
||||||
|
await ref
|
||||||
|
.read(trackProvider.notifier)
|
||||||
|
.search(
|
||||||
|
query,
|
||||||
|
filterOverride: selectedFilter,
|
||||||
|
builtInSearchProvider: searchProvider,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
if (searchProvider != null &&
|
if (searchProvider != null &&
|
||||||
searchProvider.isNotEmpty &&
|
searchProvider.isNotEmpty &&
|
||||||
!isExtensionEnabled) {
|
!isExtensionEnabled &&
|
||||||
|
!isBuiltInProvider) {
|
||||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||||
}
|
}
|
||||||
await ref
|
await ref
|
||||||
@@ -688,6 +740,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
trackName: track.name,
|
trackName: track.name,
|
||||||
artistName: track.artistName,
|
artistName: track.artistName,
|
||||||
coverUrl: track.coverUrl,
|
coverUrl: track.coverUrl,
|
||||||
|
recommendedService: trackState.searchSource,
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
ref
|
ref
|
||||||
.read(downloadQueueProvider.notifier)
|
.read(downloadQueueProvider.notifier)
|
||||||
@@ -816,7 +869,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
CheckboxListTile(
|
CheckboxListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
title: const Text('Skip already downloaded songs'),
|
title: Text(l10n.homeSkipAlreadyDownloaded),
|
||||||
value: skipDownloaded,
|
value: skipDownloaded,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setDialogState(() {
|
setDialogState(() {
|
||||||
@@ -987,9 +1040,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
final mediaQuery = MediaQuery.of(context);
|
final mediaQuery = MediaQuery.of(context);
|
||||||
final screenHeight = mediaQuery.size.height;
|
final screenHeight = mediaQuery.size.height;
|
||||||
final topPadding = normalizedHeaderTopPadding(context);
|
final topPadding = normalizedHeaderTopPadding(context);
|
||||||
final historyItems = ref.watch(
|
final historyItems = ref.watch(_homeHistoryPreviewProvider);
|
||||||
downloadHistoryProvider.select((s) => s.items),
|
|
||||||
);
|
|
||||||
|
|
||||||
final recentModeRequested = isShowingRecentAccess || isSearchFocused;
|
final recentModeRequested = isShowingRecentAccess || isSearchFocused;
|
||||||
final showRecentAccess =
|
final showRecentAccess =
|
||||||
@@ -1750,7 +1801,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.album, color: colorScheme.onSurfaceVariant),
|
leading: Icon(Icons.album, color: colorScheme.onSurfaceVariant),
|
||||||
title: const Text('Go to Album'),
|
title: Text(context.l10n.homeGoToAlbum),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
_navigateToTrackAlbum(item);
|
_navigateToTrackAlbum(item);
|
||||||
@@ -1822,9 +1873,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
SnackBar(content: Text(context.l10n.homeAlbumInfoUnavailable)),
|
||||||
).showSnackBar(const SnackBar(content: Text('Album info not available')));
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2742,6 +2793,14 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (searchProvider != null && searchProvider.isNotEmpty) {
|
if (searchProvider != null && searchProvider.isNotEmpty) {
|
||||||
|
// Check built-in providers first
|
||||||
|
if (searchProvider == 'tidal') {
|
||||||
|
return 'Search with Tidal...';
|
||||||
|
}
|
||||||
|
if (searchProvider == 'qobuz') {
|
||||||
|
return 'Search with Qobuz...';
|
||||||
|
}
|
||||||
|
|
||||||
final ext = extState.extensions
|
final ext = extState.extensions
|
||||||
.where((e) => e.id == searchProvider)
|
.where((e) => e.id == searchProvider)
|
||||||
.firstOrNull;
|
.firstOrNull;
|
||||||
@@ -2976,6 +3035,11 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
|||||||
.firstOrNull;
|
.firstOrNull;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if current provider is a built-in provider (tidal/qobuz)
|
||||||
|
const builtInProviders = {'tidal', 'qobuz'};
|
||||||
|
final isBuiltInProvider =
|
||||||
|
currentProvider != null && builtInProviders.contains(currentProvider);
|
||||||
|
|
||||||
IconData displayIcon = Icons.search;
|
IconData displayIcon = Icons.search;
|
||||||
String? iconPath;
|
String? iconPath;
|
||||||
if (currentExt != null) {
|
if (currentExt != null) {
|
||||||
@@ -2983,10 +3047,8 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
|||||||
if (currentExt.searchBehavior?.icon != null) {
|
if (currentExt.searchBehavior?.icon != null) {
|
||||||
displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!);
|
displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!);
|
||||||
}
|
}
|
||||||
}
|
} else if (isBuiltInProvider) {
|
||||||
|
displayIcon = Icons.music_note;
|
||||||
if (searchProviders.isEmpty) {
|
|
||||||
return const Icon(Icons.search);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -3053,6 +3115,62 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Built-in Tidal search option
|
||||||
|
PopupMenuItem<String>(
|
||||||
|
value: 'tidal',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.music_note,
|
||||||
|
size: 20,
|
||||||
|
color: currentProvider == 'tidal'
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Tidal',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: currentProvider == 'tidal'
|
||||||
|
? FontWeight.w600
|
||||||
|
: FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (currentProvider == 'tidal')
|
||||||
|
Icon(Icons.check, size: 18, color: colorScheme.primary),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Built-in Qobuz search option
|
||||||
|
PopupMenuItem<String>(
|
||||||
|
value: 'qobuz',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.music_note,
|
||||||
|
size: 20,
|
||||||
|
color: currentProvider == 'qobuz'
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Qobuz',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: currentProvider == 'qobuz'
|
||||||
|
? FontWeight.w600
|
||||||
|
: FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (currentProvider == 'qobuz')
|
||||||
|
Icon(Icons.check, size: 18, color: colorScheme.primary),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
if (searchProviders.isNotEmpty) const PopupMenuDivider(),
|
if (searchProviders.isNotEmpty) const PopupMenuDivider(),
|
||||||
...searchProviders.map(
|
...searchProviders.map(
|
||||||
(ext) => PopupMenuItem<String>(
|
(ext) => PopupMenuItem<String>(
|
||||||
@@ -3881,6 +3999,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
|||||||
name: (data['name'] ?? '').toString(),
|
name: (data['name'] ?? '').toString(),
|
||||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||||
albumName: (data['album_name'] ?? widget.albumName).toString(),
|
albumName: (data['album_name'] ?? widget.albumName).toString(),
|
||||||
|
albumArtist: (data['album_artist'] ?? _artistName)?.toString(),
|
||||||
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,
|
||||||
@@ -4188,7 +4307,7 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
|
|||||||
artists: (data['artists'] ?? '').toString(),
|
artists: (data['artists'] ?? '').toString(),
|
||||||
releaseDate: (data['release_date'] ?? '').toString(),
|
releaseDate: (data['release_date'] ?? '').toString(),
|
||||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||||
coverUrl: data['cover_url']?.toString(),
|
coverUrl: normalizeCoverReference(data['cover_url']?.toString()),
|
||||||
albumType: (data['album_type'] ?? 'album').toString(),
|
albumType: (data['album_type'] ?? 'album').toString(),
|
||||||
providerId: (data['provider_id'] ?? widget.extensionId).toString(),
|
providerId: (data['provider_id'] ?? widget.extensionId).toString(),
|
||||||
);
|
);
|
||||||
@@ -4213,7 +4332,9 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
|
|||||||
(data['artist_id'] ?? data['artistId'])?.toString() ??
|
(data['artist_id'] ?? data['artistId'])?.toString() ??
|
||||||
widget.artistId,
|
widget.artistId,
|
||||||
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?,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class _LibraryTracksFolderScreenState
|
|||||||
|
|
||||||
bool _isSelectionMode = false;
|
bool _isSelectionMode = false;
|
||||||
final Set<String> _selectedKeys = {};
|
final Set<String> _selectedKeys = {};
|
||||||
|
UserPlaylistCollection? playlist;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -243,7 +244,6 @@ class _LibraryTracksFolderScreenState
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
ref.watch(localLibraryProvider.select((s) => s.items));
|
ref.watch(localLibraryProvider.select((s) => s.items));
|
||||||
final localState = ref.read(localLibraryProvider);
|
final localState = ref.read(localLibraryProvider);
|
||||||
final UserPlaylistCollection? playlist;
|
|
||||||
final List<CollectionTrackEntry> entries;
|
final List<CollectionTrackEntry> entries;
|
||||||
|
|
||||||
switch (widget.mode) {
|
switch (widget.mode) {
|
||||||
@@ -850,8 +850,8 @@ class _LibraryTracksFolderScreenState
|
|||||||
final colorScheme = Theme.of(dialogContext).colorScheme;
|
final colorScheme = Theme.of(dialogContext).colorScheme;
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
title: const Text('Download All'),
|
title: Text(context.l10n.dialogDownloadAllTitle),
|
||||||
content: Text('Download ${tracks.length} tracks?'),
|
content: Text(context.l10n.dialogDownloadAllMessage(tracks.length)),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(dialogContext),
|
onPressed: () => Navigator.pop(dialogContext),
|
||||||
@@ -862,7 +862,7 @@ class _LibraryTracksFolderScreenState
|
|||||||
Navigator.pop(dialogContext);
|
Navigator.pop(dialogContext);
|
||||||
_downloadAll(tracks);
|
_downloadAll(tracks);
|
||||||
},
|
},
|
||||||
child: const Text('Download'),
|
child: Text(context.l10n.dialogDownload),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -872,11 +872,54 @@ class _LibraryTracksFolderScreenState
|
|||||||
|
|
||||||
void _downloadAll(List<Track> tracks) {
|
void _downloadAll(List<Track> tracks) {
|
||||||
if (tracks.isEmpty) return;
|
if (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 playlistName = widget.mode == LibraryTracksFolderMode.playlist
|
||||||
|
? playlist?.name ?? context.l10n.collectionPlaylist
|
||||||
|
: null;
|
||||||
|
final tracksToQueue = <Track>[];
|
||||||
|
var skippedCount = 0;
|
||||||
|
|
||||||
|
for (final track in tracks) {
|
||||||
|
final isInHistory =
|
||||||
|
historyState.isDownloaded(track.id) ||
|
||||||
|
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null) ||
|
||||||
|
historyState.findByTrackAndArtist(track.name, track.artistName) !=
|
||||||
|
null;
|
||||||
|
final isInLocal =
|
||||||
|
localLibState?.existsInLibrary(
|
||||||
|
isrc: track.isrc,
|
||||||
|
trackName: track.name,
|
||||||
|
artistName: track.artistName,
|
||||||
|
) ??
|
||||||
|
false;
|
||||||
|
|
||||||
|
if (isInHistory || isInLocal) {
|
||||||
|
skippedCount++;
|
||||||
|
} else {
|
||||||
|
tracksToQueue.add(track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tracksToQueue.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
context.l10n.discographySkippedDownloaded(0, skippedCount),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (settings.askQualityBeforeDownload) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
DownloadServicePicker.show(
|
DownloadServicePicker.show(
|
||||||
context,
|
context,
|
||||||
trackName: '${tracks.length} tracks',
|
trackName: '${tracksToQueue.length} tracks',
|
||||||
artistName: switch (widget.mode) {
|
artistName: switch (widget.mode) {
|
||||||
LibraryTracksFolderMode.wishlist => context.l10n.collectionWishlist,
|
LibraryTracksFolderMode.wishlist => context.l10n.collectionWishlist,
|
||||||
LibraryTracksFolderMode.loved => context.l10n.collectionLoved,
|
LibraryTracksFolderMode.loved => context.l10n.collectionLoved,
|
||||||
@@ -885,12 +928,24 @@ class _LibraryTracksFolderScreenState
|
|||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
ref
|
ref
|
||||||
.read(downloadQueueProvider.notifier)
|
.read(downloadQueueProvider.notifier)
|
||||||
.addMultipleToQueue(tracks, service, qualityOverride: quality);
|
.addMultipleToQueue(
|
||||||
|
tracksToQueue,
|
||||||
|
service,
|
||||||
|
qualityOverride: quality,
|
||||||
|
playlistName: playlistName,
|
||||||
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
context.l10n.snackbarAddedTracksToQueue(tracks.length),
|
skippedCount > 0
|
||||||
|
? context.l10n.discographySkippedDownloaded(
|
||||||
|
tracksToQueue.length,
|
||||||
|
skippedCount,
|
||||||
|
)
|
||||||
|
: context.l10n.snackbarAddedTracksToQueue(
|
||||||
|
tracksToQueue.length,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -899,10 +954,21 @@ class _LibraryTracksFolderScreenState
|
|||||||
} else {
|
} else {
|
||||||
ref
|
ref
|
||||||
.read(downloadQueueProvider.notifier)
|
.read(downloadQueueProvider.notifier)
|
||||||
.addMultipleToQueue(tracks, settings.defaultService);
|
.addMultipleToQueue(
|
||||||
|
tracksToQueue,
|
||||||
|
settings.defaultService,
|
||||||
|
playlistName: playlistName,
|
||||||
|
);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
|
content: Text(
|
||||||
|
skippedCount > 0
|
||||||
|
? context.l10n.discographySkippedDownloaded(
|
||||||
|
tracksToQueue.length,
|
||||||
|
skippedCount,
|
||||||
|
)
|
||||||
|
: context.l10n.snackbarAddedTracksToQueue(tracksToQueue.length),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.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/services/library_database.dart';
|
import 'package:spotiflac_android/services/library_database.dart';
|
||||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||||
|
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
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';
|
||||||
@@ -41,11 +45,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
|
|
||||||
void _showCueVirtualTrackSnackBar() {
|
void _showCueVirtualTrackSnackBar() {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(content: Text(cueVirtualTrackRequiresSplitMessage)),
|
||||||
content: Text(cueVirtualTrackRequiresSplitMessage),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
late List<int> _sortedDiscNumbersCache;
|
late List<int> _sortedDiscNumbersCache;
|
||||||
late bool _hasMultipleDiscsCache;
|
late bool _hasMultipleDiscsCache;
|
||||||
String? _commonQualityCache;
|
String? _commonQualityCache;
|
||||||
@@ -247,7 +250,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
if (tracks.isEmpty) {
|
if (tracks.isEmpty) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(widget.albumName)),
|
appBar: AppBar(title: Text(widget.albumName)),
|
||||||
body: const Center(child: Text('No tracks found for this album')),
|
body: Center(child: Text(context.l10n.noTracksFoundForAlbum)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,11 +626,13 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
|
|
||||||
slivers.add(
|
slivers.add(
|
||||||
SliverList(
|
SliverList(
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
(context, index) =>
|
final track = discTracks[index];
|
||||||
_buildTrackItem(context, colorScheme, discTracks[index]),
|
return KeyedSubtree(
|
||||||
childCount: discTracks.length,
|
key: ValueKey(track.id),
|
||||||
),
|
child: _buildTrackItem(context, colorScheme, track),
|
||||||
|
);
|
||||||
|
}, childCount: discTracks.length),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -815,6 +820,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
final format = item.format?.toLowerCase();
|
final format = item.format?.toLowerCase();
|
||||||
final lowerPath = item.filePath.toLowerCase();
|
final lowerPath = item.filePath.toLowerCase();
|
||||||
final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3');
|
final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3');
|
||||||
|
final isM4A =
|
||||||
|
format == 'm4a' ||
|
||||||
|
format == 'aac' ||
|
||||||
|
lowerPath.endsWith('.m4a') ||
|
||||||
|
lowerPath.endsWith('.aac');
|
||||||
final isOpus =
|
final isOpus =
|
||||||
format == 'opus' ||
|
format == 'opus' ||
|
||||||
format == 'ogg' ||
|
format == 'ogg' ||
|
||||||
@@ -828,6 +838,12 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
coverPath: effectiveCoverPath,
|
coverPath: effectiveCoverPath,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
);
|
);
|
||||||
|
} else if (isM4A) {
|
||||||
|
ffmpegResult = await FFmpegService.embedMetadataToM4a(
|
||||||
|
m4aPath: ffmpegTarget,
|
||||||
|
coverPath: effectiveCoverPath,
|
||||||
|
metadata: metadata,
|
||||||
|
);
|
||||||
} else if (isOpus) {
|
} else if (isOpus) {
|
||||||
ffmpegResult = await FFmpegService.embedMetadataToOpus(
|
ffmpegResult = await FFmpegService.embedMetadataToOpus(
|
||||||
opusPath: ffmpegTarget,
|
opusPath: ffmpegTarget,
|
||||||
@@ -897,6 +913,128 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<LocalLibraryItem> _selectedFlacEligibleItems(
|
||||||
|
List<LocalLibraryItem> allTracks,
|
||||||
|
) {
|
||||||
|
final tracksById = {for (final t in allTracks) t.id: t};
|
||||||
|
return _selectedIds
|
||||||
|
.map((id) => tracksById[id])
|
||||||
|
.whereType<LocalLibraryItem>()
|
||||||
|
.where(LocalTrackRedownloadService.isFlacUpgradeEligible)
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _queueSelectedAsFlac(List<LocalLibraryItem> allTracks) async {
|
||||||
|
final selected = _selectedFlacEligibleItems(allTracks);
|
||||||
|
|
||||||
|
if (selected.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text(context.l10n.queueFlacAction),
|
||||||
|
content: Text(context.l10n.queueFlacConfirmMessage(selected.length)),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: Text(context.l10n.dialogCancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: Text(context.l10n.queueFlacAction),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true || !mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
final extensionState = ref.read(extensionProvider);
|
||||||
|
final includeExtensions =
|
||||||
|
settings.useExtensionProviders &&
|
||||||
|
extensionState.extensions.any(
|
||||||
|
(ext) => ext.enabled && ext.hasMetadataProvider,
|
||||||
|
);
|
||||||
|
final targetService = LocalTrackRedownloadService.preferredFlacService(
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
final targetQuality =
|
||||||
|
LocalTrackRedownloadService.preferredFlacQualityForService(
|
||||||
|
targetService,
|
||||||
|
);
|
||||||
|
|
||||||
|
final matchedTracks = <Track>[];
|
||||||
|
var skippedCount = 0;
|
||||||
|
final total = selected.length;
|
||||||
|
|
||||||
|
for (var i = 0; i < total; i++) {
|
||||||
|
if (!mounted) break;
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).clearSnackBars();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(context.l10n.queueFlacFindingProgress(i + 1, total)),
|
||||||
|
duration: const Duration(seconds: 30),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final resolution = await LocalTrackRedownloadService.resolveBestMatch(
|
||||||
|
selected[i],
|
||||||
|
includeExtensions: includeExtensions,
|
||||||
|
);
|
||||||
|
if (resolution.canQueue && resolution.match != null) {
|
||||||
|
matchedTracks.add(resolution.match!);
|
||||||
|
} else {
|
||||||
|
skippedCount++;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
skippedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).clearSnackBars();
|
||||||
|
|
||||||
|
if (matchedTracks.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(context.l10n.queueFlacNoReliableMatches)),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ref
|
||||||
|
.read(downloadQueueProvider.notifier)
|
||||||
|
.addMultipleToQueue(
|
||||||
|
matchedTracks,
|
||||||
|
targetService,
|
||||||
|
qualityOverride: targetQuality,
|
||||||
|
);
|
||||||
|
|
||||||
|
final summary = skippedCount == 0
|
||||||
|
? context.l10n.snackbarAddedTracksToQueue(matchedTracks.length)
|
||||||
|
: context.l10n.queueFlacQueuedWithSkipped(
|
||||||
|
matchedTracks.length,
|
||||||
|
skippedCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(summary)));
|
||||||
|
setState(() {
|
||||||
|
_selectedIds.clear();
|
||||||
|
_isSelectionMode = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _reEnrichSelected(List<LocalLibraryItem> allTracks) async {
|
Future<void> _reEnrichSelected(List<LocalLibraryItem> allTracks) async {
|
||||||
final tracksById = {for (final t in allTracks) t.id: t};
|
final tracksById = {for (final t in allTracks) t.id: t};
|
||||||
final selected = <LocalLibraryItem>[];
|
final selected = <LocalLibraryItem>[];
|
||||||
@@ -1005,8 +1143,57 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
List<LocalLibraryItem> allTracks,
|
List<LocalLibraryItem> 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;
|
||||||
|
String? ext;
|
||||||
|
if (item.format != null && item.format!.isNotEmpty) {
|
||||||
|
final fmt = item.format!.toLowerCase();
|
||||||
|
if (fmt == 'flac') {
|
||||||
|
ext = 'FLAC';
|
||||||
|
} else if (fmt == 'm4a') {
|
||||||
|
ext = 'M4A';
|
||||||
|
} else if (fmt == 'mp3') {
|
||||||
|
ext = 'MP3';
|
||||||
|
} else if (fmt == 'opus' || fmt == 'ogg') {
|
||||||
|
ext = 'Opus';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ext == null) {
|
||||||
|
final lower = item.filePath.toLowerCase();
|
||||||
|
if (lower.endsWith('.flac')) {
|
||||||
|
ext = 'FLAC';
|
||||||
|
} else if (lower.endsWith('.m4a')) {
|
||||||
|
ext = 'M4A';
|
||||||
|
} else if (lower.endsWith('.mp3')) {
|
||||||
|
ext = 'MP3';
|
||||||
|
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
|
||||||
|
ext = 'Opus';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ext != null) sourceFormats.add(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) {
|
||||||
|
return sourceFormats.any((src) {
|
||||||
|
if (src == target) return false;
|
||||||
|
final isLosslessTarget = target == 'ALAC' || target == 'FLAC';
|
||||||
|
final isLosslessSource = src == 'FLAC' || src == 'M4A';
|
||||||
|
if (isLosslessTarget && !isLosslessSource) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
if (formats.isEmpty) return;
|
||||||
|
|
||||||
|
String selectedFormat = formats.first;
|
||||||
|
bool isLosslessTarget =
|
||||||
|
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
|
||||||
|
String selectedBitrate = isLosslessTarget
|
||||||
|
? '320k'
|
||||||
|
: (selectedFormat == 'Opus' ? '128k' : '320k');
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -1018,7 +1205,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
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(
|
||||||
@@ -1055,28 +1241,31 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
children: formats.map((format) {
|
children: formats.map((format) {
|
||||||
final isSelected = format == selectedFormat;
|
final isSelected = format == selectedFormat;
|
||||||
return Padding(
|
return ChoiceChip(
|
||||||
padding: const EdgeInsets.only(right: 8),
|
|
||||||
child: ChoiceChip(
|
|
||||||
label: Text(format),
|
label: Text(format),
|
||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
onSelected: (selected) {
|
onSelected: (selected) {
|
||||||
if (selected) {
|
if (selected) {
|
||||||
setSheetState(() {
|
setSheetState(() {
|
||||||
selectedFormat = format;
|
selectedFormat = format;
|
||||||
|
isLosslessTarget =
|
||||||
|
format == 'ALAC' || format == 'FLAC';
|
||||||
|
if (!isLosslessTarget) {
|
||||||
selectedBitrate = format == 'Opus'
|
selectedBitrate = format == 'Opus'
|
||||||
? '128k'
|
? '128k'
|
||||||
: '320k';
|
: '320k';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
|
if (!isLosslessTarget) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
context.l10n.trackConvertBitrate,
|
context.l10n.trackConvertBitrate,
|
||||||
@@ -1100,6 +1289,25 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
);
|
);
|
||||||
}).toList(),
|
}).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,
|
||||||
@@ -1152,6 +1360,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
final fmt = item.format!.toLowerCase();
|
final fmt = item.format!.toLowerCase();
|
||||||
if (fmt == 'flac') {
|
if (fmt == 'flac') {
|
||||||
currentFormat = 'FLAC';
|
currentFormat = 'FLAC';
|
||||||
|
} else if (fmt == 'm4a') {
|
||||||
|
currentFormat = 'M4A';
|
||||||
} else if (fmt == 'mp3') {
|
} else if (fmt == 'mp3') {
|
||||||
currentFormat = 'MP3';
|
currentFormat = 'MP3';
|
||||||
} else if (fmt == 'opus' || fmt == 'ogg') {
|
} else if (fmt == 'opus' || fmt == 'ogg') {
|
||||||
@@ -1163,16 +1373,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
final lower = item.filePath.toLowerCase();
|
final lower = item.filePath.toLowerCase();
|
||||||
if (lower.endsWith('.flac')) {
|
if (lower.endsWith('.flac')) {
|
||||||
currentFormat = 'FLAC';
|
currentFormat = 'FLAC';
|
||||||
|
} else if (lower.endsWith('.m4a')) {
|
||||||
|
currentFormat = 'M4A';
|
||||||
} else if (lower.endsWith('.mp3')) {
|
} else if (lower.endsWith('.mp3')) {
|
||||||
currentFormat = 'MP3';
|
currentFormat = 'MP3';
|
||||||
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
|
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
|
||||||
currentFormat = 'Opus';
|
currentFormat = 'Opus';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (currentFormat != null && currentFormat != targetFormat) {
|
if (currentFormat == null || currentFormat == targetFormat) continue;
|
||||||
|
// Skip lossy sources when target is lossless (pointless re-encoding)
|
||||||
|
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||||
|
final isLosslessSource =
|
||||||
|
currentFormat == 'FLAC' || currentFormat == 'M4A';
|
||||||
|
if (isLosslessTarget && !isLosslessSource) continue;
|
||||||
selected.add(item);
|
selected.add(item);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (selected.isEmpty) {
|
if (selected.isEmpty) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -1183,12 +1399,18 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
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
|
||||||
|
? context.l10n.selectionBatchConvertConfirmMessageLossless(
|
||||||
|
selected.length,
|
||||||
|
targetFormat,
|
||||||
|
)
|
||||||
|
: context.l10n.selectionBatchConvertConfirmMessage(
|
||||||
selected.length,
|
selected.length,
|
||||||
targetFormat,
|
targetFormat,
|
||||||
bitrate,
|
bitrate,
|
||||||
@@ -1239,12 +1461,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
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(
|
||||||
@@ -1357,13 +1574,27 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
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,
|
||||||
@@ -1434,6 +1665,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
double bottomPadding,
|
double bottomPadding,
|
||||||
) {
|
) {
|
||||||
final selectedCount = _selectedIds.length;
|
final selectedCount = _selectedIds.length;
|
||||||
|
final flacEligibleCount = _selectedFlacEligibleItems(tracks).length;
|
||||||
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
|
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
@@ -1525,6 +1757,18 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
|
if (flacEligibleCount > 0) ...[
|
||||||
|
Expanded(
|
||||||
|
child: _LocalAlbumSelectionActionButton(
|
||||||
|
icon: Icons.download_for_offline_outlined,
|
||||||
|
label:
|
||||||
|
'${context.l10n.queueFlacAction} ($flacEligibleCount)',
|
||||||
|
onPressed: () => _queueSelectedAsFlac(tracks),
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _LocalAlbumSelectionActionButton(
|
child: _LocalAlbumSelectionActionButton(
|
||||||
icon: Icons.auto_fix_high_outlined,
|
icon: Icons.auto_fix_high_outlined,
|
||||||
|
|||||||
+46
-25
@@ -33,7 +33,7 @@ class MainShell extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _MainShellState extends ConsumerState<MainShell> {
|
class _MainShellState extends ConsumerState<MainShell> {
|
||||||
int _currentIndex = 0;
|
int _currentIndex = 0;
|
||||||
late PageController _pageController;
|
late final PageController _pageController;
|
||||||
bool _hasCheckedUpdate = false;
|
bool _hasCheckedUpdate = false;
|
||||||
StreamSubscription<String>? _shareSubscription;
|
StreamSubscription<String>? _shareSubscription;
|
||||||
DateTime? _lastBackPress;
|
DateTime? _lastBackPress;
|
||||||
@@ -83,7 +83,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
final extState = ref.read(extensionProvider);
|
final extState = ref.read(extensionProvider);
|
||||||
if (!extState.isInitialized) {
|
if (!extState.isInitialized) {
|
||||||
_log.d('Waiting for extensions to initialize before handling URL...');
|
_log.d('Waiting for extensions to initialize before handling URL...');
|
||||||
// Wait up to 5 seconds for extensions to initialize
|
|
||||||
for (int i = 0; i < 50; i++) {
|
for (int i = 0; i < 50; i++) {
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -113,7 +112,8 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
if (trackState.error != null && mounted) {
|
if (trackState.error != null && mounted) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final errorMsg = trackState.error!;
|
final errorMsg = trackState.error!;
|
||||||
final isRateLimit = errorMsg.contains('429') ||
|
final isRateLimit =
|
||||||
|
errorMsg.contains('429') ||
|
||||||
errorMsg.toLowerCase().contains('rate limit') ||
|
errorMsg.toLowerCase().contains('rate limit') ||
|
||||||
errorMsg.toLowerCase().contains('too many requests');
|
errorMsg.toLowerCase().contains('too many requests');
|
||||||
final displayMessage = errorMsg == 'url_not_recognized'
|
final displayMessage = errorMsg == 'url_not_recognized'
|
||||||
@@ -121,9 +121,9 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
: isRateLimit
|
: isRateLimit
|
||||||
? l10n.errorRateLimitedMessage
|
? l10n.errorRateLimitedMessage
|
||||||
: l10n.errorUrlFetchFailed;
|
: l10n.errorUrlFetchFailed;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text(displayMessage)),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text(displayMessage)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,12 +158,10 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
if (settings.storageMode == 'saf') return;
|
if (settings.storageMode == 'saf') return;
|
||||||
if (settings.downloadDirectory.isEmpty) return;
|
if (settings.downloadDirectory.isEmpty) return;
|
||||||
|
|
||||||
// Check Android version
|
|
||||||
final deviceInfo = DeviceInfoPlugin();
|
final deviceInfo = DeviceInfoPlugin();
|
||||||
final androidInfo = await deviceInfo.androidInfo;
|
final androidInfo = await deviceInfo.androidInfo;
|
||||||
if (androidInfo.version.sdkInt < 29) return;
|
if (androidInfo.version.sdkInt < 29) return;
|
||||||
|
|
||||||
// Only show once
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
if (prefs.getBool(_safMigrationShownKey) == true) return;
|
if (prefs.getBool(_safMigrationShownKey) == true) return;
|
||||||
await prefs.setBool(_safMigrationShownKey, true);
|
await prefs.setBool(_safMigrationShownKey, true);
|
||||||
@@ -181,25 +179,20 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
size: 32,
|
size: 32,
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
title: const Text('Storage Update Required'),
|
title: Text(context.l10n.safMigrationTitle),
|
||||||
content: const Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(context.l10n.safMigrationMessage1),
|
||||||
'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. '
|
const SizedBox(height: 12),
|
||||||
'This fixes "permission denied" errors on Android 10+.',
|
Text(context.l10n.safMigrationMessage2),
|
||||||
),
|
|
||||||
SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
'Please select your download folder again to switch to the new storage system.',
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(ctx),
|
onPressed: () => Navigator.pop(ctx),
|
||||||
child: const Text('Later'),
|
child: Text(context.l10n.updateLater),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
@@ -219,15 +212,13 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
);
|
);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(content: Text(context.l10n.safMigrationSuccess)),
|
||||||
content: Text('Download folder updated to SAF mode'),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text('Select Folder'),
|
child: Text(context.l10n.setupSelectFolder),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -260,6 +251,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_currentIndex != index) {
|
if (_currentIndex != index) {
|
||||||
|
final shouldResetHome = index == 0;
|
||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
setState(() => _currentIndex = index);
|
setState(() => _currentIndex = index);
|
||||||
final showStore = ref.read(
|
final showStore = ref.read(
|
||||||
@@ -269,6 +261,10 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
currentTabIndex: _currentIndex,
|
currentTabIndex: _currentIndex,
|
||||||
showStoreTab: showStore,
|
showStoreTab: showStore,
|
||||||
);
|
);
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
|
if (shouldResetHome) {
|
||||||
|
_resetHomeToMain();
|
||||||
|
}
|
||||||
_pageController.animateToPage(
|
_pageController.animateToPage(
|
||||||
index,
|
index,
|
||||||
duration: const Duration(milliseconds: 250),
|
duration: const Duration(milliseconds: 250),
|
||||||
@@ -508,11 +504,15 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: PageView(
|
body: PageView.builder(
|
||||||
controller: _pageController,
|
controller: _pageController,
|
||||||
|
itemCount: tabs.length,
|
||||||
onPageChanged: _onPageChanged,
|
onPageChanged: _onPageChanged,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
children: tabs,
|
itemBuilder: (context, index) => _KeepAliveTabPage(
|
||||||
|
key: ValueKey('page-$index'),
|
||||||
|
child: tabs[index],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: NavigationBar(
|
||||||
selectedIndex: _currentIndex.clamp(0, maxIndex),
|
selectedIndex: _currentIndex.clamp(0, maxIndex),
|
||||||
@@ -573,6 +573,27 @@ class _LibraryTabRoot extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _KeepAliveTabPage extends StatefulWidget {
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const _KeepAliveTabPage({super.key, required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_KeepAliveTabPage> createState() => _KeepAliveTabPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _KeepAliveTabPageState extends State<_KeepAliveTabPage>
|
||||||
|
with AutomaticKeepAliveClientMixin<_KeepAliveTabPage> {
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
super.build(context);
|
||||||
|
return widget.child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class BouncingIcon extends StatefulWidget {
|
class BouncingIcon extends StatefulWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
const BouncingIcon({super.key, required this.child});
|
const BouncingIcon({super.key, required this.child});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/models/track.dart';
|
|||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
import 'package:spotiflac_android/providers/library_collections_provider.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/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_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/playback_provider.dart';
|
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||||
@@ -39,8 +40,12 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
List<Track>? _fetchedTracks;
|
List<Track>? _fetchedTracks;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
String? _resolvedPlaylistName;
|
||||||
|
String? _resolvedCoverUrl;
|
||||||
|
|
||||||
List<Track> get _tracks => _fetchedTracks ?? widget.tracks;
|
List<Track> get _tracks => _fetchedTracks ?? widget.tracks;
|
||||||
|
String get _playlistName => _resolvedPlaylistName ?? widget.playlistName;
|
||||||
|
String? get _coverUrl => _resolvedCoverUrl ?? widget.coverUrl;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -65,18 +70,25 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Extract numeric ID from "deezer:123" format
|
|
||||||
String playlistId = widget.playlistId!;
|
String playlistId = widget.playlistId!;
|
||||||
|
late final Map<String, dynamic> result;
|
||||||
if (playlistId.startsWith('deezer:')) {
|
if (playlistId.startsWith('deezer:')) {
|
||||||
playlistId = playlistId.substring(7);
|
playlistId = playlistId.substring(7);
|
||||||
|
result = await PlatformBridge.getDeezerMetadata('playlist', playlistId);
|
||||||
|
} else if (playlistId.startsWith('qobuz:')) {
|
||||||
|
playlistId = playlistId.substring(6);
|
||||||
|
result = await PlatformBridge.getQobuzMetadata('playlist', playlistId);
|
||||||
|
} else if (playlistId.startsWith('tidal:')) {
|
||||||
|
playlistId = playlistId.substring(6);
|
||||||
|
result = await PlatformBridge.getTidalMetadata('playlist', playlistId);
|
||||||
|
} else {
|
||||||
|
result = await PlatformBridge.getDeezerMetadata('playlist', playlistId);
|
||||||
}
|
}
|
||||||
|
|
||||||
final result = await PlatformBridge.getDeezerMetadata(
|
|
||||||
'playlist',
|
|
||||||
playlistId,
|
|
||||||
);
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final playlistInfo = result['playlist_info'] as Map<String, dynamic>?;
|
||||||
|
final owner = playlistInfo?['owner'] as Map<String, dynamic>?;
|
||||||
|
|
||||||
// Go backend returns 'track_list' not 'tracks'
|
// Go backend returns 'track_list' not 'tracks'
|
||||||
final trackList = result['track_list'] as List<dynamic>? ?? [];
|
final trackList = result['track_list'] as List<dynamic>? ?? [];
|
||||||
final tracks = trackList
|
final tracks = trackList
|
||||||
@@ -85,6 +97,10 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_fetchedTracks = tracks;
|
_fetchedTracks = tracks;
|
||||||
|
_resolvedPlaylistName = (playlistInfo?['name'] ?? owner?['name'])
|
||||||
|
?.toString();
|
||||||
|
_resolvedCoverUrl = (playlistInfo?['images'] ?? owner?['images'])
|
||||||
|
?.toString();
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -113,7 +129,9 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
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?,
|
||||||
@@ -184,7 +202,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
opacity: _showTitleInAppBar ? 1.0 : 0.0,
|
opacity: _showTitleInAppBar ? 1.0 : 0.0,
|
||||||
child: Text(
|
child: Text(
|
||||||
widget.playlistName,
|
_playlistName,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@@ -206,10 +224,9 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
background: Stack(
|
background: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
if (widget.coverUrl != null)
|
if (_coverUrl != null)
|
||||||
CachedNetworkImage(
|
CachedNetworkImage(
|
||||||
imageUrl:
|
imageUrl: _highResCoverUrl(_coverUrl) ?? _coverUrl!,
|
||||||
_highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!,
|
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
cacheManager: CoverCacheManager.instance,
|
cacheManager: CoverCacheManager.instance,
|
||||||
placeholder: (_, _) =>
|
placeholder: (_, _) =>
|
||||||
@@ -256,7 +273,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
widget.playlistName,
|
_playlistName,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
@@ -336,7 +353,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,7 +432,12 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
ref
|
ref
|
||||||
.read(downloadQueueProvider.notifier)
|
.read(downloadQueueProvider.notifier)
|
||||||
.addToQueue(track, service, qualityOverride: quality, playlistName: widget.playlistName);
|
.addToQueue(
|
||||||
|
track,
|
||||||
|
service,
|
||||||
|
qualityOverride: quality,
|
||||||
|
playlistName: _playlistName,
|
||||||
|
);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
|
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
|
||||||
@@ -427,7 +448,11 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
} else {
|
} else {
|
||||||
ref
|
ref
|
||||||
.read(downloadQueueProvider.notifier)
|
.read(downloadQueueProvider.notifier)
|
||||||
.addToQueue(track, settings.defaultService, playlistName: widget.playlistName);
|
.addToQueue(
|
||||||
|
track,
|
||||||
|
settings.defaultService,
|
||||||
|
playlistName: _playlistName,
|
||||||
|
);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
|
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
|
||||||
);
|
);
|
||||||
@@ -482,7 +507,9 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -505,10 +532,15 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
Widget _buildAddToPlaylistButton(BuildContext context) {
|
Widget _buildAddToPlaylistButton(BuildContext context) {
|
||||||
return _buildCircleButton(
|
return _buildCircleButton(
|
||||||
icon: Icons.playlist_add,
|
icon: Icons.playlist_add,
|
||||||
tooltip: 'Add to Playlist',
|
tooltip: context.l10n.tooltipAddToPlaylist,
|
||||||
onPressed: _tracks.isEmpty
|
onPressed: _tracks.isEmpty
|
||||||
? null
|
? null
|
||||||
: () => showAddTracksToPlaylistSheet(context, ref, _tracks),
|
: () => showAddTracksToPlaylistSheet(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
_tracks,
|
||||||
|
playlistNamePrefill: widget.playlistName,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,8 +552,8 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
final colorScheme = Theme.of(dialogContext).colorScheme;
|
final colorScheme = Theme.of(dialogContext).colorScheme;
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
title: const Text('Download All'),
|
title: Text(context.l10n.dialogDownloadAllTitle),
|
||||||
content: Text('Download ${_tracks.length} tracks?'),
|
content: Text(context.l10n.dialogDownloadAllMessage(_tracks.length)),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(dialogContext),
|
onPressed: () => Navigator.pop(dialogContext),
|
||||||
@@ -532,7 +564,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
Navigator.pop(dialogContext);
|
Navigator.pop(dialogContext);
|
||||||
_downloadAll(context);
|
_downloadAll(context);
|
||||||
},
|
},
|
||||||
child: const Text('Download'),
|
child: Text(context.l10n.dialogDownload),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -552,7 +584,11 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
}
|
}
|
||||||
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 {
|
||||||
@@ -565,7 +601,9 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
}
|
}
|
||||||
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)),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -577,36 +615,86 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
|
|
||||||
void _downloadTracks(BuildContext context, List<Track> tracks) {
|
void _downloadTracks(BuildContext context, List<Track> tracks) {
|
||||||
if (tracks.isEmpty) return;
|
if (tracks.isEmpty) return;
|
||||||
|
|
||||||
|
// Skip already-downloaded tracks
|
||||||
|
final historyState = ref.read(downloadHistoryProvider);
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
if (settings.askQualityBeforeDownload) {
|
final localLibState =
|
||||||
DownloadServicePicker.show(
|
(settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
|
||||||
context,
|
? ref.read(localLibraryProvider)
|
||||||
trackName: '${tracks.length} tracks',
|
: null;
|
||||||
artistName: widget.playlistName,
|
final tracksToQueue = <Track>[];
|
||||||
onSelect: (quality, service) {
|
int skippedCount = 0;
|
||||||
ref
|
|
||||||
.read(downloadQueueProvider.notifier)
|
for (final track in tracks) {
|
||||||
.addMultipleToQueue(tracks, service, qualityOverride: quality, playlistName: widget.playlistName);
|
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(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
context.l10n.snackbarAddedTracksToQueue(tracks.length),
|
context.l10n.discographySkippedDownloaded(0, skippedCount),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.askQualityBeforeDownload) {
|
||||||
|
DownloadServicePicker.show(
|
||||||
|
context,
|
||||||
|
trackName: '${tracksToQueue.length} tracks',
|
||||||
|
artistName: _playlistName,
|
||||||
|
onSelect: (quality, service) {
|
||||||
|
ref
|
||||||
|
.read(downloadQueueProvider.notifier)
|
||||||
|
.addMultipleToQueue(
|
||||||
|
tracksToQueue,
|
||||||
|
service,
|
||||||
|
qualityOverride: quality,
|
||||||
|
playlistName: _playlistName,
|
||||||
|
);
|
||||||
|
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ref
|
ref
|
||||||
.read(downloadQueueProvider.notifier)
|
.read(downloadQueueProvider.notifier)
|
||||||
.addMultipleToQueue(tracks, settings.defaultService, playlistName: widget.playlistName);
|
.addMultipleToQueue(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
tracksToQueue,
|
||||||
SnackBar(
|
settings.defaultService,
|
||||||
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
|
playlistName: _playlistName,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showQueuedSnackbar(BuildContext context, int added, int skipped) {
|
||||||
|
final message = skipped > 0
|
||||||
|
? context.l10n.discographySkippedDownloaded(added, skipped)
|
||||||
|
: context.l10n.snackbarAddedTracksToQueue(added);
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(message)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
|
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
|
||||||
|
|||||||
+744
-360
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user