mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 19:27:57 +02:00
Compare commits
165 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8615cde898 | |||
| 207c0653cc | |||
| de756e5d86 | |||
| fd5db3f7b6 | |||
| d087da9409 | |||
| 43469a7ef2 | |||
| add4af831e | |||
| 4e530ffbc3 | |||
| 14f6776fdc | |||
| da1c6e9171 | |||
| 9c3e934395 | |||
| 15d2c3b465 | |||
| 8aaa6d5cbe | |||
| 9158d0228d | |||
| 2bbcda3320 | |||
| a7622676dd | |||
| 5779f910a2 | |||
| 030f44a444 | |||
| 1248270fb4 | |||
| 413e3b0686 | |||
| ac711efadc | |||
| 59f2fe880a | |||
| 355f2eba2a | |||
| f2f45fa31d | |||
| 042937a8ed | |||
| 674e9af3d0 | |||
| 76d50fab3a | |||
| 81e25d7dab | |||
| 26f26f792a | |||
| 4dfa76b49e | |||
| f511f30ad0 | |||
| a1aa1319ce | |||
| c936bd7dd0 | |||
| 3a60ea2f4e | |||
| 7dba938299 | |||
| 93e77aeb84 | |||
| dd750b95ca | |||
| e42e44f28b | |||
| 67daefdf60 | |||
| fabaf0a3ff | |||
| fb90c73f42 | |||
| c6cf65f075 | |||
| 25de009ebc | |||
| 8918d74bb5 | |||
| f9de8d45d9 | |||
| 48eef0853d | |||
| fc70a912bf | |||
| cd3e5b4b28 | |||
| 482ca82eb4 | |||
| 6d87ae5484 | |||
| bd3e2b999b | |||
| 186196e12b | |||
| bd73eb292d | |||
| 8ee2919934 | |||
| f29177216d | |||
| 18d3612674 | |||
| f7c0e417d7 | |||
| 3fd13e9930 | |||
| 0b20cb895e | |||
| 8979210804 | |||
| e9b24712c5 | |||
| 3d6e5615fa | |||
| fc7220b572 | |||
| 198ed5ce6f | |||
| b48462a945 | |||
| 0f327cd1f6 | |||
| 4f2e677e8b | |||
| 79a69f8f70 | |||
| bf0f4bdf3e | |||
| 5e1cc3ecb5 | |||
| d4b37edc2f | |||
| 9483614bc7 | |||
| a73f2e1a13 | |||
| 091e3fadd9 | |||
| 5340ca7b16 | |||
| 85d3e58a26 | |||
| 1125c757fe | |||
| 66d714d368 | |||
| 49c2501fbc | |||
| e487817f21 | |||
| d8bbeb1e67 | |||
| 9693616645 | |||
| 0423e36d34 | |||
| c8d605fdee | |||
| 03fd734048 | |||
| da9d64ccfd | |||
| 02e64b7a3c | |||
| a435009d4d | |||
| 9ca73a99a6 | |||
| 4974284760 | |||
| a0306bd345 | |||
| ea7e594c68 | |||
| d00a84f1b9 | |||
| 58b6203681 | |||
| d299144c47 | |||
| 40b224e5a1 | |||
| 7021e5493f | |||
| 68bbc8a259 | |||
| be94a59441 | |||
| 3a73aee1b7 | |||
| c91154ea3e | |||
| 4f365ca7fe | |||
| 98fdc0ed7c | |||
| 12be560cb8 | |||
| 4cf885a52e | |||
| c57c8a4267 | |||
| 2ca6c737c0 | |||
| 2a451ec2a3 | |||
| 346e79b247 | |||
| 497ba342c0 | |||
| aca0bbb819 | |||
| 2df8fd6282 | |||
| 999317eba1 | |||
| 16991476ed | |||
| ba33639818 | |||
| 23cab16471 | |||
| 0a892011de | |||
| acb1d957d3 | |||
| 4a492aeefc | |||
| eb143a41fc | |||
| 75db2f162b | |||
| 855d0e3ffc | |||
| 5ccd06cc68 | |||
| b2873378fc | |||
| bffeb55a7a | |||
| cbfa147a12 | |||
| 5b8c953ae6 | |||
| 37a4dc096b | |||
| b3808645fb | |||
| 24aa804bf2 | |||
| 941ffb2bb7 | |||
| 59737d6f2b | |||
| c8ad93ee9b | |||
| 8cb0c037c2 | |||
| e30b69397b | |||
| d6e837fd61 | |||
| 5c97d202b9 | |||
| 0f6cfa75bb | |||
| 91bd6d1572 | |||
| 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 |
@@ -4,5 +4,5 @@ contact_links:
|
|||||||
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
|
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
|
||||||
about: Check the README for setup instructions and FAQ
|
about: Check the README for setup instructions and FAQ
|
||||||
- name: Extension Development Guide
|
- name: Extension Development Guide
|
||||||
url: https://zarz.moe/docs
|
url: https://spotiflac.zarz.moe/docs
|
||||||
about: Documentation for building SpotiFLAC extensions
|
about: Documentation for building SpotiFLAC extensions
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "1.25.7"
|
go-version: "1.25.8"
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
# Cache Gradle for faster builds
|
# Cache Gradle for faster builds
|
||||||
@@ -93,12 +93,12 @@ jobs:
|
|||||||
# Accept licenses
|
# Accept licenses
|
||||||
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
||||||
|
|
||||||
# Install NDK r27d LTS (required for 16KB page size support on Android 15+)
|
# Install NDK r29 (supports 16KB page size for Android 15+)
|
||||||
# Platform android-36 and build-tools 36.0.0 for targetSdk 36 (Android 16)
|
# Platform android-36 and build-tools 36.0.0 for targetSdk 36 (Android 16)
|
||||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;27.3.13750724" "platforms;android-36" "build-tools;36.0.0"
|
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;29.0.14206865" "platforms;android-36" "build-tools;36.0.0"
|
||||||
|
|
||||||
# Set NDK path
|
# Set NDK path
|
||||||
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/27.3.13750724" >> $GITHUB_ENV
|
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Install gomobile
|
- name: Install gomobile
|
||||||
run: |
|
run: |
|
||||||
@@ -164,17 +164,22 @@ jobs:
|
|||||||
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
|
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
|
||||||
|
|
||||||
build-ios:
|
build-ios:
|
||||||
runs-on: macos-latest
|
runs-on: macos-15
|
||||||
needs: get-version # Only depends on version, NOT android build!
|
needs: get-version # Only depends on version, NOT android build!
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Select Xcode 26.1.1
|
||||||
|
run: |
|
||||||
|
sudo xcode-select -s /Applications/Xcode_26.1.1.app
|
||||||
|
xcodebuild -version
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "1.25.7"
|
go-version: "1.25.8"
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
# Cache CocoaPods
|
# Cache CocoaPods
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ flutter_*.log
|
|||||||
# Development tools
|
# Development tools
|
||||||
tool/
|
tool/
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
.playwright-mcp/
|
||||||
|
|
||||||
# FVM Version Cache
|
# FVM Version Cache
|
||||||
.fvm/
|
.fvm/
|
||||||
|
|||||||
@@ -14,6 +14,17 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
|
[](https://www.virustotal.com/gui/file/31d1bf3c3b2015c13e83c4f909a7c6093a9423e3e702f0c582a3e0035c849424)
|
||||||
|
[](https://crowdin.com/project/spotiflac-mobile)
|
||||||
|
|
||||||
|
[](https://t.me/spotiflac)
|
||||||
|
[](https://t.me/spotiflac_chat)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -23,89 +34,154 @@
|
|||||||
<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/63a445a956fa71ea347ad3695a62d543e14e341933326b9dbb9a15d79614ef58)
|
|
||||||
[](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. When opening the Store for the first time, you will be asked to enter an **Extension Repository URL**
|
1. Open the **Store** tab in the app
|
||||||
|
2. On first launch, enter an **Extension Repository URL** when prompted
|
||||||
3. Browse and install extensions with one tap
|
3. Browse and install extensions with one tap
|
||||||
4. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
4. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
||||||
5. Configure extension settings if needed
|
5. Configure extension settings if needed
|
||||||
6. Set provider priority in **Settings > Extensions > Provider Priority**
|
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://zarzet.github.io/SpotiFLAC-Mobile/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 does the Store tab ask me to enter a URL?**
|
<details>
|
||||||
A: Starting from version 3.8.0, SpotiFLAC uses a decentralized extension repository system — extensions are hosted on GitHub repositories rather than a built-in server, so anyone can create and host their own. Enter a repository URL in the Store tab to browse and install extensions.
|
<summary><b>Why does the Store tab ask me to enter a URL?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
**Q: Why is my download failing with "Song not found"?**
|
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: 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.
|
|
||||||
|
|
||||||
**Q: Why are some tracks downloading in lower quality?**
|
</details>
|
||||||
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.
|
<summary><b>Why is my download failing with "Song not found"?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
**Q: Why do I need to grant storage permission?**
|
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: 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.
|
|
||||||
|
|
||||||
**Q: Is this app safe?**
|
</details>
|
||||||
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.
|
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
**Q: Can I add SpotiFLAC to AltStore or SideStore?**
|
Quality depends on what's available from the streaming service and its extensions. Built-in providers:
|
||||||
A: Yes! You can add the official source to receive updates directly within the app. Just copy this link:
|
- **Tidal** up to 24-bit/192kHz
|
||||||
|
- **Qobuz** up to 24-bit/192kHz
|
||||||
|
- **Deezer** up to 16-bit/44.1kHz
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<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
|
https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/refs/heads/main/apps.json
|
||||||
In AltStore/SideStore, go to the Browse tab, tap Sources at the top, then tap the + icon and paste the link.
|
```
|
||||||
|
|
||||||
|
In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link.
|
||||||
|
|
||||||
### Want to support SpotiFLAC-Mobile?
|
</details>
|
||||||
|
|
||||||
_If this software is useful and brings you value, consider supporting the project. Your support helps keep development going._
|
> [!NOTE]
|
||||||
|
> If SpotiFLAC is useful to you, consider supporting development:
|
||||||
|
>
|
||||||
|
> [](https://ko-fi.com/zarzet)
|
||||||
|
|
||||||
[](https://ko-fi.com/zarzet)
|
---
|
||||||
|
|
||||||
## Contributors
|
## Contributors
|
||||||
|
|
||||||
Thanks to all the amazing people who have contributed to SpotiFLAC Mobile!
|
Thanks to everyone who has contributed to SpotiFLAC Mobile!
|
||||||
|
|
||||||
<a href="https://github.com/zarzet/SpotiFLAC-Mobile/graphs/contributors">
|
<a href="https://github.com/zarzet/SpotiFLAC-Mobile/graphs/contributors">
|
||||||
<img src="https://contrib.rocks/image?repo=zarzet/SpotiFLAC-Mobile" />
|
<img src="https://contrib.rocks/image?repo=zarzet/SpotiFLAC-Mobile" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
We also appreciate everyone who has helped with [translations on Crowdin](https://crowdin.com/project/spotiflac-mobile), reported bugs, suggested features, and spread the word about SpotiFLAC Mobile.
|
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 our [Contributing Guide](CONTRIBUTING.md) to get started!
|
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) | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Disclaimer
|
||||||
|
|
||||||
|
This repository and its contents are provided strictly for educational and research purposes. The software is provided "as-is" without warranty of any kind, express or implied, as stated in the [MIT License](LICENSE).
|
||||||
|
|
||||||
|
- No copyrighted content is hosted, stored, mirrored, or distributed by this repository.
|
||||||
|
- Users must ensure that their use of this software is properly authorized and complies with all applicable laws, regulations, and third-party terms of service.
|
||||||
|
- This software is provided free of charge by the maintainer. If you paid a third party for access to this software in its original form from this repository, you may have been misled or scammed. Any redistribution or commercial use by third parties must comply with the terms of the repository license. No affiliation, endorsement, or support by the maintainer is implied unless explicitly stated in writing.
|
||||||
|
- SpotiFLAC Mobile is an independent project. It is not affiliated with, endorsed by, or connected to any other project or version on other platforms that may share a similar name. The maintainer of this repository has no control over or responsibility for third-party projects.
|
||||||
|
- The author(s) disclaim all liability for any direct, indirect, incidental, or consequential damages arising from the use or misuse of this software. Users assume all risk associated with its use.
|
||||||
|
- If you are a copyright holder or authorized representative and believe this repository infringes upon your rights, please contact the maintainer with sufficient detail (including relevant URLs and proof of ownership). The matter will be promptly investigated and appropriate action will be taken, which may include removal of the referenced material.
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
>
|
> **Star the repo** to get notified about all new releases directly from GitHub.
|
||||||
> **Star Us**, You will receive all release notifications from GitHub without any delay
|
|
||||||
|
|||||||
@@ -9,6 +9,19 @@
|
|||||||
# packages, and plugins designed to encourage good coding practices.
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
include: package:flutter_lints/flutter.yaml
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
analyzer:
|
||||||
|
exclude:
|
||||||
|
- build/**
|
||||||
|
- .dart_tool/**
|
||||||
|
- lib/**/*.g.dart
|
||||||
|
- lib/l10n/*.dart
|
||||||
|
language:
|
||||||
|
strict-casts: true
|
||||||
|
strict-inference: true
|
||||||
|
strict-raw-types: true
|
||||||
|
plugins:
|
||||||
|
- custom_lint
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
# The lint rules applied to this project can be customized in the
|
# The lint rules applied to this project can be customized in the
|
||||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||||
@@ -23,6 +36,13 @@ linter:
|
|||||||
rules:
|
rules:
|
||||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||||
|
avoid_dynamic_calls: true
|
||||||
|
cancel_subscriptions: true
|
||||||
|
close_sinks: true
|
||||||
|
|
||||||
|
custom_lint:
|
||||||
|
rules:
|
||||||
|
- avoid_public_notifier_properties
|
||||||
|
|
||||||
# Additional information about this file can be found at
|
# Additional information about this file can be found at
|
||||||
# https://dart.dev/guides/language/analysis-options
|
# https://dart.dev/guides/language/analysis-options
|
||||||
|
|||||||
@@ -57,6 +57,18 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
getByName("debug") {
|
||||||
|
ndk {
|
||||||
|
debugSymbolLevel = "FULL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getByName("profile") {
|
||||||
|
ndk {
|
||||||
|
debugSymbolLevel = "FULL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
release {
|
release {
|
||||||
// For local builds: use release signing if key.properties exists
|
// For local builds: use release signing if key.properties exists
|
||||||
// For CI builds: APK is signed by GitHub Action after build
|
// For CI builds: APK is signed by GitHub Action after build
|
||||||
@@ -71,6 +83,9 @@ android {
|
|||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
)
|
)
|
||||||
|
ndk {
|
||||||
|
debugSymbolLevel = "FULL"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,24 +94,6 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="dataSync" />
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
<!-- Audio playback service for media notification / background audio -->
|
|
||||||
<service
|
|
||||||
android:name="com.ryanheise.audioservice.AudioService"
|
|
||||||
android:exported="true"
|
|
||||||
android:foregroundServiceType="mediaPlayback">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.media.browse.MediaBrowserService" />
|
|
||||||
</intent-filter>
|
|
||||||
</service>
|
|
||||||
|
|
||||||
<receiver
|
|
||||||
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
|
||||||
android:exported="true">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
|
||||||
</intent-filter>
|
|
||||||
</receiver>
|
|
||||||
|
|
||||||
<!-- flutter_local_notifications receivers -->
|
<!-- flutter_local_notifications receivers -->
|
||||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
|
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
|
||||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
|
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ class DownloadService : Service() {
|
|||||||
updateNotification(progress, total)
|
updateNotification(progress, total)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return START_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
@@ -137,14 +137,13 @@ class DownloadService : Service() {
|
|||||||
|
|
||||||
private fun startForegroundService() {
|
private fun startForegroundService() {
|
||||||
isRunning = true
|
isRunning = true
|
||||||
|
|
||||||
// Acquire wake lock to prevent CPU sleep
|
|
||||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
wakeLock = powerManager.newWakeLock(
|
wakeLock = powerManager.newWakeLock(
|
||||||
PowerManager.PARTIAL_WAKE_LOCK,
|
PowerManager.PARTIAL_WAKE_LOCK,
|
||||||
WAKELOCK_TAG
|
WAKELOCK_TAG
|
||||||
).apply {
|
).apply {
|
||||||
acquire(60 * 60 * 1000L) // 1 hour max
|
acquire(60 * 60 * 1000L)
|
||||||
}
|
}
|
||||||
|
|
||||||
val notification = buildNotification(0, 0)
|
val notification = buildNotification(0, 0)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-all.zip
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ pluginManagement {
|
|||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.13.2" apply false
|
id("com.android.application") version "8.13.2" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.2.21" apply false
|
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
@@ -7,12 +7,12 @@
|
|||||||
"name": "SpotiFLAC",
|
"name": "SpotiFLAC",
|
||||||
"bundleIdentifier": "com.zarzet.spotiflac",
|
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||||
"developerName": "zarzet",
|
"developerName": "zarzet",
|
||||||
"version": "3.8.6",
|
"version": "3.9.0",
|
||||||
"versionDate": "2026-03-16",
|
"versionDate": "2026-03-25",
|
||||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.8.6/SpotiFLAC-v3.8.6-ios-unsigned.ipa",
|
"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.",
|
"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",
|
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
||||||
"size": 33676960
|
"size": 34477323
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,611 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// APEv2 tag format constants.
|
||||||
|
const (
|
||||||
|
apeTagPreamble = "APETAGEX"
|
||||||
|
apeTagHeaderSize = 32
|
||||||
|
apeTagVersion2 = 2000
|
||||||
|
apeTagFlagHeader = 1 << 29 // bit 29: this is the header, not the footer
|
||||||
|
apeTagFlagReadOnly = 1 << 0
|
||||||
|
// Item flags: bits 1-2 encode content type
|
||||||
|
apeItemFlagUTF8 = 0 << 1 // 00: UTF-8 text
|
||||||
|
apeItemFlagBinary = 1 << 1 // 01: binary data
|
||||||
|
apeItemFlagLink = 2 << 1 // 10: external link
|
||||||
|
)
|
||||||
|
|
||||||
|
// APETagItem represents a single key-value item in an APEv2 tag.
|
||||||
|
type APETagItem struct {
|
||||||
|
Key string
|
||||||
|
Value string
|
||||||
|
Flags uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// APETag represents a complete APEv2 tag block.
|
||||||
|
type APETag struct {
|
||||||
|
Version uint32
|
||||||
|
Items []APETagItem
|
||||||
|
ReadOnly bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadAPETags reads APEv2 tags from a file.
|
||||||
|
// APEv2 tags are typically appended at the end of the file.
|
||||||
|
// The layout is: [audio data] [APEv2 header (optional)] [items...] [APEv2 footer]
|
||||||
|
// We locate the footer first (last 32 bytes), then read the tag block.
|
||||||
|
func ReadAPETags(filePath string) (*APETag, error) {
|
||||||
|
f, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open file: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
fi, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to stat file: %w", err)
|
||||||
|
}
|
||||||
|
fileSize := fi.Size()
|
||||||
|
|
||||||
|
if fileSize < apeTagHeaderSize {
|
||||||
|
return nil, fmt.Errorf("file too small for APE tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find APE tag footer at the end of file.
|
||||||
|
// The footer is the last 32 bytes before any ID3v1 tag (128 bytes).
|
||||||
|
tag, err := readAPETagAtOffset(f, fileSize, fileSize-apeTagHeaderSize)
|
||||||
|
if err == nil {
|
||||||
|
return tag, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry: skip ID3v1 tag (128 bytes) if present
|
||||||
|
if fileSize > apeTagHeaderSize+128 {
|
||||||
|
tag, err = readAPETagAtOffset(f, fileSize, fileSize-apeTagHeaderSize-128)
|
||||||
|
if err == nil {
|
||||||
|
return tag, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no APEv2 tag found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func readAPETagAtOffset(f *os.File, fileSize, footerOffset int64) (*APETag, error) {
|
||||||
|
if footerOffset < 0 || footerOffset+apeTagHeaderSize > fileSize {
|
||||||
|
return nil, fmt.Errorf("invalid footer offset")
|
||||||
|
}
|
||||||
|
|
||||||
|
footer := make([]byte, apeTagHeaderSize)
|
||||||
|
if _, err := f.ReadAt(footer, footerOffset); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read APE footer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(footer[0:8]) != apeTagPreamble {
|
||||||
|
return nil, fmt.Errorf("APE preamble not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
version := binary.LittleEndian.Uint32(footer[8:12])
|
||||||
|
tagSize := binary.LittleEndian.Uint32(footer[12:16]) // size of items + footer (32 bytes)
|
||||||
|
itemCount := binary.LittleEndian.Uint32(footer[16:20])
|
||||||
|
flags := binary.LittleEndian.Uint32(footer[20:24])
|
||||||
|
|
||||||
|
if version != apeTagVersion2 && version != 1000 {
|
||||||
|
return nil, fmt.Errorf("unsupported APE tag version: %d", version)
|
||||||
|
}
|
||||||
|
if tagSize < apeTagHeaderSize {
|
||||||
|
return nil, fmt.Errorf("APE tag size too small: %d", tagSize)
|
||||||
|
}
|
||||||
|
if itemCount > 1000 {
|
||||||
|
return nil, fmt.Errorf("APE tag item count too large: %d", itemCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This should be the footer (bit 29 clear)
|
||||||
|
isHeader := (flags & apeTagFlagHeader) != 0
|
||||||
|
if isHeader {
|
||||||
|
return nil, fmt.Errorf("expected APE footer but found header")
|
||||||
|
}
|
||||||
|
|
||||||
|
// tagSize includes items + footer (32 bytes), but NOT the header.
|
||||||
|
itemsSize := int64(tagSize) - apeTagHeaderSize
|
||||||
|
if itemsSize < 0 {
|
||||||
|
return nil, fmt.Errorf("invalid APE tag: items size negative")
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsOffset := footerOffset - itemsSize
|
||||||
|
if itemsOffset < 0 {
|
||||||
|
return nil, fmt.Errorf("APE tag items extend before file start")
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsData := make([]byte, itemsSize)
|
||||||
|
if _, err := f.ReadAt(itemsData, itemsOffset); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read APE items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := parseAPEItems(itemsData, int(itemCount))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse APE items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &APETag{
|
||||||
|
Version: version,
|
||||||
|
Items: items,
|
||||||
|
ReadOnly: (flags & apeTagFlagReadOnly) != 0,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAPEItems(data []byte, count int) ([]APETagItem, error) {
|
||||||
|
items := make([]APETagItem, 0, count)
|
||||||
|
pos := 0
|
||||||
|
|
||||||
|
for i := 0; i < count && pos < len(data); i++ {
|
||||||
|
if pos+8 > len(data) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
valueSize := int(binary.LittleEndian.Uint32(data[pos : pos+4]))
|
||||||
|
itemFlags := binary.LittleEndian.Uint32(data[pos+4 : pos+8])
|
||||||
|
pos += 8
|
||||||
|
|
||||||
|
// Key is null-terminated ASCII (2-255 bytes, case-insensitive)
|
||||||
|
keyEnd := pos
|
||||||
|
for keyEnd < len(data) && data[keyEnd] != 0 {
|
||||||
|
keyEnd++
|
||||||
|
}
|
||||||
|
if keyEnd >= len(data) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
key := string(data[pos:keyEnd])
|
||||||
|
pos = keyEnd + 1
|
||||||
|
|
||||||
|
if pos+valueSize > len(data) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
value := string(data[pos : pos+valueSize])
|
||||||
|
pos += valueSize
|
||||||
|
|
||||||
|
items = append(items, APETagItem{
|
||||||
|
Key: key,
|
||||||
|
Value: value,
|
||||||
|
Flags: itemFlags,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteAPETags writes APEv2 tags to the end of a file.
|
||||||
|
// If the file already has APEv2 tags, they are replaced.
|
||||||
|
// The tag is written with both header and footer.
|
||||||
|
func WriteAPETags(filePath string, tag *APETag) error {
|
||||||
|
existingSize, err := findExistingAPETagSize(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check existing APE tag: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tagData, err := marshalAPETag(tag)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal APE tag: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if existingSize > 0 {
|
||||||
|
fi, err := os.Stat(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to stat file: %w", err)
|
||||||
|
}
|
||||||
|
newSize := fi.Size() - int64(existingSize)
|
||||||
|
if err := os.Truncate(filePath, newSize); err != nil {
|
||||||
|
return fmt.Errorf("failed to truncate existing APE tag: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.OpenFile(filePath, os.O_WRONLY|os.O_APPEND, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open file for writing: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
if _, err := f.Write(tagData); err != nil {
|
||||||
|
return fmt.Errorf("failed to write APE tag: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findExistingAPETagSize returns the total size of an existing APE tag
|
||||||
|
// (header + items + footer) at the end of the file, or 0 if none exists.
|
||||||
|
func findExistingAPETagSize(filePath string) (int64, error) {
|
||||||
|
f, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
fi, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
fileSize := fi.Size()
|
||||||
|
|
||||||
|
offsets := []int64{fileSize - apeTagHeaderSize}
|
||||||
|
if fileSize > apeTagHeaderSize+128 {
|
||||||
|
offsets = append(offsets, fileSize-apeTagHeaderSize-128)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, offset := range offsets {
|
||||||
|
if offset < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
footer := make([]byte, apeTagHeaderSize)
|
||||||
|
if _, err := f.ReadAt(footer, offset); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if string(footer[0:8]) != apeTagPreamble {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
flags := binary.LittleEndian.Uint32(footer[20:24])
|
||||||
|
if (flags & apeTagFlagHeader) != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tagSize := int64(binary.LittleEndian.Uint32(footer[12:16]))
|
||||||
|
|
||||||
|
// Check if there's also a header (tagSize only covers items + footer)
|
||||||
|
hasHeader := (flags & (1 << 31)) != 0 // bit 31 = tag contains header
|
||||||
|
totalSize := tagSize
|
||||||
|
if hasHeader {
|
||||||
|
totalSize += apeTagHeaderSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include any trailing data after the footer (e.g. ID3v1 128-byte tag).
|
||||||
|
// When truncating, we must remove the APE tag AND everything after it.
|
||||||
|
trailingBytes := fileSize - (offset + apeTagHeaderSize)
|
||||||
|
totalSize += trailingBytes
|
||||||
|
|
||||||
|
return totalSize, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// marshalAPETag serializes an APETag into bytes (header + items + footer).
|
||||||
|
func marshalAPETag(tag *APETag) ([]byte, error) {
|
||||||
|
if tag == nil || len(tag.Items) == 0 {
|
||||||
|
return nil, fmt.Errorf("empty APE tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
var itemsData []byte
|
||||||
|
for _, item := range tag.Items {
|
||||||
|
keyBytes := []byte(item.Key)
|
||||||
|
valueBytes := []byte(item.Value)
|
||||||
|
|
||||||
|
// 4 bytes: value size (LE)
|
||||||
|
sizeBuf := make([]byte, 4)
|
||||||
|
binary.LittleEndian.PutUint32(sizeBuf, uint32(len(valueBytes)))
|
||||||
|
|
||||||
|
// 4 bytes: item flags (LE)
|
||||||
|
flagsBuf := make([]byte, 4)
|
||||||
|
binary.LittleEndian.PutUint32(flagsBuf, item.Flags)
|
||||||
|
|
||||||
|
itemsData = append(itemsData, sizeBuf...)
|
||||||
|
itemsData = append(itemsData, flagsBuf...)
|
||||||
|
itemsData = append(itemsData, keyBytes...)
|
||||||
|
itemsData = append(itemsData, 0)
|
||||||
|
itemsData = append(itemsData, valueBytes...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tagSize = items data + footer (32 bytes)
|
||||||
|
tagSize := uint32(len(itemsData) + apeTagHeaderSize)
|
||||||
|
itemCount := uint32(len(tag.Items))
|
||||||
|
|
||||||
|
version := uint32(apeTagVersion2)
|
||||||
|
if tag.Version != 0 {
|
||||||
|
version = tag.Version
|
||||||
|
}
|
||||||
|
|
||||||
|
// flags: bit 29 = 1 (is header), bit 31 = 1 (contains header)
|
||||||
|
headerFlags := uint32(apeTagFlagHeader | (1 << 31))
|
||||||
|
header := buildAPEHeaderFooter(version, tagSize, itemCount, headerFlags)
|
||||||
|
|
||||||
|
// flags: bit 29 = 0 (is footer), bit 31 = 1 (contains header)
|
||||||
|
footerFlags := uint32(1 << 31)
|
||||||
|
footer := buildAPEHeaderFooter(version, tagSize, itemCount, footerFlags)
|
||||||
|
|
||||||
|
// Final layout: header + items + footer
|
||||||
|
result := make([]byte, 0, len(header)+len(itemsData)+len(footer))
|
||||||
|
result = append(result, header...)
|
||||||
|
result = append(result, itemsData...)
|
||||||
|
result = append(result, footer...)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAPEHeaderFooter(version, tagSize, itemCount, flags uint32) []byte {
|
||||||
|
buf := make([]byte, apeTagHeaderSize)
|
||||||
|
copy(buf[0:8], apeTagPreamble)
|
||||||
|
binary.LittleEndian.PutUint32(buf[8:12], version)
|
||||||
|
binary.LittleEndian.PutUint32(buf[12:16], tagSize)
|
||||||
|
binary.LittleEndian.PutUint32(buf[16:20], itemCount)
|
||||||
|
binary.LittleEndian.PutUint32(buf[20:24], flags)
|
||||||
|
// bytes 24-31 are reserved (zeros)
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
// APETagToAudioMetadata converts an APETag to our unified AudioMetadata struct.
|
||||||
|
func APETagToAudioMetadata(tag *APETag) *AudioMetadata {
|
||||||
|
if tag == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := &AudioMetadata{}
|
||||||
|
for _, item := range tag.Items {
|
||||||
|
key := strings.ToUpper(strings.TrimSpace(item.Key))
|
||||||
|
value := strings.TrimSpace(item.Value)
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch key {
|
||||||
|
case "TITLE":
|
||||||
|
metadata.Title = value
|
||||||
|
case "ARTIST":
|
||||||
|
metadata.Artist = value
|
||||||
|
case "ALBUM":
|
||||||
|
metadata.Album = value
|
||||||
|
case "ALBUMARTIST", "ALBUM ARTIST":
|
||||||
|
metadata.AlbumArtist = value
|
||||||
|
case "GENRE":
|
||||||
|
metadata.Genre = value
|
||||||
|
case "YEAR":
|
||||||
|
metadata.Year = value
|
||||||
|
case "DATE":
|
||||||
|
metadata.Date = value
|
||||||
|
case "TRACK", "TRACKNUMBER":
|
||||||
|
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||||
|
case "DISC", "DISCNUMBER":
|
||||||
|
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||||
|
case "ISRC":
|
||||||
|
metadata.ISRC = value
|
||||||
|
case "LYRICS", "UNSYNCEDLYRICS":
|
||||||
|
if metadata.Lyrics == "" {
|
||||||
|
metadata.Lyrics = value
|
||||||
|
}
|
||||||
|
case "LABEL", "PUBLISHER":
|
||||||
|
metadata.Label = value
|
||||||
|
case "COPYRIGHT":
|
||||||
|
metadata.Copyright = value
|
||||||
|
case "COMPOSER":
|
||||||
|
metadata.Composer = value
|
||||||
|
case "COMMENT":
|
||||||
|
metadata.Comment = value
|
||||||
|
case "REPLAYGAIN_TRACK_GAIN":
|
||||||
|
metadata.ReplayGainTrackGain = value
|
||||||
|
case "REPLAYGAIN_TRACK_PEAK":
|
||||||
|
metadata.ReplayGainTrackPeak = value
|
||||||
|
case "REPLAYGAIN_ALBUM_GAIN":
|
||||||
|
metadata.ReplayGainAlbumGain = value
|
||||||
|
case "REPLAYGAIN_ALBUM_PEAK":
|
||||||
|
metadata.ReplayGainAlbumPeak = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioMetadataToAPEItems converts metadata fields to APE tag items.
|
||||||
|
func AudioMetadataToAPEItems(metadata *AudioMetadata) []APETagItem {
|
||||||
|
if metadata == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []APETagItem
|
||||||
|
addItem := func(key, value string) {
|
||||||
|
if value != "" {
|
||||||
|
items = append(items, APETagItem{Key: key, Value: value})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addItem("Title", metadata.Title)
|
||||||
|
addItem("Artist", metadata.Artist)
|
||||||
|
addItem("Album", metadata.Album)
|
||||||
|
addItem("Album Artist", metadata.AlbumArtist)
|
||||||
|
addItem("Genre", metadata.Genre)
|
||||||
|
if metadata.Date != "" {
|
||||||
|
addItem("Year", metadata.Date)
|
||||||
|
} else if metadata.Year != "" {
|
||||||
|
addItem("Year", metadata.Year)
|
||||||
|
}
|
||||||
|
if metadata.TrackNumber > 0 {
|
||||||
|
addItem("Track", formatIndexValue(metadata.TrackNumber, metadata.TotalTracks))
|
||||||
|
}
|
||||||
|
if metadata.DiscNumber > 0 {
|
||||||
|
addItem("Disc", formatIndexValue(metadata.DiscNumber, metadata.TotalDiscs))
|
||||||
|
}
|
||||||
|
addItem("ISRC", metadata.ISRC)
|
||||||
|
addItem("Lyrics", metadata.Lyrics)
|
||||||
|
addItem("Label", metadata.Label)
|
||||||
|
addItem("Copyright", metadata.Copyright)
|
||||||
|
addItem("Composer", metadata.Composer)
|
||||||
|
addItem("Comment", metadata.Comment)
|
||||||
|
addItem("REPLAYGAIN_TRACK_GAIN", metadata.ReplayGainTrackGain)
|
||||||
|
addItem("REPLAYGAIN_TRACK_PEAK", metadata.ReplayGainTrackPeak)
|
||||||
|
addItem("REPLAYGAIN_ALBUM_GAIN", metadata.ReplayGainAlbumGain)
|
||||||
|
addItem("REPLAYGAIN_ALBUM_PEAK", metadata.ReplayGainAlbumPeak)
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
// apeKeysFromFields builds a set of upper-case APE tag keys corresponding to
|
||||||
|
// the metadata fields map sent by the editor. This is used during merge to
|
||||||
|
// ensure that even empty (cleared) fields override old values.
|
||||||
|
func apeKeysFromFields(fields map[string]string) map[string]struct{} {
|
||||||
|
mapping := map[string]string{
|
||||||
|
"title": "TITLE",
|
||||||
|
"artist": "ARTIST",
|
||||||
|
"album": "ALBUM",
|
||||||
|
"album_artist": "ALBUM ARTIST",
|
||||||
|
"date": "DATE",
|
||||||
|
"genre": "GENRE",
|
||||||
|
"track_number": "TRACK",
|
||||||
|
"disc_number": "DISC",
|
||||||
|
"isrc": "ISRC",
|
||||||
|
"lyrics": "LYRICS",
|
||||||
|
"label": "LABEL",
|
||||||
|
"copyright": "COPYRIGHT",
|
||||||
|
"composer": "COMPOSER",
|
||||||
|
"comment": "COMMENT",
|
||||||
|
"replaygain_track_gain": "REPLAYGAIN_TRACK_GAIN",
|
||||||
|
"replaygain_track_peak": "REPLAYGAIN_TRACK_PEAK",
|
||||||
|
"replaygain_album_gain": "REPLAYGAIN_ALBUM_GAIN",
|
||||||
|
"replaygain_album_peak": "REPLAYGAIN_ALBUM_PEAK",
|
||||||
|
}
|
||||||
|
result := make(map[string]struct{})
|
||||||
|
for fk, apeKey := range mapping {
|
||||||
|
if _, present := fields[fk]; present {
|
||||||
|
result[strings.ToUpper(apeKey)] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Some fields have reader aliases that must also be cleared when the
|
||||||
|
// canonical key is updated (e.g. DATE writer ↔ DATE/YEAR reader,
|
||||||
|
// DISC ↔ DISCNUMBER, TRACK ↔ TRACKNUMBER, "ALBUM ARTIST" ↔ ALBUMARTIST,
|
||||||
|
// LABEL ↔ PUBLISHER, LYRICS ↔ UNSYNCEDLYRICS).
|
||||||
|
if _, present := fields["date"]; present {
|
||||||
|
result["DATE"] = struct{}{}
|
||||||
|
}
|
||||||
|
if _, present := fields["disc_number"]; present {
|
||||||
|
result["DISCNUMBER"] = struct{}{}
|
||||||
|
}
|
||||||
|
if _, present := fields["disc_total"]; present {
|
||||||
|
result["DISCNUMBER"] = struct{}{}
|
||||||
|
}
|
||||||
|
if _, present := fields["track_number"]; present {
|
||||||
|
result["TRACKNUMBER"] = struct{}{}
|
||||||
|
}
|
||||||
|
if _, present := fields["track_total"]; present {
|
||||||
|
result["TRACKNUMBER"] = struct{}{}
|
||||||
|
}
|
||||||
|
if _, present := fields["album_artist"]; present {
|
||||||
|
result["ALBUMARTIST"] = struct{}{}
|
||||||
|
}
|
||||||
|
if _, present := fields["label"]; present {
|
||||||
|
result["PUBLISHER"] = struct{}{}
|
||||||
|
}
|
||||||
|
if _, present := fields["lyrics"]; present {
|
||||||
|
result["UNSYNCEDLYRICS"] = struct{}{}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeAPEItems overlays newItems on top of existing items.
|
||||||
|
// For each new item, if a matching key exists (case-insensitive) in existing,
|
||||||
|
// it is replaced. New keys are appended. Existing items whose keys are NOT
|
||||||
|
// in newItems are preserved (cover art, ReplayGain, custom tags, etc.).
|
||||||
|
//
|
||||||
|
// overrideKeys is an optional set of upper-case keys that should be removed
|
||||||
|
// from existing even if they do not appear in newItems. This handles field
|
||||||
|
// deletion: the caller sends an empty value which is not serialized into
|
||||||
|
// newItems, but the old value must still be dropped.
|
||||||
|
func MergeAPEItems(existing, newItems []APETagItem, overrideKeys map[string]struct{}) []APETagItem {
|
||||||
|
// Build a set of keys being updated (upper-case for case-insensitive match)
|
||||||
|
combined := make(map[string]struct{}, len(newItems)+len(overrideKeys))
|
||||||
|
for k := range overrideKeys {
|
||||||
|
combined[strings.ToUpper(k)] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, item := range newItems {
|
||||||
|
combined[strings.ToUpper(item.Key)] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var merged []APETagItem
|
||||||
|
for _, item := range existing {
|
||||||
|
if _, overwritten := combined[strings.ToUpper(item.Key)]; !overwritten {
|
||||||
|
merged = append(merged, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merged = append(merged, newItems...)
|
||||||
|
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadAPETagsFromReader reads APEv2 tags from an io.ReaderAt + size.
|
||||||
|
// This is useful for reading APE tags from files opened via SAF or other abstractions.
|
||||||
|
func ReadAPETagsFromReader(r io.ReaderAt, fileSize int64) (*APETag, error) {
|
||||||
|
if fileSize < apeTagHeaderSize {
|
||||||
|
return nil, fmt.Errorf("file too small for APE tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try footer at end of file
|
||||||
|
footer := make([]byte, apeTagHeaderSize)
|
||||||
|
if _, err := r.ReadAt(footer, fileSize-apeTagHeaderSize); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read APE footer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(footer[0:8]) == apeTagPreamble {
|
||||||
|
tag, err := parseAPETagFromFooter(r, fileSize, fileSize-apeTagHeaderSize, footer)
|
||||||
|
if err == nil {
|
||||||
|
return tag, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry: skip ID3v1 tag (128 bytes)
|
||||||
|
if fileSize > apeTagHeaderSize+128 {
|
||||||
|
offset := fileSize - apeTagHeaderSize - 128
|
||||||
|
if _, err := r.ReadAt(footer, offset); err == nil {
|
||||||
|
if string(footer[0:8]) == apeTagPreamble {
|
||||||
|
tag, err := parseAPETagFromFooter(r, fileSize, offset, footer)
|
||||||
|
if err == nil {
|
||||||
|
return tag, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no APEv2 tag found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAPETagFromFooter(r io.ReaderAt, fileSize, footerOffset int64, footer []byte) (*APETag, error) {
|
||||||
|
version := binary.LittleEndian.Uint32(footer[8:12])
|
||||||
|
tagSize := binary.LittleEndian.Uint32(footer[12:16])
|
||||||
|
itemCount := binary.LittleEndian.Uint32(footer[16:20])
|
||||||
|
flags := binary.LittleEndian.Uint32(footer[20:24])
|
||||||
|
|
||||||
|
if version != apeTagVersion2 && version != 1000 {
|
||||||
|
return nil, fmt.Errorf("unsupported APE tag version: %d", version)
|
||||||
|
}
|
||||||
|
if tagSize < apeTagHeaderSize {
|
||||||
|
return nil, fmt.Errorf("APE tag size too small: %d", tagSize)
|
||||||
|
}
|
||||||
|
if itemCount > 1000 {
|
||||||
|
return nil, fmt.Errorf("APE tag item count too large: %d", itemCount)
|
||||||
|
}
|
||||||
|
if (flags & apeTagFlagHeader) != 0 {
|
||||||
|
return nil, fmt.Errorf("expected footer, found header")
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsSize := int64(tagSize) - apeTagHeaderSize
|
||||||
|
itemsOffset := footerOffset - itemsSize
|
||||||
|
if itemsOffset < 0 {
|
||||||
|
return nil, fmt.Errorf("APE items extend before file start")
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsData := make([]byte, itemsSize)
|
||||||
|
if _, err := r.ReadAt(itemsData, itemsOffset); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read APE items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := parseAPEItems(itemsData, int(itemCount))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse APE items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &APETag{
|
||||||
|
Version: version,
|
||||||
|
Items: items,
|
||||||
|
ReadOnly: (flags & apeTagFlagReadOnly) != 0,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
+104
-62
@@ -21,13 +21,20 @@ type AudioMetadata struct {
|
|||||||
Year string
|
Year string
|
||||||
Date string
|
Date string
|
||||||
TrackNumber int
|
TrackNumber int
|
||||||
|
TotalTracks int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
|
TotalDiscs int
|
||||||
ISRC string
|
ISRC string
|
||||||
Lyrics string
|
Lyrics string
|
||||||
Label string
|
Label string
|
||||||
Copyright string
|
Copyright string
|
||||||
Composer string
|
Composer string
|
||||||
Comment string
|
Comment string
|
||||||
|
// ReplayGain fields (text values, e.g. "-6.50 dB", "0.988831")
|
||||||
|
ReplayGainTrackGain string
|
||||||
|
ReplayGainTrackPeak string
|
||||||
|
ReplayGainAlbumGain string
|
||||||
|
ReplayGainAlbumPeak string
|
||||||
}
|
}
|
||||||
|
|
||||||
type MP3Quality struct {
|
type MP3Quality struct {
|
||||||
@@ -168,9 +175,9 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
|
|||||||
case "TCO":
|
case "TCO":
|
||||||
metadata.Genre = cleanGenre(value)
|
metadata.Genre = cleanGenre(value)
|
||||||
case "TRK":
|
case "TRK":
|
||||||
metadata.TrackNumber = parseTrackNumber(value)
|
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||||
case "TPA":
|
case "TPA":
|
||||||
metadata.DiscNumber = parseTrackNumber(value)
|
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||||
case "TCM":
|
case "TCM":
|
||||||
metadata.Composer = value
|
metadata.Composer = value
|
||||||
case "TPB":
|
case "TPB":
|
||||||
@@ -287,9 +294,9 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
|
|||||||
case "TCON":
|
case "TCON":
|
||||||
metadata.Genre = cleanGenre(value)
|
metadata.Genre = cleanGenre(value)
|
||||||
case "TRCK":
|
case "TRCK":
|
||||||
metadata.TrackNumber = parseTrackNumber(value)
|
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||||
case "TPOS":
|
case "TPOS":
|
||||||
metadata.DiscNumber = parseTrackNumber(value)
|
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||||
case "TSRC":
|
case "TSRC":
|
||||||
metadata.ISRC = value
|
metadata.ISRC = value
|
||||||
case "TCOM":
|
case "TCOM":
|
||||||
@@ -311,6 +318,17 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
|
|||||||
if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" {
|
if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" {
|
||||||
metadata.Lyrics = userValue
|
metadata.Lyrics = userValue
|
||||||
}
|
}
|
||||||
|
upperDesc := strings.ToUpper(desc)
|
||||||
|
switch upperDesc {
|
||||||
|
case "REPLAYGAIN_TRACK_GAIN":
|
||||||
|
metadata.ReplayGainTrackGain = userValue
|
||||||
|
case "REPLAYGAIN_TRACK_PEAK":
|
||||||
|
metadata.ReplayGainTrackPeak = userValue
|
||||||
|
case "REPLAYGAIN_ALBUM_GAIN":
|
||||||
|
metadata.ReplayGainAlbumGain = userValue
|
||||||
|
case "REPLAYGAIN_ALBUM_PEAK":
|
||||||
|
metadata.ReplayGainAlbumPeak = userValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pos += 10 + frameSize
|
pos += 10 + frameSize
|
||||||
@@ -338,7 +356,6 @@ func readID3v1(file *os.File) (*AudioMetadata, error) {
|
|||||||
Year: strings.TrimRight(string(tag[93:97]), " \x00"),
|
Year: strings.TrimRight(string(tag[93:97]), " \x00"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ID3v1.1 track number (if byte 125 is 0 and byte 126 is not)
|
|
||||||
if tag[125] == 0 && tag[126] != 0 {
|
if tag[125] == 0 && tag[126] != 0 {
|
||||||
metadata.TrackNumber = int(tag[126])
|
metadata.TrackNumber = int(tag[126])
|
||||||
}
|
}
|
||||||
@@ -373,27 +390,23 @@ func extractTextFrame(data []byte) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractCommentFrame parses an ID3v2 COMM frame.
|
|
||||||
// Format: encoding(1) + language(3) + description(null-terminated) + text
|
|
||||||
func extractCommentFrame(data []byte) string {
|
func extractCommentFrame(data []byte) string {
|
||||||
if len(data) < 5 {
|
if len(data) < 5 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
encoding := data[0]
|
encoding := data[0]
|
||||||
// skip 3-byte language code
|
|
||||||
rest := data[4:]
|
rest := data[4:]
|
||||||
|
|
||||||
// find null terminator separating description from text
|
|
||||||
var text []byte
|
var text []byte
|
||||||
switch encoding {
|
switch encoding {
|
||||||
case 1, 2: // UTF-16 variants use double-null terminator
|
case 1, 2:
|
||||||
for i := 0; i+1 < len(rest); i += 2 {
|
for i := 0; i+1 < len(rest); i += 2 {
|
||||||
if rest[i] == 0 && rest[i+1] == 0 {
|
if rest[i] == 0 && rest[i+1] == 0 {
|
||||||
text = rest[i+2:]
|
text = rest[i+2:]
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default: // ISO-8859-1 or UTF-8
|
default:
|
||||||
idx := bytes.IndexByte(rest, 0)
|
idx := bytes.IndexByte(rest, 0)
|
||||||
if idx >= 0 && idx+1 < len(rest) {
|
if idx >= 0 && idx+1 < len(rest) {
|
||||||
text = rest[idx+1:]
|
text = rest[idx+1:]
|
||||||
@@ -406,33 +419,30 @@ func extractCommentFrame(data []byte) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// re-prepend encoding byte so extractTextFrame can decode properly
|
|
||||||
framed := make([]byte, 1+len(text))
|
framed := make([]byte, 1+len(text))
|
||||||
framed[0] = encoding
|
framed[0] = encoding
|
||||||
copy(framed[1:], text)
|
copy(framed[1:], text)
|
||||||
return extractTextFrame(framed)
|
return extractTextFrame(framed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractLyricsFrame parses ID3 unsynchronized lyrics frames (USLT/ULT).
|
|
||||||
// Format: encoding(1) + language(3) + description(null-terminated) + lyrics text.
|
|
||||||
func extractLyricsFrame(data []byte) string {
|
func extractLyricsFrame(data []byte) string {
|
||||||
if len(data) < 5 {
|
if len(data) < 5 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
encoding := data[0]
|
encoding := data[0]
|
||||||
rest := data[4:] // skip 3-byte language code
|
rest := data[4:]
|
||||||
|
|
||||||
var text []byte
|
var text []byte
|
||||||
switch encoding {
|
switch encoding {
|
||||||
case 1, 2: // UTF-16 variants use double-null terminator
|
case 1, 2:
|
||||||
for i := 0; i+1 < len(rest); i += 2 {
|
for i := 0; i+1 < len(rest); i += 2 {
|
||||||
if rest[i] == 0 && rest[i+1] == 0 {
|
if rest[i] == 0 && rest[i+1] == 0 {
|
||||||
text = rest[i+2:]
|
text = rest[i+2:]
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default: // ISO-8859-1 or UTF-8
|
default:
|
||||||
idx := bytes.IndexByte(rest, 0)
|
idx := bytes.IndexByte(rest, 0)
|
||||||
if idx >= 0 && idx+1 < len(rest) {
|
if idx >= 0 && idx+1 < len(rest) {
|
||||||
text = rest[idx+1:]
|
text = rest[idx+1:]
|
||||||
@@ -451,8 +461,6 @@ func extractLyricsFrame(data []byte) string {
|
|||||||
return extractTextFrame(framed)
|
return extractTextFrame(framed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractUserTextFrame parses ID3 TXXX/TXX user text frame:
|
|
||||||
// encoding(1) + description + separator + value.
|
|
||||||
func extractUserTextFrame(data []byte) (string, string) {
|
func extractUserTextFrame(data []byte) (string, string) {
|
||||||
if len(data) < 2 {
|
if len(data) < 2 {
|
||||||
return "", ""
|
return "", ""
|
||||||
@@ -463,7 +471,7 @@ func extractUserTextFrame(data []byte) (string, string) {
|
|||||||
|
|
||||||
var descRaw, valueRaw []byte
|
var descRaw, valueRaw []byte
|
||||||
switch encoding {
|
switch encoding {
|
||||||
case 1, 2: // UTF-16 variants
|
case 1, 2:
|
||||||
for i := 0; i+1 < len(payload); i += 2 {
|
for i := 0; i+1 < len(payload); i += 2 {
|
||||||
if payload[i] == 0 && payload[i+1] == 0 {
|
if payload[i] == 0 && payload[i+1] == 0 {
|
||||||
descRaw = payload[:i]
|
descRaw = payload[:i]
|
||||||
@@ -471,7 +479,7 @@ func extractUserTextFrame(data []byte) (string, string) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default: // ISO-8859-1 or UTF-8
|
default:
|
||||||
idx := bytes.IndexByte(payload, 0)
|
idx := bytes.IndexByte(payload, 0)
|
||||||
if idx >= 0 {
|
if idx >= 0 {
|
||||||
descRaw = payload[:idx]
|
descRaw = payload[:idx]
|
||||||
@@ -498,7 +506,13 @@ func extractUserTextFrame(data []byte) (string, string) {
|
|||||||
|
|
||||||
func isLyricsDescription(description string) bool {
|
func isLyricsDescription(description string) bool {
|
||||||
switch strings.ToLower(strings.TrimSpace(description)) {
|
switch strings.ToLower(strings.TrimSpace(description)) {
|
||||||
case "lyrics", "lyric", "unsyncedlyrics", "unsynced lyrics", "lrc":
|
case
|
||||||
|
"lyrics",
|
||||||
|
"lyric",
|
||||||
|
"unsyncedlyrics",
|
||||||
|
"unsynced lyrics",
|
||||||
|
"uslt",
|
||||||
|
"lrc":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -568,14 +582,28 @@ func cleanGenre(genre string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseTrackNumber(s string) int {
|
func parseTrackNumber(s string) int {
|
||||||
s = strings.TrimSpace(s)
|
num, _ := parseIndexPair(s)
|
||||||
if idx := strings.Index(s, "/"); idx > 0 {
|
|
||||||
s = s[:idx]
|
|
||||||
}
|
|
||||||
num, _ := strconv.Atoi(s)
|
|
||||||
return num
|
return num
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseIndexPair(s string) (int, int) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
first := s
|
||||||
|
second := ""
|
||||||
|
if idx := strings.Index(s, "/"); idx > 0 {
|
||||||
|
first = s[:idx]
|
||||||
|
second = s[idx+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
num, _ := strconv.Atoi(strings.TrimSpace(first))
|
||||||
|
total, _ := strconv.Atoi(strings.TrimSpace(second))
|
||||||
|
return num, total
|
||||||
|
}
|
||||||
|
|
||||||
func removeUnsync(data []byte) []byte {
|
func removeUnsync(data []byte) []byte {
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
return data
|
return data
|
||||||
@@ -659,7 +687,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
|||||||
|
|
||||||
file.Seek(audioStart, io.SeekStart)
|
file.Seek(audioStart, io.SeekStart)
|
||||||
|
|
||||||
// Find first valid MP3 frame sync
|
|
||||||
frameHeader := make([]byte, 4)
|
frameHeader := make([]byte, 4)
|
||||||
var frameStart int64 = -1
|
var frameStart int64 = -1
|
||||||
for i := 0; i < 10000; i++ {
|
for i := 0; i < 10000; i++ {
|
||||||
@@ -686,8 +713,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
|||||||
sampleRateIdx := (frameHeader[2] >> 2) & 0x03
|
sampleRateIdx := (frameHeader[2] >> 2) & 0x03
|
||||||
channelMode := (frameHeader[3] >> 6) & 0x03
|
channelMode := (frameHeader[3] >> 6) & 0x03
|
||||||
|
|
||||||
// Sample rate tables: [version][index]
|
|
||||||
// version: 0=MPEG2.5, 1=reserved, 2=MPEG2, 3=MPEG1
|
|
||||||
sampleRates := [][]int{
|
sampleRates := [][]int{
|
||||||
{11025, 12000, 8000},
|
{11025, 12000, 8000},
|
||||||
{0, 0, 0},
|
{0, 0, 0},
|
||||||
@@ -698,15 +723,12 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
|||||||
quality.SampleRate = sampleRates[version][sampleRateIdx]
|
quality.SampleRate = sampleRates[version][sampleRateIdx]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bitrate tables for all MPEG versions and layers
|
|
||||||
// MPEG1 Layer III
|
|
||||||
if version == 3 && layer == 1 {
|
if version == 3 && layer == 1 {
|
||||||
bitrates := []int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}
|
bitrates := []int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}
|
||||||
if bitrateIdx < 16 {
|
if bitrateIdx < 16 {
|
||||||
quality.Bitrate = bitrates[bitrateIdx] * 1000
|
quality.Bitrate = bitrates[bitrateIdx] * 1000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// MPEG2/2.5 Layer III
|
|
||||||
if (version == 0 || version == 2) && layer == 1 {
|
if (version == 0 || version == 2) && layer == 1 {
|
||||||
bitrates := []int{0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0}
|
bitrates := []int{0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0}
|
||||||
if bitrateIdx < 16 {
|
if bitrateIdx < 16 {
|
||||||
@@ -714,14 +736,11 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine samples per frame for duration calculation
|
|
||||||
samplesPerFrame := 1152 // MPEG1 Layer III
|
samplesPerFrame := 1152 // MPEG1 Layer III
|
||||||
if version == 0 || version == 2 {
|
if version == 0 || version == 2 {
|
||||||
samplesPerFrame = 576 // MPEG2/2.5 Layer III
|
samplesPerFrame = 576 // MPEG2/2.5 Layer III
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to read Xing/VBRI header from the first frame for VBR info
|
|
||||||
// Xing header offset depends on MPEG version and channel mode
|
|
||||||
var xingOffset int
|
var xingOffset int
|
||||||
if version == 3 { // MPEG1
|
if version == 3 { // MPEG1
|
||||||
if channelMode == 3 { // Mono
|
if channelMode == 3 { // Mono
|
||||||
@@ -737,7 +756,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read enough of the first frame to find Xing/VBRI header
|
|
||||||
xingBuf := make([]byte, 200)
|
xingBuf := make([]byte, 200)
|
||||||
file.Seek(frameStart+4, io.SeekStart)
|
file.Seek(frameStart+4, io.SeekStart)
|
||||||
n, _ := io.ReadFull(file, xingBuf)
|
n, _ := io.ReadFull(file, xingBuf)
|
||||||
@@ -747,7 +765,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
|||||||
vbrBytes := int64(0)
|
vbrBytes := int64(0)
|
||||||
isVBR := false
|
isVBR := false
|
||||||
|
|
||||||
// Check for Xing/Info header
|
|
||||||
if xingOffset+8 <= n {
|
if xingOffset+8 <= n {
|
||||||
tag := string(xingBuf[xingOffset : xingOffset+4])
|
tag := string(xingBuf[xingOffset : xingOffset+4])
|
||||||
if tag == "Xing" || tag == "Info" {
|
if tag == "Xing" || tag == "Info" {
|
||||||
@@ -766,7 +783,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for VBRI header (always at offset 32 from frame start + 4)
|
|
||||||
if !isVBR && 36+26 <= n {
|
if !isVBR && 36+26 <= n {
|
||||||
if string(xingBuf[32:36]) == "VBRI" {
|
if string(xingBuf[32:36]) == "VBRI" {
|
||||||
vbrBytes = int64(binary.BigEndian.Uint32(xingBuf[36+6 : 36+10]))
|
vbrBytes = int64(binary.BigEndian.Uint32(xingBuf[36+6 : 36+10]))
|
||||||
@@ -778,11 +794,9 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if isVBR && vbrFrames > 0 && quality.SampleRate > 0 {
|
if isVBR && vbrFrames > 0 && quality.SampleRate > 0 {
|
||||||
// Accurate duration from total frames
|
|
||||||
totalSamples := int64(vbrFrames) * int64(samplesPerFrame)
|
totalSamples := int64(vbrFrames) * int64(samplesPerFrame)
|
||||||
quality.Duration = int(totalSamples / int64(quality.SampleRate))
|
quality.Duration = int(totalSamples / int64(quality.SampleRate))
|
||||||
|
|
||||||
// Accurate average bitrate
|
|
||||||
if vbrBytes > 0 && quality.Duration > 0 {
|
if vbrBytes > 0 && quality.Duration > 0 {
|
||||||
quality.Bitrate = int(vbrBytes * 8 / int64(quality.Duration))
|
quality.Bitrate = int(vbrBytes * 8 / int64(quality.Duration))
|
||||||
} else if quality.Duration > 0 {
|
} else if quality.Duration > 0 {
|
||||||
@@ -790,7 +804,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
|||||||
quality.Bitrate = int(audioSize * 8 / int64(quality.Duration))
|
quality.Bitrate = int(audioSize * 8 / int64(quality.Duration))
|
||||||
}
|
}
|
||||||
} else if quality.Bitrate > 0 {
|
} else if quality.Bitrate > 0 {
|
||||||
// CBR fallback: estimate duration from file size and frame bitrate
|
|
||||||
audioSize := fileSize - audioStart - 128 // subtract possible ID3v1 tag
|
audioSize := fileSize - audioStart - 128 // subtract possible ID3v1 tag
|
||||||
if audioSize > 0 {
|
if audioSize > 0 {
|
||||||
quality.Duration = int(audioSize * 8 / int64(quality.Bitrate))
|
quality.Duration = int(audioSize * 8 / int64(quality.Bitrate))
|
||||||
@@ -974,8 +987,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
reader := bytes.NewReader(data)
|
reader := bytes.NewReader(data)
|
||||||
|
artistValues := make([]string, 0, 1)
|
||||||
|
albumArtistValues := make([]string, 0, 1)
|
||||||
|
|
||||||
// Read vendor string length
|
|
||||||
var vendorLen uint32
|
var vendorLen uint32
|
||||||
if err := binary.Read(reader, binary.LittleEndian, &vendorLen); err != nil {
|
if err := binary.Read(reader, binary.LittleEndian, &vendorLen); err != nil {
|
||||||
return
|
return
|
||||||
@@ -1004,8 +1018,6 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
|||||||
if commentLen > remaining {
|
if commentLen > remaining {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
// Large comment entries are typically METADATA_BLOCK_PICTURE.
|
|
||||||
// Skip them so we can continue parsing normal text tags after/before.
|
|
||||||
if commentLen > 512*1024 {
|
if commentLen > 512*1024 {
|
||||||
reader.Seek(int64(commentLen), io.SeekCurrent)
|
reader.Seek(int64(commentLen), io.SeekCurrent)
|
||||||
continue
|
continue
|
||||||
@@ -1028,9 +1040,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
|||||||
case "TITLE":
|
case "TITLE":
|
||||||
metadata.Title = value
|
metadata.Title = value
|
||||||
case "ARTIST":
|
case "ARTIST":
|
||||||
metadata.Artist = value
|
artistValues = append(artistValues, value)
|
||||||
case "ALBUMARTIST", "ALBUM_ARTIST", "ALBUM ARTIST":
|
case "ALBUMARTIST", "ALBUM_ARTIST", "ALBUM ARTIST":
|
||||||
metadata.AlbumArtist = value
|
albumArtistValues = append(albumArtistValues, value)
|
||||||
case "ALBUM":
|
case "ALBUM":
|
||||||
metadata.Album = value
|
metadata.Album = value
|
||||||
case "DATE", "YEAR":
|
case "DATE", "YEAR":
|
||||||
@@ -1041,9 +1053,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
|||||||
case "GENRE":
|
case "GENRE":
|
||||||
metadata.Genre = value
|
metadata.Genre = value
|
||||||
case "TRACKNUMBER", "TRACK":
|
case "TRACKNUMBER", "TRACK":
|
||||||
metadata.TrackNumber = parseTrackNumber(value)
|
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||||
case "DISCNUMBER", "DISC":
|
case "DISCNUMBER", "DISC":
|
||||||
metadata.DiscNumber = parseTrackNumber(value)
|
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||||
case "ISRC":
|
case "ISRC":
|
||||||
metadata.ISRC = value
|
metadata.ISRC = value
|
||||||
case "COMPOSER":
|
case "COMPOSER":
|
||||||
@@ -1058,8 +1070,23 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
|||||||
metadata.Label = value
|
metadata.Label = value
|
||||||
case "COPYRIGHT":
|
case "COPYRIGHT":
|
||||||
metadata.Copyright = value
|
metadata.Copyright = value
|
||||||
|
case "REPLAYGAIN_TRACK_GAIN":
|
||||||
|
metadata.ReplayGainTrackGain = value
|
||||||
|
case "REPLAYGAIN_TRACK_PEAK":
|
||||||
|
metadata.ReplayGainTrackPeak = value
|
||||||
|
case "REPLAYGAIN_ALBUM_GAIN":
|
||||||
|
metadata.ReplayGainAlbumGain = value
|
||||||
|
case "REPLAYGAIN_ALBUM_PEAK":
|
||||||
|
metadata.ReplayGainAlbumPeak = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(artistValues) > 0 {
|
||||||
|
metadata.Artist = joinVorbisCommentValues(artistValues)
|
||||||
|
}
|
||||||
|
if len(albumArtistValues) > 0 {
|
||||||
|
metadata.AlbumArtist = joinVorbisCommentValues(albumArtistValues)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetOggQuality(filePath string) (*OggQuality, error) {
|
func GetOggQuality(filePath string) (*OggQuality, error) {
|
||||||
@@ -1108,7 +1135,6 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read granule position from the last Ogg page for accurate duration
|
|
||||||
stat, err := file.Stat()
|
stat, err := file.Stat()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return quality, nil
|
return quality, nil
|
||||||
@@ -1118,7 +1144,6 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
|
|||||||
granule := readLastOggGranulePosition(file, fileSize)
|
granule := readLastOggGranulePosition(file, fileSize)
|
||||||
if granule > 0 {
|
if granule > 0 {
|
||||||
if isOpus {
|
if isOpus {
|
||||||
// Opus always uses 48kHz granule position internally
|
|
||||||
totalSamples := granule - int64(preSkip)
|
totalSamples := granule - int64(preSkip)
|
||||||
if totalSamples > 0 {
|
if totalSamples > 0 {
|
||||||
durationSec := float64(totalSamples) / 48000.0
|
durationSec := float64(totalSamples) / 48000.0
|
||||||
@@ -1136,11 +1161,9 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback bitrate estimate if duration exists but bitrate couldn't be derived.
|
|
||||||
if quality.Bitrate <= 0 && quality.Duration > 0 {
|
if quality.Bitrate <= 0 && quality.Duration > 0 {
|
||||||
quality.Bitrate = int(fileSize * 8 / int64(quality.Duration))
|
quality.Bitrate = int(fileSize * 8 / int64(quality.Duration))
|
||||||
}
|
}
|
||||||
// Guard against obviously invalid values from corrupted/unreliable granule reads.
|
|
||||||
if quality.Duration > 24*60*60 {
|
if quality.Duration > 24*60*60 {
|
||||||
quality.Duration = 0
|
quality.Duration = 0
|
||||||
quality.Bitrate = 0
|
quality.Bitrate = 0
|
||||||
@@ -1152,10 +1175,7 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
|
|||||||
return quality, nil
|
return quality, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// readLastOggGranulePosition seeks to the end of the file and scans backwards
|
|
||||||
// to find the last Ogg page, then reads its granule position (bytes 6-13).
|
|
||||||
func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
|
func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
|
||||||
// Read the last chunk of the file to find the last OggS sync
|
|
||||||
searchSize := int64(65536)
|
searchSize := int64(65536)
|
||||||
if searchSize > fileSize {
|
if searchSize > fileSize {
|
||||||
searchSize = fileSize
|
searchSize = fileSize
|
||||||
@@ -1179,7 +1199,6 @@ func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
|
|||||||
if i+27 > n {
|
if i+27 > n {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Validate minimal header fields to avoid false positives inside payload bytes.
|
|
||||||
version := buf[i+4]
|
version := buf[i+4]
|
||||||
headerType := buf[i+5]
|
headerType := buf[i+5]
|
||||||
if version != 0 || headerType > 0x07 {
|
if version != 0 || headerType > 0x07 {
|
||||||
@@ -1197,7 +1216,6 @@ func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
|
|||||||
if i+headerLen+payloadLen > n {
|
if i+headerLen+payloadLen > n {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Granule position is at bytes 6-13 of the Ogg page header (little-endian int64).
|
|
||||||
return int64(binary.LittleEndian.Uint64(buf[i+6 : i+14]))
|
return int64(binary.LittleEndian.Uint64(buf[i+6 : i+14]))
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
@@ -1257,7 +1275,6 @@ func extractMP3CoverArt(filePath string) ([]byte, string, error) {
|
|||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse frames looking for APIC (Attached Picture)
|
|
||||||
pos := 0
|
pos := 0
|
||||||
var frameIDLen, headerLen int
|
var frameIDLen, headerLen int
|
||||||
if majorVersion == 2 {
|
if majorVersion == 2 {
|
||||||
@@ -1288,7 +1305,6 @@ func extractMP3CoverArt(filePath string) ([]byte, string, error) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for APIC (ID3v2.3/2.4) or PIC (ID3v2.2)
|
|
||||||
if (frameIDLen == 4 && frameID == "APIC") || (frameIDLen == 3 && frameID == "PIC") {
|
if (frameIDLen == 4 && frameID == "APIC") || (frameIDLen == 3 && frameID == "PIC") {
|
||||||
frameData := tagData[pos+headerLen : pos+headerLen+frameSize]
|
frameData := tagData[pos+headerLen : pos+headerLen+frameSize]
|
||||||
imageData, mimeType := parseAPICFrame(frameData, majorVersion)
|
imageData, mimeType := parseAPICFrame(frameData, majorVersion)
|
||||||
@@ -1594,7 +1610,19 @@ func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, strin
|
|||||||
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)
|
||||||
@@ -1602,14 +1630,28 @@ func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SaveCoverToCache(filePath, cacheDir string) (string, error) {
|
func SaveCoverToCache(filePath, cacheDir string) (string, error) {
|
||||||
return SaveCoverToCacheWithHint(filePath, "", cacheDir)
|
return SaveCoverToCacheWithHintAndKey(filePath, "", cacheDir, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func SaveCoverToCacheWithHint(filePath, displayNameHint, cacheDir string) (string, error) {
|
func SaveCoverToCacheWithHint(filePath, displayNameHint, cacheDir string) (string, error) {
|
||||||
|
return SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveLibraryCoverCacheKey(filePath, explicitKey string) string {
|
||||||
|
explicitKey = strings.TrimSpace(explicitKey)
|
||||||
|
if explicitKey != "" {
|
||||||
|
return explicitKey
|
||||||
|
}
|
||||||
|
|
||||||
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())
|
||||||
}
|
}
|
||||||
|
return cacheKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, coverCacheKey string) (string, error) {
|
||||||
|
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
|
||||||
hash := hashString(cacheKey)
|
hash := hashString(cacheKey)
|
||||||
|
|
||||||
jpgPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.jpg", hash))
|
jpgPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.jpg", hash))
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveLibraryCoverCacheKeyUsesExplicitKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const explicitKey = "content://media/external/audio/media/42|123456"
|
||||||
|
got := resolveLibraryCoverCacheKey("/tmp/saf_random.flac", explicitKey)
|
||||||
|
if got != explicitKey {
|
||||||
|
t.Fatalf("expected explicit cache key %q, got %q", explicitKey, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveLibraryCoverCacheKeyUsesFilePathAndStatWhenNoExplicitKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tempFile, err := os.CreateTemp("", "cover-cache-*.flac")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTemp failed: %v", err)
|
||||||
|
}
|
||||||
|
tempPath := tempFile.Name()
|
||||||
|
tempFile.Close()
|
||||||
|
defer os.Remove(tempPath)
|
||||||
|
|
||||||
|
got := resolveLibraryCoverCacheKey(tempPath, "")
|
||||||
|
if !strings.HasPrefix(got, tempPath+"|") {
|
||||||
|
t.Fatalf("expected stat-based cache key to start with %q, got %q", tempPath+"|", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ffmpegCommand(args ...string) *exec.Cmd {
|
||||||
|
if ffmpegPath, err := exec.LookPath("ffmpeg"); err == nil {
|
||||||
|
return exec.Command(ffmpegPath, args...)
|
||||||
|
}
|
||||||
|
return exec.Command("ffmpeg", args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runFFmpegTestCommand(t *testing.T, args ...string) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := ffmpegCommand(args...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ffmpeg failed: %v\n%s", err, string(output))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractLyricsReadsMp3AfterCoverEmbed(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||||
|
t.Skip("ffmpeg not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
sourceFlac := filepath.Join(tempDir, "source.flac")
|
||||||
|
baseMp3 := filepath.Join(tempDir, "base.mp3")
|
||||||
|
finalMp3 := filepath.Join(tempDir, "final.mp3")
|
||||||
|
coverPath := filepath.Join(tempDir, "cover.jpg")
|
||||||
|
lyrics := "[ti:Test Song]\n[ar:Test Artist]\n[00:00.00]Hello from embedded lyrics"
|
||||||
|
|
||||||
|
runFFmpegTestCommand(
|
||||||
|
t,
|
||||||
|
"-y",
|
||||||
|
"-f",
|
||||||
|
"lavfi",
|
||||||
|
"-i",
|
||||||
|
"sine=frequency=440:duration=1",
|
||||||
|
"-c:a",
|
||||||
|
"flac",
|
||||||
|
sourceFlac,
|
||||||
|
)
|
||||||
|
|
||||||
|
runFFmpegTestCommand(
|
||||||
|
t,
|
||||||
|
"-y",
|
||||||
|
"-f",
|
||||||
|
"lavfi",
|
||||||
|
"-i",
|
||||||
|
"color=c=red:s=32x32:d=1",
|
||||||
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
coverPath,
|
||||||
|
)
|
||||||
|
|
||||||
|
runFFmpegTestCommand(
|
||||||
|
t,
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
sourceFlac,
|
||||||
|
"-b:a",
|
||||||
|
"320k",
|
||||||
|
"-metadata",
|
||||||
|
"title=Test Song",
|
||||||
|
"-metadata",
|
||||||
|
"artist=Test Artist",
|
||||||
|
"-metadata",
|
||||||
|
"lyrics="+lyrics,
|
||||||
|
baseMp3,
|
||||||
|
)
|
||||||
|
|
||||||
|
runFFmpegTestCommand(
|
||||||
|
t,
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
baseMp3,
|
||||||
|
"-i",
|
||||||
|
coverPath,
|
||||||
|
"-map",
|
||||||
|
"0:a",
|
||||||
|
"-map_metadata",
|
||||||
|
"-1",
|
||||||
|
"-map",
|
||||||
|
"1:0",
|
||||||
|
"-c:v:0",
|
||||||
|
"copy",
|
||||||
|
"-id3v2_version",
|
||||||
|
"3",
|
||||||
|
"-metadata",
|
||||||
|
"title=Test Song",
|
||||||
|
"-metadata",
|
||||||
|
"artist=Test Artist",
|
||||||
|
"-metadata",
|
||||||
|
"lyrics="+lyrics,
|
||||||
|
"-metadata:s:v",
|
||||||
|
"title=Album cover",
|
||||||
|
"-metadata:s:v",
|
||||||
|
"comment=Cover (front)",
|
||||||
|
"-c:a",
|
||||||
|
"copy",
|
||||||
|
finalMp3,
|
||||||
|
)
|
||||||
|
|
||||||
|
meta, err := ReadID3Tags(finalMp3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadID3Tags failed: %v", err)
|
||||||
|
}
|
||||||
|
if meta == nil {
|
||||||
|
t.Fatalf("ReadID3Tags returned nil metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
embeddedLyrics, err := ExtractLyrics(finalMp3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExtractLyrics failed: %v (metadata=%+v)", err, meta)
|
||||||
|
}
|
||||||
|
if !strings.Contains(embeddedLyrics, "Hello from embedded lyrics") {
|
||||||
|
t.Fatalf("embedded lyrics missing, got %q (metadata=%+v)", embeddedLyrics, meta)
|
||||||
|
}
|
||||||
|
if !strings.Contains(meta.Lyrics, "Hello from embedded lyrics") {
|
||||||
|
t.Fatalf("ReadID3Tags lyrics missing, got %+v", meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(finalMp3); err != nil {
|
||||||
|
t.Fatalf("expected final mp3 to exist: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-4
@@ -17,6 +17,8 @@ const (
|
|||||||
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
|
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
|
||||||
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
||||||
|
|
||||||
|
var tidalSizeRegex = regexp.MustCompile(`/\d+x\d+\.jpg$`)
|
||||||
|
|
||||||
func convertSmallToMedium(imageURL string) string {
|
func convertSmallToMedium(imageURL string) string {
|
||||||
if strings.Contains(imageURL, spotifySize300) {
|
if strings.Contains(imageURL, spotifySize300) {
|
||||||
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||||
@@ -40,7 +42,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
|||||||
maxURL := upgradeToMaxQuality(downloadURL)
|
maxURL := upgradeToMaxQuality(downloadURL)
|
||||||
if maxURL != downloadURL {
|
if maxURL != downloadURL {
|
||||||
downloadURL = maxURL
|
downloadURL = maxURL
|
||||||
// Log already printed by upgradeToMaxQuality for Deezer
|
|
||||||
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
|
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
|
||||||
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
|
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
|
||||||
}
|
}
|
||||||
@@ -86,16 +87,22 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func upgradeToMaxQuality(coverURL string) string {
|
func upgradeToMaxQuality(coverURL string) string {
|
||||||
// Spotify CDN upgrade
|
|
||||||
if strings.Contains(coverURL, spotifySize640) {
|
if strings.Contains(coverURL, spotifySize640) {
|
||||||
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deezer CDN upgrade
|
|
||||||
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||||
return upgradeDeezerCover(coverURL)
|
return upgradeDeezerCover(coverURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.Contains(coverURL, "resources.tidal.com") {
|
||||||
|
return upgradeTidalCover(coverURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(coverURL, "static.qobuz.com") {
|
||||||
|
return upgradeQobuzCover(coverURL)
|
||||||
|
}
|
||||||
|
|
||||||
return coverURL
|
return coverURL
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,12 +118,35 @@ func upgradeDeezerCover(coverURL string) string {
|
|||||||
return upgraded
|
return upgraded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func upgradeTidalCover(coverURL string) string {
|
||||||
|
if !strings.Contains(coverURL, "resources.tidal.com") {
|
||||||
|
return coverURL
|
||||||
|
}
|
||||||
|
|
||||||
|
upgraded := tidalSizeRegex.ReplaceAllString(coverURL, "/origin.jpg")
|
||||||
|
if upgraded != coverURL {
|
||||||
|
GoLog("[Cover] Tidal: upgraded to origin resolution")
|
||||||
|
}
|
||||||
|
return upgraded
|
||||||
|
}
|
||||||
|
|
||||||
|
func upgradeQobuzCover(coverURL string) string {
|
||||||
|
if !strings.Contains(coverURL, "static.qobuz.com") {
|
||||||
|
return coverURL
|
||||||
|
}
|
||||||
|
|
||||||
|
upgraded := qobuzImageSizeRe.ReplaceAllString(coverURL, "_max.jpg")
|
||||||
|
if upgraded != coverURL {
|
||||||
|
GoLog("[Cover] Qobuz: upgraded to max resolution")
|
||||||
|
}
|
||||||
|
return upgraded
|
||||||
|
}
|
||||||
|
|
||||||
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
||||||
if imageURL == "" {
|
if imageURL == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always upgrade small to medium first
|
|
||||||
result := convertSmallToMedium(imageURL)
|
result := convertSmallToMedium(imageURL)
|
||||||
|
|
||||||
if maxQuality {
|
if maxQuality {
|
||||||
|
|||||||
+41
-66
@@ -11,9 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CueSheet represents a parsed .cue file
|
|
||||||
type CueSheet struct {
|
type CueSheet struct {
|
||||||
// Album-level metadata
|
|
||||||
Performer string `json:"performer"`
|
Performer string `json:"performer"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
FileName string `json:"file_name"`
|
FileName string `json:"file_name"`
|
||||||
@@ -25,19 +23,16 @@ type CueSheet struct {
|
|||||||
Tracks []CueTrack `json:"tracks"`
|
Tracks []CueTrack `json:"tracks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CueTrack represents a single track in a cue sheet
|
|
||||||
type CueTrack struct {
|
type CueTrack struct {
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Performer string `json:"performer"`
|
Performer string `json:"performer"`
|
||||||
ISRC string `json:"isrc,omitempty"`
|
ISRC string `json:"isrc,omitempty"`
|
||||||
Composer string `json:"composer,omitempty"`
|
Composer string `json:"composer,omitempty"`
|
||||||
// Index positions in seconds (fractional)
|
|
||||||
StartTime float64 `json:"start_time"` // INDEX 01 in seconds
|
StartTime float64 `json:"start_time"` // INDEX 01 in seconds
|
||||||
PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present)
|
PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CueSplitInfo represents the information needed to split a CUE+audio file
|
|
||||||
type CueSplitInfo struct {
|
type CueSplitInfo struct {
|
||||||
CuePath string `json:"cue_path"`
|
CuePath string `json:"cue_path"`
|
||||||
AudioPath string `json:"audio_path"`
|
AudioPath string `json:"audio_path"`
|
||||||
@@ -48,7 +43,6 @@ type CueSplitInfo struct {
|
|||||||
Tracks []CueSplitTrack `json:"tracks"`
|
Tracks []CueSplitTrack `json:"tracks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CueSplitTrack has the FFmpeg split parameters for a single track
|
|
||||||
type CueSplitTrack struct {
|
type CueSplitTrack struct {
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
@@ -64,7 +58,6 @@ var (
|
|||||||
reQuoted = regexp.MustCompile(`"([^"]*)"`)
|
reQuoted = regexp.MustCompile(`"([^"]*)"`)
|
||||||
)
|
)
|
||||||
|
|
||||||
// ParseCueFile parses a .cue file and returns a CueSheet
|
|
||||||
func ParseCueFile(cuePath string) (*CueSheet, error) {
|
func ParseCueFile(cuePath string) (*CueSheet, error) {
|
||||||
f, err := os.Open(cuePath)
|
f, err := os.Open(cuePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -82,7 +75,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle BOM at start of file
|
|
||||||
if strings.HasPrefix(line, "\xef\xbb\xbf") {
|
if strings.HasPrefix(line, "\xef\xbb\xbf") {
|
||||||
line = strings.TrimPrefix(line, "\xef\xbb\xbf")
|
line = strings.TrimPrefix(line, "\xef\xbb\xbf")
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
@@ -90,7 +82,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
|
|
||||||
upper := strings.ToUpper(line)
|
upper := strings.ToUpper(line)
|
||||||
|
|
||||||
// REM commands (album-level metadata)
|
|
||||||
if strings.HasPrefix(upper, "REM ") {
|
if strings.HasPrefix(upper, "REM ") {
|
||||||
matches := reRemCommand.FindStringSubmatch(line)
|
matches := reRemCommand.FindStringSubmatch(line)
|
||||||
if len(matches) == 3 {
|
if len(matches) == 3 {
|
||||||
@@ -136,9 +127,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
|
|
||||||
if strings.HasPrefix(upper, "FILE ") {
|
if strings.HasPrefix(upper, "FILE ") {
|
||||||
rest := line[len("FILE "):]
|
rest := line[len("FILE "):]
|
||||||
// Extract filename and type
|
|
||||||
// Format: FILE "filename.flac" WAVE
|
|
||||||
// or: FILE filename.flac WAVE
|
|
||||||
fname, ftype := parseCueFileLine(rest)
|
fname, ftype := parseCueFileLine(rest)
|
||||||
sheet.FileName = fname
|
sheet.FileName = fname
|
||||||
sheet.FileType = ftype
|
sheet.FileType = ftype
|
||||||
@@ -146,7 +134,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(upper, "TRACK ") {
|
if strings.HasPrefix(upper, "TRACK ") {
|
||||||
// Save previous track
|
|
||||||
if currentTrack != nil {
|
if currentTrack != nil {
|
||||||
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
||||||
}
|
}
|
||||||
@@ -184,7 +171,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// SONGWRITER (used as composer sometimes)
|
|
||||||
if strings.HasPrefix(upper, "SONGWRITER ") {
|
if strings.HasPrefix(upper, "SONGWRITER ") {
|
||||||
value := unquoteCue(line[len("SONGWRITER "):])
|
value := unquoteCue(line[len("SONGWRITER "):])
|
||||||
if currentTrack != nil {
|
if currentTrack != nil {
|
||||||
@@ -196,7 +182,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't forget the last track
|
|
||||||
if currentTrack != nil {
|
if currentTrack != nil {
|
||||||
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
||||||
}
|
}
|
||||||
@@ -212,7 +197,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
return sheet, nil
|
return sheet, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseCueTimestamp converts MM:SS:FF (frames at 75fps) to seconds
|
|
||||||
func parseCueTimestamp(ts string) float64 {
|
func parseCueTimestamp(ts string) float64 {
|
||||||
parts := strings.Split(ts, ":")
|
parts := strings.Split(ts, ":")
|
||||||
if len(parts) != 3 {
|
if len(parts) != 3 {
|
||||||
@@ -226,7 +210,6 @@ func parseCueTimestamp(ts string) float64 {
|
|||||||
return float64(minutes)*60 + float64(seconds) + float64(frames)/75.0
|
return float64(minutes)*60 + float64(seconds) + float64(frames)/75.0
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatCueTimestamp converts seconds to HH:MM:SS.mmm format for FFmpeg
|
|
||||||
func formatCueTimestamp(seconds float64) string {
|
func formatCueTimestamp(seconds float64) string {
|
||||||
if seconds < 0 {
|
if seconds < 0 {
|
||||||
return "0"
|
return "0"
|
||||||
@@ -237,7 +220,6 @@ func formatCueTimestamp(seconds float64) string {
|
|||||||
return fmt.Sprintf("%02d:%02d:%06.3f", hours, mins, secs)
|
return fmt.Sprintf("%02d:%02d:%06.3f", hours, mins, secs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// unquoteCue removes surrounding quotes from a CUE value
|
|
||||||
func unquoteCue(s string) string {
|
func unquoteCue(s string) string {
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
if matches := reQuoted.FindStringSubmatch(s); len(matches) == 2 {
|
if matches := reQuoted.FindStringSubmatch(s); len(matches) == 2 {
|
||||||
@@ -246,14 +228,12 @@ func unquoteCue(s string) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseCueFileLine parses the FILE command's filename and type
|
|
||||||
func parseCueFileLine(rest string) (string, string) {
|
func parseCueFileLine(rest string) (string, string) {
|
||||||
rest = strings.TrimSpace(rest)
|
rest = strings.TrimSpace(rest)
|
||||||
|
|
||||||
var filename, ftype string
|
var filename, ftype string
|
||||||
|
|
||||||
if strings.HasPrefix(rest, "\"") {
|
if strings.HasPrefix(rest, "\"") {
|
||||||
// Quoted filename
|
|
||||||
endQuote := strings.Index(rest[1:], "\"")
|
endQuote := strings.Index(rest[1:], "\"")
|
||||||
if endQuote >= 0 {
|
if endQuote >= 0 {
|
||||||
filename = rest[1 : endQuote+1]
|
filename = rest[1 : endQuote+1]
|
||||||
@@ -263,7 +243,6 @@ func parseCueFileLine(rest string) (string, string) {
|
|||||||
filename = rest
|
filename = rest
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Unquoted filename - last word is the type
|
|
||||||
parts := strings.Fields(rest)
|
parts := strings.Fields(rest)
|
||||||
if len(parts) >= 2 {
|
if len(parts) >= 2 {
|
||||||
ftype = parts[len(parts)-1]
|
ftype = parts[len(parts)-1]
|
||||||
@@ -276,18 +255,14 @@ func parseCueFileLine(rest string) (string, string) {
|
|||||||
return filename, strings.TrimSpace(ftype)
|
return filename, strings.TrimSpace(ftype)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveCueAudioPath finds the actual audio file referenced by a .cue sheet.
|
|
||||||
// It checks relative to the cue file's directory.
|
|
||||||
func ResolveCueAudioPath(cuePath string, cueFileName string) string {
|
func ResolveCueAudioPath(cuePath string, cueFileName string) string {
|
||||||
cueDir := filepath.Dir(cuePath)
|
cueDir := filepath.Dir(cuePath)
|
||||||
|
|
||||||
// 1. Try the exact filename from the .cue
|
|
||||||
candidate := filepath.Join(cueDir, cueFileName)
|
candidate := filepath.Join(cueDir, cueFileName)
|
||||||
if _, err := os.Stat(candidate); err == nil {
|
if _, err := os.Stat(candidate); err == nil {
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Try common case variations
|
|
||||||
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
|
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
|
||||||
commonExts := []string{".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
|
commonExts := []string{".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
|
||||||
for _, ext := range commonExts {
|
for _, ext := range commonExts {
|
||||||
@@ -295,14 +270,12 @@ func ResolveCueAudioPath(cuePath string, cueFileName string) string {
|
|||||||
if _, err := os.Stat(candidate); err == nil {
|
if _, err := os.Stat(candidate); err == nil {
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
// Try uppercase ext
|
|
||||||
candidate = filepath.Join(cueDir, baseName+strings.ToUpper(ext))
|
candidate = filepath.Join(cueDir, baseName+strings.ToUpper(ext))
|
||||||
if _, err := os.Stat(candidate); err == nil {
|
if _, err := os.Stat(candidate); err == nil {
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Try to find any audio file with the same base name as the .cue file
|
|
||||||
cueBase := strings.TrimSuffix(filepath.Base(cuePath), filepath.Ext(cuePath))
|
cueBase := strings.TrimSuffix(filepath.Base(cuePath), filepath.Ext(cuePath))
|
||||||
for _, ext := range commonExts {
|
for _, ext := range commonExts {
|
||||||
candidate = filepath.Join(cueDir, cueBase+ext)
|
candidate = filepath.Join(cueDir, cueBase+ext)
|
||||||
@@ -311,7 +284,6 @@ func ResolveCueAudioPath(cuePath string, cueFileName string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. If there's only one audio file in the directory, use that
|
|
||||||
entries, err := os.ReadDir(cueDir)
|
entries, err := os.ReadDir(cueDir)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
audioExts := map[string]bool{
|
audioExts := map[string]bool{
|
||||||
@@ -336,13 +308,9 @@ func ResolveCueAudioPath(cuePath string, cueFileName string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildCueSplitInfo creates the split information from a parsed CUE sheet.
|
|
||||||
// This is returned to the Dart side so FFmpeg can perform the splitting.
|
|
||||||
// audioDir, if non-empty, overrides the directory for audio file resolution.
|
|
||||||
func BuildCueSplitInfo(cuePath string, sheet *CueSheet, audioDir string) (*CueSplitInfo, error) {
|
func BuildCueSplitInfo(cuePath string, sheet *CueSheet, audioDir string) (*CueSplitInfo, error) {
|
||||||
resolveDir := cuePath
|
resolveDir := cuePath
|
||||||
if audioDir != "" {
|
if audioDir != "" {
|
||||||
// Create a virtual path in audioDir so ResolveCueAudioPath looks there
|
|
||||||
resolveDir = filepath.Join(audioDir, filepath.Base(cuePath))
|
resolveDir = filepath.Join(audioDir, filepath.Base(cuePath))
|
||||||
}
|
}
|
||||||
audioPath := ResolveCueAudioPath(resolveDir, sheet.FileName)
|
audioPath := ResolveCueAudioPath(resolveDir, sheet.FileName)
|
||||||
@@ -370,11 +338,9 @@ func BuildCueSplitInfo(cuePath string, sheet *CueSheet, audioDir string) (*CueSp
|
|||||||
composer = sheet.Composer
|
composer = sheet.Composer
|
||||||
}
|
}
|
||||||
|
|
||||||
// End time is the start of the next track, or -1 for the last track
|
|
||||||
endSec := float64(-1)
|
endSec := float64(-1)
|
||||||
if i+1 < len(sheet.Tracks) {
|
if i+1 < len(sheet.Tracks) {
|
||||||
nextTrack := sheet.Tracks[i+1]
|
nextTrack := sheet.Tracks[i+1]
|
||||||
// Use pre-gap of next track if available, otherwise its start time
|
|
||||||
if nextTrack.PreGap >= 0 {
|
if nextTrack.PreGap >= 0 {
|
||||||
endSec = nextTrack.PreGap
|
endSec = nextTrack.PreGap
|
||||||
} else {
|
} else {
|
||||||
@@ -396,11 +362,6 @@ func BuildCueSplitInfo(cuePath string, sheet *CueSheet, audioDir string) (*CueSp
|
|||||||
return info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseCueFileJSON parses a .cue file and returns JSON with split info.
|
|
||||||
// This is the main entry point called from Dart via the platform bridge.
|
|
||||||
// audioDir, if non-empty, overrides the directory used for resolving the
|
|
||||||
// referenced audio file (useful when the .cue was copied to a temp dir
|
|
||||||
// but the audio still lives in the original location, e.g. SAF).
|
|
||||||
func ParseCueFileJSON(cuePath string, audioDir string) (string, error) {
|
func ParseCueFileJSON(cuePath string, audioDir string) (string, error) {
|
||||||
sheet, err := ParseCueFile(cuePath)
|
sheet, err := ParseCueFile(cuePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -420,9 +381,6 @@ func ParseCueFileJSON(cuePath string, audioDir string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScanCueFileForLibrary parses a .cue file and returns multiple LibraryScanResult
|
|
||||||
// entries, one per track. This is used by the library scanner to populate the
|
|
||||||
// library with individual track entries from a single CUE+FLAC album.
|
|
||||||
func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult, error) {
|
func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult, error) {
|
||||||
sheet, err := ParseCueFile(cuePath)
|
sheet, err := ParseCueFile(cuePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -432,17 +390,21 @@ func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return scanCueSheetForLibrary(cuePath, sheet, audioPath, "", 0, scanTime)
|
return scanCueSheetForLibrary(cuePath, sheet, audioPath, "", 0, "", scanTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScanCueFileForLibraryExt is like ScanCueFileForLibrary but with extra parameters
|
|
||||||
// for SAF (Storage Access Framework) scenarios:
|
|
||||||
// - audioDir: if non-empty, overrides the directory used to find the audio file
|
|
||||||
// - virtualPathPrefix: if non-empty, used instead of cuePath as the base for
|
|
||||||
// virtual file paths (e.g. a content:// URI). IDs are also based on this.
|
|
||||||
// - fileModTime: if > 0, used as the FileModTime for all results instead of
|
|
||||||
// stat-ing the cuePath on disk (useful when the real file lives behind SAF)
|
|
||||||
func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
|
func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
|
||||||
|
return ScanCueFileForLibraryExtWithCoverCacheKey(
|
||||||
|
cuePath,
|
||||||
|
audioDir,
|
||||||
|
virtualPathPrefix,
|
||||||
|
fileModTime,
|
||||||
|
"",
|
||||||
|
scanTime,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScanCueFileForLibraryExtWithCoverCacheKey(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, coverCacheKey, scanTime string) ([]LibraryScanResult, error) {
|
||||||
sheet, err := ParseCueFile(cuePath)
|
sheet, err := ParseCueFile(cuePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -451,7 +413,15 @@ func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileM
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return scanCueSheetForLibrary(cuePath, sheet, audioPath, virtualPathPrefix, fileModTime, scanTime)
|
return scanCueSheetForLibrary(
|
||||||
|
cuePath,
|
||||||
|
sheet,
|
||||||
|
audioPath,
|
||||||
|
virtualPathPrefix,
|
||||||
|
fileModTime,
|
||||||
|
coverCacheKey,
|
||||||
|
scanTime,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveCueAudioPathForLibrary(cuePath string, sheet *CueSheet, audioDir string) (string, error) {
|
func resolveCueAudioPathForLibrary(cuePath string, sheet *CueSheet, audioDir string) (string, error) {
|
||||||
@@ -469,12 +439,11 @@ func resolveCueAudioPathForLibrary(cuePath string, sheet *CueSheet, audioDir str
|
|||||||
return audioPath, nil
|
return audioPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
|
func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualPathPrefix string, fileModTime int64, coverCacheKey, scanTime string) ([]LibraryScanResult, error) {
|
||||||
if sheet == nil {
|
if sheet == nil {
|
||||||
return nil, fmt.Errorf("cue sheet is nil for %s", cuePath)
|
return nil, fmt.Errorf("cue sheet is nil for %s", cuePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to get quality info from the audio file
|
|
||||||
var bitDepth, sampleRate int
|
var bitDepth, sampleRate int
|
||||||
var totalDurationSec float64
|
var totalDurationSec float64
|
||||||
audioExt := strings.ToLower(filepath.Ext(audioPath))
|
audioExt := strings.ToLower(filepath.Ext(audioPath))
|
||||||
@@ -496,25 +465,27 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract cover from audio file for all tracks
|
|
||||||
var coverPath string
|
var coverPath string
|
||||||
libraryCoverCacheMu.RLock()
|
libraryCoverCacheMu.RLock()
|
||||||
coverCacheDir := libraryCoverCacheDir
|
coverCacheDir := libraryCoverCacheDir
|
||||||
libraryCoverCacheMu.RUnlock()
|
libraryCoverCacheMu.RUnlock()
|
||||||
if coverCacheDir != "" {
|
if coverCacheDir != "" {
|
||||||
cp, err := SaveCoverToCache(audioPath, coverCacheDir)
|
cp, err := SaveCoverToCacheWithHintAndKey(
|
||||||
|
audioPath,
|
||||||
|
"",
|
||||||
|
coverCacheDir,
|
||||||
|
coverCacheKey,
|
||||||
|
)
|
||||||
if err == nil && cp != "" {
|
if err == nil && cp != "" {
|
||||||
coverPath = cp
|
coverPath = cp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine the base path for virtual paths and IDs
|
|
||||||
pathBase := cuePath
|
pathBase := cuePath
|
||||||
if virtualPathPrefix != "" {
|
if virtualPathPrefix != "" {
|
||||||
pathBase = virtualPathPrefix
|
pathBase = virtualPathPrefix
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine fileModTime
|
|
||||||
modTime := fileModTime
|
modTime := fileModTime
|
||||||
if modTime <= 0 {
|
if modTime <= 0 {
|
||||||
if info, err := os.Stat(cuePath); err == nil {
|
if info, err := os.Stat(cuePath); err == nil {
|
||||||
@@ -542,7 +513,11 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
|
|||||||
album = "Unknown Album"
|
album = "Unknown Album"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate duration for this track
|
composer := track.Composer
|
||||||
|
if composer == "" {
|
||||||
|
composer = sheet.Composer
|
||||||
|
}
|
||||||
|
|
||||||
var duration int
|
var duration int
|
||||||
if i+1 < len(sheet.Tracks) {
|
if i+1 < len(sheet.Tracks) {
|
||||||
nextStart := sheet.Tracks[i+1].StartTime
|
nextStart := sheet.Tracks[i+1].StartTime
|
||||||
@@ -556,9 +531,6 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
|
|||||||
|
|
||||||
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
|
|
||||||
// uniqueness in the database (file_path has a UNIQUE constraint).
|
|
||||||
// Format: /path/to/album.cue#track01 or content://...album.cue#track01
|
|
||||||
virtualFilePath := fmt.Sprintf("%s#track%02d", pathBase, track.Number)
|
virtualFilePath := fmt.Sprintf("%s#track%02d", pathBase, track.Number)
|
||||||
|
|
||||||
result := LibraryScanResult{
|
result := LibraryScanResult{
|
||||||
@@ -572,12 +544,15 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
|
|||||||
ScannedAt: scanTime,
|
ScannedAt: scanTime,
|
||||||
ISRC: track.ISRC,
|
ISRC: track.ISRC,
|
||||||
TrackNumber: track.Number,
|
TrackNumber: track.Number,
|
||||||
|
TotalTracks: len(sheet.Tracks),
|
||||||
DiscNumber: 1,
|
DiscNumber: 1,
|
||||||
|
TotalDiscs: 1,
|
||||||
Duration: duration,
|
Duration: duration,
|
||||||
ReleaseDate: sheet.Date,
|
ReleaseDate: sheet.Date,
|
||||||
BitDepth: bitDepth,
|
BitDepth: bitDepth,
|
||||||
SampleRate: sampleRate,
|
SampleRate: sampleRate,
|
||||||
Genre: sheet.Genre,
|
Genre: sheet.Genre,
|
||||||
|
Composer: composer,
|
||||||
Format: "cue+" + strings.TrimPrefix(audioExt, "."),
|
Format: "cue+" + strings.TrimPrefix(audioExt, "."),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+81
-7
@@ -196,15 +196,22 @@ type deezerAlbumSimple struct {
|
|||||||
RecordType string `json:"record_type"`
|
RecordType string `json:"record_type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
// deezerTrackArtistDisplay returns the display artist string for a track,
|
||||||
artistName := track.Artist.Name
|
// preferring the Contributors list (comma-joined) when available, falling
|
||||||
|
// back to the primary Artist.Name.
|
||||||
|
func deezerTrackArtistDisplay(track deezerTrack) string {
|
||||||
if len(track.Contributors) > 0 {
|
if len(track.Contributors) > 0 {
|
||||||
names := make([]string, len(track.Contributors))
|
names := make([]string, len(track.Contributors))
|
||||||
for i, a := range track.Contributors {
|
for i, a := range track.Contributors {
|
||||||
names[i] = a.Name
|
names[i] = a.Name
|
||||||
}
|
}
|
||||||
artistName = strings.Join(names, ", ")
|
return strings.Join(names, ", ")
|
||||||
}
|
}
|
||||||
|
return track.Artist.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
||||||
|
artistName := deezerTrackArtistDisplay(track)
|
||||||
|
|
||||||
albumImage := track.Album.CoverXL
|
albumImage := track.Album.CoverXL
|
||||||
if albumImage == "" {
|
if albumImage == "" {
|
||||||
@@ -623,6 +630,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
}
|
}
|
||||||
|
|
||||||
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
|
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
|
||||||
|
totalDiscs := 0
|
||||||
|
for _, track := range allTracks {
|
||||||
|
if track.DiskNumber > totalDiscs {
|
||||||
|
totalDiscs = track.DiskNumber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
|
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
|
||||||
albumType := album.RecordType
|
albumType := album.RecordType
|
||||||
@@ -641,7 +654,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
|
|
||||||
tracks = append(tracks, AlbumTrackMetadata{
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
||||||
Artists: track.Artist.Name,
|
Artists: deezerTrackArtistDisplay(track),
|
||||||
Name: track.Title,
|
Name: track.Title,
|
||||||
AlbumName: album.Title,
|
AlbumName: album.Title,
|
||||||
AlbumArtist: artistName,
|
AlbumArtist: artistName,
|
||||||
@@ -651,6 +664,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
TrackNumber: trackNum,
|
TrackNumber: trackNum,
|
||||||
TotalTracks: album.NbTracks,
|
TotalTracks: album.NbTracks,
|
||||||
DiscNumber: track.DiskNumber,
|
DiscNumber: track.DiskNumber,
|
||||||
|
TotalDiscs: totalDiscs,
|
||||||
ExternalURL: track.Link,
|
ExternalURL: track.Link,
|
||||||
ISRC: isrc,
|
ISRC: isrc,
|
||||||
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
|
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
|
||||||
@@ -741,6 +755,10 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
|||||||
Artists: artist.Name,
|
Artists: artist.Name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Deezer /artist/{id}/albums endpoint does not return nb_tracks.
|
||||||
|
// Fetch track counts in parallel from individual /album/{id} endpoints.
|
||||||
|
c.fetchAlbumTrackCounts(ctx, albums)
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &ArtistResponsePayload{
|
result := &ArtistResponsePayload{
|
||||||
@@ -760,6 +778,63 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fetchAlbumTrackCounts fetches nb_tracks for each album in parallel using
|
||||||
|
// individual /album/{id} calls, since the /artist/{id}/albums endpoint does
|
||||||
|
// not include this field. Albums whose track count is already known (non-zero)
|
||||||
|
// are skipped.
|
||||||
|
func (c *DeezerClient) fetchAlbumTrackCounts(ctx context.Context, albums []ArtistAlbumMetadata) {
|
||||||
|
// Find albums that need track counts
|
||||||
|
type indexedID struct {
|
||||||
|
idx int
|
||||||
|
albumID string
|
||||||
|
}
|
||||||
|
var toFetch []indexedID
|
||||||
|
for i, a := range albums {
|
||||||
|
if a.TotalTracks == 0 {
|
||||||
|
rawID := strings.TrimPrefix(a.ID, "deezer:")
|
||||||
|
if rawID != "" {
|
||||||
|
toFetch = append(toFetch, indexedID{idx: i, albumID: rawID})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(toFetch) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxParallel = 10
|
||||||
|
sem := make(chan struct{}, maxParallel)
|
||||||
|
var mu sync.Mutex
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for _, item := range toFetch {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(it indexedID) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case sem <- struct{}{}:
|
||||||
|
defer func() { <-sem }()
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
albumURL := fmt.Sprintf(deezerAlbumURL, it.albumID)
|
||||||
|
var resp struct {
|
||||||
|
NbTracks int `json:"nb_tracks"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, albumURL, &resp); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
albums[it.idx].TotalTracks = resp.NbTracks
|
||||||
|
mu.Unlock()
|
||||||
|
}(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
func (c *DeezerClient) GetRelatedArtists(ctx context.Context, artistID string, limit int) ([]SearchArtistResult, error) {
|
func (c *DeezerClient) GetRelatedArtists(ctx context.Context, artistID string, limit int) ([]SearchArtistResult, error) {
|
||||||
normalizedArtistID := strings.TrimSpace(strings.TrimPrefix(artistID, "deezer:"))
|
normalizedArtistID := strings.TrimSpace(strings.TrimPrefix(artistID, "deezer:"))
|
||||||
if normalizedArtistID == "" {
|
if normalizedArtistID == "" {
|
||||||
@@ -892,7 +967,7 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
|
|||||||
|
|
||||||
tracks = append(tracks, AlbumTrackMetadata{
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
||||||
Artists: track.Artist.Name,
|
Artists: deezerTrackArtistDisplay(track),
|
||||||
Name: track.Title,
|
Name: track.Title,
|
||||||
AlbumName: track.Album.Title,
|
AlbumName: track.Album.Title,
|
||||||
AlbumArtist: track.Artist.Name,
|
AlbumArtist: track.Artist.Name,
|
||||||
@@ -1181,7 +1256,7 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
|||||||
|
|
||||||
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
|
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
|
||||||
if attempt > 0 {
|
if attempt > 0 {
|
||||||
delay := deezerRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
|
delay := deezerRetryDelay * time.Duration(1<<(attempt-1))
|
||||||
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
|
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
|
||||||
time.Sleep(delay)
|
time.Sleep(delay)
|
||||||
}
|
}
|
||||||
@@ -1194,7 +1269,6 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
|||||||
lastErr = err
|
lastErr = err
|
||||||
errStr := err.Error()
|
errStr := err.Error()
|
||||||
|
|
||||||
// Check if error is retryable
|
|
||||||
isRetryable := strings.Contains(errStr, "timeout") ||
|
isRetryable := strings.Contains(errStr, "timeout") ||
|
||||||
strings.Contains(errStr, "connection reset") ||
|
strings.Contains(errStr, "connection reset") ||
|
||||||
strings.Contains(errStr, "connection refused") ||
|
strings.Contains(errStr, "connection refused") ||
|
||||||
|
|||||||
@@ -1,606 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
const deezerYoinkifyURL = "https://yoinkify.lol/api/download"
|
|
||||||
const deezerMusicDLURL = "https://www.musicdl.me/api/download"
|
|
||||||
|
|
||||||
type YoinkifyRequest struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
Format string `json:"format"`
|
|
||||||
GenreSource string `json:"genreSource"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DeezerDownloadResult struct {
|
|
||||||
FilePath string
|
|
||||||
BitDepth int
|
|
||||||
SampleRate int
|
|
||||||
Title string
|
|
||||||
Artist string
|
|
||||||
Album string
|
|
||||||
ReleaseDate string
|
|
||||||
TrackNumber int
|
|
||||||
DiscNumber int
|
|
||||||
ISRC string
|
|
||||||
LyricsLRC string
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveSpotifyURLForYoinkify(req DownloadRequest) (string, error) {
|
|
||||||
rawSpotify := strings.TrimSpace(req.SpotifyID)
|
|
||||||
if rawSpotify != "" {
|
|
||||||
if isLikelySpotifyTrackID(rawSpotify) {
|
|
||||||
return fmt.Sprintf("https://open.spotify.com/track/%s", rawSpotify), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if parsed, err := parseSpotifyURI(rawSpotify); err == nil && parsed.Type == "track" && parsed.ID != "" {
|
|
||||||
return fmt.Sprintf("https://open.spotify.com/track/%s", parsed.ID), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deezerID := strings.TrimSpace(req.DeezerID)
|
|
||||||
if deezerID == "" {
|
|
||||||
if prefixed, found := strings.CutPrefix(rawSpotify, "deezer:"); found {
|
|
||||||
deezerID = strings.TrimSpace(prefixed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if deezerID != "" {
|
|
||||||
songlink := NewSongLinkClient()
|
|
||||||
spotifyID, err := songlink.GetSpotifyIDFromDeezer(deezerID)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to map deezer:%s to Spotify ID: %w", deezerID, err)
|
|
||||||
}
|
|
||||||
spotifyID = strings.TrimSpace(spotifyID)
|
|
||||||
if spotifyID == "" {
|
|
||||||
return "", fmt.Errorf("SongLink returned empty Spotify ID for deezer:%s", deezerID)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("https://open.spotify.com/track/%s", spotifyID), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("missing Spotify track ID for Deezer Yoinkify")
|
|
||||||
}
|
|
||||||
|
|
||||||
func isLikelySpotifyTrackID(value string) bool {
|
|
||||||
if len(value) != 22 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, r := range value {
|
|
||||||
switch {
|
|
||||||
case r >= 'A' && r <= 'Z':
|
|
||||||
case r >= 'a' && r <= 'z':
|
|
||||||
case r >= '0' && r <= '9':
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *DeezerClient) DownloadFromYoinkify(spotifyURL, outputPath string, outputFD int, itemID string) error {
|
|
||||||
payload := YoinkifyRequest{
|
|
||||||
URL: spotifyURL,
|
|
||||||
Format: "flac",
|
|
||||||
GenreSource: "spotify",
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonData, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to encode Yoinkify request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
if itemID != "" {
|
|
||||||
StartItemProgress(itemID)
|
|
||||||
defer CompleteItemProgress(itemID)
|
|
||||||
ctx = initDownloadCancel(itemID)
|
|
||||||
defer clearDownloadCancel(itemID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isDownloadCancelled(itemID) {
|
|
||||||
return ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, deezerYoinkifyURL, bytes.NewBuffer(jsonData))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create Yoinkify request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Accept", "*/*")
|
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
|
|
||||||
resp, err := GetDownloadClient().Do(req)
|
|
||||||
if err != nil {
|
|
||||||
if isDownloadCancelled(itemID) {
|
|
||||||
return ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
return fmt.Errorf("failed to call Yoinkify: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
contentType := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Type")))
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
||||||
bodyText := strings.TrimSpace(string(bodyBytes))
|
|
||||||
if bodyText != "" {
|
|
||||||
return fmt.Errorf("Yoinkify returned status %d: %s", resp.StatusCode, bodyText)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("Yoinkify returned status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(contentType, "application/json") {
|
|
||||||
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
||||||
bodyText := strings.TrimSpace(string(bodyBytes))
|
|
||||||
if bodyText == "" {
|
|
||||||
bodyText = "empty JSON payload"
|
|
||||||
}
|
|
||||||
return fmt.Errorf("Yoinkify returned JSON instead of audio: %s", bodyText)
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedSize := resp.ContentLength
|
|
||||||
if expectedSize > 0 && itemID != "" {
|
|
||||||
SetItemBytesTotal(itemID, expectedSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
out, err := openOutputForWrite(outputPath, outputFD)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
|
||||||
var written int64
|
|
||||||
if itemID != "" {
|
|
||||||
pw := NewItemProgressWriter(bufWriter, itemID)
|
|
||||||
written, err = io.Copy(pw, resp.Body)
|
|
||||||
} else {
|
|
||||||
written, err = io.Copy(bufWriter, resp.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
flushErr := bufWriter.Flush()
|
|
||||||
closeErr := out.Close()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
if isDownloadCancelled(itemID) {
|
|
||||||
return ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
return fmt.Errorf("download interrupted: %w", err)
|
|
||||||
}
|
|
||||||
if flushErr != nil {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
return fmt.Errorf("failed to flush output: %w", flushErr)
|
|
||||||
}
|
|
||||||
if closeErr != nil {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
return fmt.Errorf("failed to close output: %w", closeErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if expectedSize > 0 && written != expectedSize {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[Deezer] Downloaded via Yoinkify: %.2f MB\n", float64(written)/(1024*1024))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
|
||||||
deezerID := strings.TrimSpace(req.DeezerID)
|
|
||||||
if deezerID == "" {
|
|
||||||
if prefixed, found := strings.CutPrefix(strings.TrimSpace(req.SpotifyID), "deezer:"); found {
|
|
||||||
deezerID = strings.TrimSpace(prefixed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if deezerID != "" {
|
|
||||||
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 SongLink
|
|
||||||
spotifyID := strings.TrimSpace(req.SpotifyID)
|
|
||||||
if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) {
|
|
||||||
songlink := NewSongLinkClient()
|
|
||||||
availability, err := songlink.CheckTrackAvailability(spotifyID, "")
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try ISRC
|
|
||||||
isrc := strings.TrimSpace(req.ISRC)
|
|
||||||
if isrc != "" {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
|
||||||
defer cancel()
|
|
||||||
track, err := GetDeezerClient().SearchByISRC(ctx, isrc)
|
|
||||||
if err == nil && track != nil {
|
|
||||||
resolvedID := songLinkExtractDeezerTrackID(track)
|
|
||||||
if resolvedID != "" {
|
|
||||||
if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil {
|
|
||||||
GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr)
|
|
||||||
return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("https://www.deezer.com/track/%s", resolvedID), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("could not resolve Deezer track URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
func verifyDeezerTrack(req DownloadRequest, deezerID string) error {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
|
||||||
defer cancel()
|
|
||||||
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
|
|
||||||
if err != nil {
|
|
||||||
return nil // Can't verify — don't block the download.
|
|
||||||
}
|
|
||||||
resolved := resolvedTrackInfo{
|
|
||||||
Title: trackResp.Track.Name,
|
|
||||||
ArtistName: trackResp.Track.Artists,
|
|
||||||
Duration: trackResp.Track.DurationMS / 1000,
|
|
||||||
}
|
|
||||||
if !trackMatchesRequest(req, resolved, "Deezer") {
|
|
||||||
return fmt.Errorf("expected '%s - %s', got '%s - %s'",
|
|
||||||
req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
|
|
||||||
}
|
|
||||||
GoLog("[Deezer] Track %s verified: '%s - %s' ✓\n", deezerID, resolved.ArtistName, resolved.Title)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type deezerMusicDLRequest struct {
|
|
||||||
Platform string `json:"platform"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, error) {
|
|
||||||
payload := deezerMusicDLRequest{
|
|
||||||
Platform: "deezer",
|
|
||||||
URL: deezerTrackURL,
|
|
||||||
}
|
|
||||||
jsonData, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to encode MusicDL request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodPost, deezerMusicDLURL, bytes.NewReader(jsonData))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create MusicDL request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("X-Debug-Key", getQobuzDebugKey())
|
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("MusicDL request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read MusicDL response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return "", fmt.Errorf("MusicDL returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
||||||
}
|
|
||||||
|
|
||||||
var raw map[string]any
|
|
||||||
if err := json.Unmarshal(body, &raw); err != nil {
|
|
||||||
return "", fmt.Errorf("invalid MusicDL JSON: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" {
|
|
||||||
return "", fmt.Errorf("MusicDL error: %s", errMsg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try various response fields for download URL
|
|
||||||
for _, key := range []string{"download_url", "url", "link"} {
|
|
||||||
if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" {
|
|
||||||
return strings.TrimSpace(urlVal), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if data, ok := raw["data"].(map[string]any); ok {
|
|
||||||
for _, key := range []string{"download_url", "url", "link"} {
|
|
||||||
if urlVal, ok := data[key].(string); ok && strings.TrimSpace(urlVal) != "" {
|
|
||||||
return strings.TrimSpace(urlVal), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("no download URL found in MusicDL response")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *DeezerClient) DownloadFromMusicDL(deezerTrackURL, outputPath string, outputFD int, itemID string) error {
|
|
||||||
GoLog("[Deezer] Resolving download URL via MusicDL for: %s\n", deezerTrackURL)
|
|
||||||
|
|
||||||
downloadURL, err := c.GetMusicDLDownloadURL(deezerTrackURL)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
GoLog("[Deezer] MusicDL returned download URL, starting download...\n")
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
if itemID != "" {
|
|
||||||
StartItemProgress(itemID)
|
|
||||||
defer CompleteItemProgress(itemID)
|
|
||||||
ctx = initDownloadCancel(itemID)
|
|
||||||
defer clearDownloadCancel(itemID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isDownloadCancelled(itemID) {
|
|
||||||
return ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create download request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
|
|
||||||
resp, err := GetDownloadClient().Do(req)
|
|
||||||
if err != nil {
|
|
||||||
if isDownloadCancelled(itemID) {
|
|
||||||
return ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
return fmt.Errorf("download request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedSize := resp.ContentLength
|
|
||||||
if expectedSize > 0 && itemID != "" {
|
|
||||||
SetItemBytesTotal(itemID, expectedSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
out, err := openOutputForWrite(outputPath, outputFD)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
|
||||||
var written int64
|
|
||||||
if itemID != "" {
|
|
||||||
pw := NewItemProgressWriter(bufWriter, itemID)
|
|
||||||
written, err = io.Copy(pw, resp.Body)
|
|
||||||
} else {
|
|
||||||
written, err = io.Copy(bufWriter, resp.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
flushErr := bufWriter.Flush()
|
|
||||||
closeErr := out.Close()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
if isDownloadCancelled(itemID) {
|
|
||||||
return ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
return fmt.Errorf("download interrupted: %w", err)
|
|
||||||
}
|
|
||||||
if flushErr != nil {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
return fmt.Errorf("failed to flush output: %w", flushErr)
|
|
||||||
}
|
|
||||||
if closeErr != nil {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
return fmt.Errorf("failed to close output: %w", closeErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if expectedSize > 0 && written != expectedSize {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[Deezer] Downloaded via MusicDL: %.2f MB\n", float64(written)/(1024*1024))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
|
|
||||||
deezerClient := GetDeezerClient()
|
|
||||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
|
||||||
|
|
||||||
if !isSafOutput {
|
|
||||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
|
||||||
return DeezerDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
|
||||||
"title": req.TrackName,
|
|
||||||
"artist": req.ArtistName,
|
|
||||||
"album": req.AlbumName,
|
|
||||||
"track": req.TrackNumber,
|
|
||||||
"year": extractYear(req.ReleaseDate),
|
|
||||||
"date": req.ReleaseDate,
|
|
||||||
"disc": req.DiscNumber,
|
|
||||||
})
|
|
||||||
|
|
||||||
var outputPath string
|
|
||||||
if isSafOutput {
|
|
||||||
outputPath = strings.TrimSpace(req.OutputPath)
|
|
||||||
if outputPath == "" && isFDOutput(req.OutputFD) {
|
|
||||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
filename = sanitizeFilename(filename) + ".flac"
|
|
||||||
outputPath = filepath.Join(req.OutputDir, filename)
|
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
|
||||||
return DeezerDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var parallelResult *ParallelDownloadResult
|
|
||||||
parallelDone := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
defer close(parallelDone)
|
|
||||||
coverURL := req.CoverURL
|
|
||||||
embedLyrics := req.EmbedLyrics
|
|
||||||
if !req.EmbedMetadata {
|
|
||||||
coverURL = ""
|
|
||||||
embedLyrics = false
|
|
||||||
}
|
|
||||||
parallelResult = FetchCoverAndLyricsParallel(
|
|
||||||
coverURL,
|
|
||||||
req.EmbedMaxQualityCover,
|
|
||||||
req.SpotifyID,
|
|
||||||
req.TrackName,
|
|
||||||
req.ArtistName,
|
|
||||||
embedLyrics,
|
|
||||||
int64(req.DurationMS),
|
|
||||||
)
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Try MusicDL first (better quality), fallback to Yoinkify
|
|
||||||
var downloadErr error
|
|
||||||
deezerTrackURL, deezerURLErr := resolveDeezerTrackURL(req)
|
|
||||||
if deezerURLErr == nil {
|
|
||||||
GoLog("[Deezer] Trying MusicDL for: %s\n", deezerTrackURL)
|
|
||||||
downloadErr = deezerClient.DownloadFromMusicDL(deezerTrackURL, outputPath, req.OutputFD, req.ItemID)
|
|
||||||
if downloadErr != nil {
|
|
||||||
if errors.Is(downloadErr, ErrDownloadCancelled) {
|
|
||||||
return DeezerDownloadResult{}, ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
GoLog("[Deezer] MusicDL failed: %v, falling back to Yoinkify\n", downloadErr)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
GoLog("[Deezer] Could not resolve Deezer URL: %v, using Yoinkify directly\n", deezerURLErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if downloadErr != nil || deezerURLErr != nil {
|
|
||||||
spotifyURL, err := resolveSpotifyURLForYoinkify(req)
|
|
||||||
if err != nil {
|
|
||||||
if deezerURLErr != nil {
|
|
||||||
return DeezerDownloadResult{}, fmt.Errorf(
|
|
||||||
"deezer download failed: direct Deezer resolution error: %v; Yoinkify fallback error: %w",
|
|
||||||
deezerURLErr,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return DeezerDownloadResult{}, err
|
|
||||||
}
|
|
||||||
downloadErr = deezerClient.DownloadFromYoinkify(spotifyURL, outputPath, req.OutputFD, req.ItemID)
|
|
||||||
if downloadErr != nil {
|
|
||||||
if errors.Is(downloadErr, ErrDownloadCancelled) {
|
|
||||||
return DeezerDownloadResult{}, ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
return DeezerDownloadResult{}, fmt.Errorf("deezer download failed (MusicDL + Yoinkify): %w", downloadErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
<-parallelDone
|
|
||||||
|
|
||||||
if req.ItemID != "" {
|
|
||||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
|
||||||
SetItemFinalizing(req.ItemID)
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata := Metadata{
|
|
||||||
Title: req.TrackName,
|
|
||||||
Artist: req.ArtistName,
|
|
||||||
Album: req.AlbumName,
|
|
||||||
AlbumArtist: req.AlbumArtist,
|
|
||||||
Date: req.ReleaseDate,
|
|
||||||
TrackNumber: req.TrackNumber,
|
|
||||||
TotalTracks: req.TotalTracks,
|
|
||||||
DiscNumber: req.DiscNumber,
|
|
||||||
ISRC: req.ISRC,
|
|
||||||
Genre: req.Genre,
|
|
||||||
Label: req.Label,
|
|
||||||
Copyright: req.Copyright,
|
|
||||||
}
|
|
||||||
|
|
||||||
var coverData []byte
|
|
||||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
|
||||||
coverData = parallelResult.CoverData
|
|
||||||
}
|
|
||||||
|
|
||||||
if isSafOutput || !req.EmbedMetadata {
|
|
||||||
if !req.EmbedMetadata {
|
|
||||||
GoLog("[Deezer] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n")
|
|
||||||
} else {
|
|
||||||
GoLog("[Deezer] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
|
||||||
GoLog("[Deezer] Warning: failed to embed metadata: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
|
||||||
lyricsMode := req.LyricsMode
|
|
||||||
if lyricsMode == "" {
|
|
||||||
lyricsMode = "embed"
|
|
||||||
}
|
|
||||||
|
|
||||||
if lyricsMode == "external" || lyricsMode == "both" {
|
|
||||||
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
|
||||||
GoLog("[Deezer] Warning: failed to save LRC file: %v\n", lrcErr)
|
|
||||||
} else {
|
|
||||||
GoLog("[Deezer] LRC file saved: %s\n", lrcPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
|
||||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
|
||||||
GoLog("[Deezer] Warning: failed to embed lyrics: %v\n", embedErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isSafOutput {
|
|
||||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
bitDepth, sampleRate := 0, 0
|
|
||||||
if quality, qErr := GetAudioQuality(outputPath); qErr == nil {
|
|
||||||
bitDepth = quality.BitDepth
|
|
||||||
sampleRate = quality.SampleRate
|
|
||||||
}
|
|
||||||
|
|
||||||
lyricsLRC := ""
|
|
||||||
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
|
||||||
lyricsLRC = parallelResult.LyricsLRC
|
|
||||||
}
|
|
||||||
|
|
||||||
return DeezerDownloadResult{
|
|
||||||
FilePath: outputPath,
|
|
||||||
BitDepth: bitDepth,
|
|
||||||
SampleRate: sampleRate,
|
|
||||||
Title: req.TrackName,
|
|
||||||
Artist: req.ArtistName,
|
|
||||||
Album: req.AlbumName,
|
|
||||||
ReleaseDate: req.ReleaseDate,
|
|
||||||
TrackNumber: req.TrackNumber,
|
|
||||||
DiscNumber: req.DiscNumber,
|
|
||||||
ISRC: req.ISRC,
|
|
||||||
LyricsLRC: lyricsLRC,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
@@ -25,7 +25,6 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func GetISRCIndex(outputDir string) *ISRCIndex {
|
func GetISRCIndex(outputDir string) *ISRCIndex {
|
||||||
// Fast path: check cache first
|
|
||||||
isrcIndexCacheMu.RLock()
|
isrcIndexCacheMu.RLock()
|
||||||
idx, exists := isrcIndexCache[outputDir]
|
idx, exists := isrcIndexCache[outputDir]
|
||||||
isrcIndexCacheMu.RUnlock()
|
isrcIndexCacheMu.RUnlock()
|
||||||
@@ -34,13 +33,11 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
|
|||||||
return idx
|
return idx
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
|
|
||||||
// Double-check cache after acquiring lock (another goroutine may have built it)
|
|
||||||
isrcIndexCacheMu.RLock()
|
isrcIndexCacheMu.RLock()
|
||||||
idx, exists = isrcIndexCache[outputDir]
|
idx, exists = isrcIndexCache[outputDir]
|
||||||
isrcIndexCacheMu.RUnlock()
|
isrcIndexCacheMu.RUnlock()
|
||||||
|
|||||||
+992
-482
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,21 @@ package gobackend
|
|||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
|
func TestSetExtensionFallbackProviderIDsJSONEmptyStringResetsDefault(t *testing.T) {
|
||||||
|
original := GetExtensionFallbackProviderIDs()
|
||||||
|
defer SetExtensionFallbackProviderIDs(original)
|
||||||
|
|
||||||
|
SetExtensionFallbackProviderIDs([]string{"custom-ext"})
|
||||||
|
|
||||||
|
if err := SetExtensionFallbackProviderIDsJSON(""); err != nil {
|
||||||
|
t.Fatalf("SetExtensionFallbackProviderIDsJSON returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := GetExtensionFallbackProviderIDs(); got != nil {
|
||||||
|
t.Fatalf("expected nil fallback provider list after reset, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
|
func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
|
||||||
req := DownloadRequest{
|
req := DownloadRequest{
|
||||||
TrackName: "Bonus Track",
|
TrackName: "Bonus Track",
|
||||||
@@ -84,3 +99,245 @@ func TestPreferredReleaseMetadataPrefersRequestValues(t *testing.T) {
|
|||||||
t.Fatalf("disc number = %d", discNumber)
|
t.Fatalf("disc number = %d", discNumber)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
|
||||||
|
req := DownloadRequest{
|
||||||
|
TrackName: "Track",
|
||||||
|
ArtistName: "Artist",
|
||||||
|
AlbumName: "Album",
|
||||||
|
AlbumArtist: "Artist",
|
||||||
|
}
|
||||||
|
|
||||||
|
result := DownloadResult{
|
||||||
|
Title: "Track",
|
||||||
|
Artist: "Artist",
|
||||||
|
Album: "Album",
|
||||||
|
CoverURL: "https://cdn.qobuz.test/cover.jpg",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := buildDownloadSuccessResponse(
|
||||||
|
req,
|
||||||
|
result,
|
||||||
|
"qobuz",
|
||||||
|
"ok",
|
||||||
|
"/tmp/test.flac",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.CoverURL != result.CoverURL {
|
||||||
|
t.Fatalf("cover url = %q, want %q", resp.CoverURL, result.CoverURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDownloadSuccessResponseNormalizesDecryptionDescriptor(t *testing.T) {
|
||||||
|
req := DownloadRequest{
|
||||||
|
TrackName: "Track",
|
||||||
|
ArtistName: "Artist",
|
||||||
|
}
|
||||||
|
|
||||||
|
result := DownloadResult{
|
||||||
|
Title: "Track",
|
||||||
|
Artist: "Artist",
|
||||||
|
DecryptionKey: "00112233",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := buildDownloadSuccessResponse(
|
||||||
|
req,
|
||||||
|
result,
|
||||||
|
"amazon",
|
||||||
|
"ok",
|
||||||
|
"/tmp/test.m4a",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.Decryption == nil {
|
||||||
|
t.Fatal("expected decryption descriptor to be present")
|
||||||
|
}
|
||||||
|
if resp.Decryption.Strategy != genericFFmpegMOVDecryptionStrategy {
|
||||||
|
t.Fatalf("strategy = %q", resp.Decryption.Strategy)
|
||||||
|
}
|
||||||
|
if resp.Decryption.Key != result.DecryptionKey {
|
||||||
|
t.Fatalf("key = %q, want %q", resp.Decryption.Key, result.DecryptionKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMissing(t *testing.T) {
|
||||||
|
req := reEnrichRequest{
|
||||||
|
SpotifyID: "spotify-track-id",
|
||||||
|
AlbumName: "Original Album",
|
||||||
|
ReleaseDate: "2024-01-01",
|
||||||
|
ISRC: "REQ123",
|
||||||
|
}
|
||||||
|
|
||||||
|
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
|
||||||
|
AlbumName: "Resolved Album",
|
||||||
|
ReleaseDate: "",
|
||||||
|
ISRC: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
if req.ReleaseDate != "2024-01-01" {
|
||||||
|
t.Fatalf("release date = %q, want existing value preserved", req.ReleaseDate)
|
||||||
|
}
|
||||||
|
if req.AlbumName != "Resolved Album" {
|
||||||
|
t.Fatalf("album = %q, want updated album", req.AlbumName)
|
||||||
|
}
|
||||||
|
if req.ISRC != "REQ123" {
|
||||||
|
t.Fatalf("isrc = %q, want existing value preserved", req.ISRC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
|
||||||
|
req := reEnrichRequest{
|
||||||
|
TrackName: "Song Title",
|
||||||
|
ArtistName: "Artist Name",
|
||||||
|
AlbumName: "Album Name",
|
||||||
|
ReleaseDate: "",
|
||||||
|
DurationMs: 180000,
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks := []ExtTrackMetadata{
|
||||||
|
{
|
||||||
|
ID: "first",
|
||||||
|
Name: "Song Title",
|
||||||
|
Artists: "Artist Name",
|
||||||
|
AlbumName: "Album Name",
|
||||||
|
DurationMS: 180000,
|
||||||
|
ReleaseDate: "",
|
||||||
|
ProviderID: "spotify",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "second",
|
||||||
|
Name: "Song Title",
|
||||||
|
Artists: "Artist Name",
|
||||||
|
AlbumName: "Album Name",
|
||||||
|
DurationMS: 180000,
|
||||||
|
ReleaseDate: "2024-03-09",
|
||||||
|
ProviderID: "deezer",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
best := selectBestReEnrichTrack(req, tracks)
|
||||||
|
if best == nil {
|
||||||
|
t.Fatal("expected a selected track")
|
||||||
|
}
|
||||||
|
if best.ID != "second" {
|
||||||
|
t.Fatalf("selected track = %q, want candidate with release date", best.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
|
||||||
|
req := reEnrichRequest{
|
||||||
|
TrackName: "Song",
|
||||||
|
ArtistName: "Artist",
|
||||||
|
AlbumName: "Album",
|
||||||
|
AlbumArtist: "",
|
||||||
|
ReleaseDate: "",
|
||||||
|
TrackNumber: 0,
|
||||||
|
DiscNumber: 0,
|
||||||
|
ISRC: "",
|
||||||
|
Genre: "",
|
||||||
|
Label: "",
|
||||||
|
Copyright: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := buildReEnrichFFmpegMetadata(&req, "")
|
||||||
|
|
||||||
|
if metadata["TITLE"] != "Song" {
|
||||||
|
t.Fatalf("title = %q", metadata["TITLE"])
|
||||||
|
}
|
||||||
|
if metadata["ARTIST"] != "Artist" {
|
||||||
|
t.Fatalf("artist = %q", metadata["ARTIST"])
|
||||||
|
}
|
||||||
|
if metadata["ALBUM"] != "Album" {
|
||||||
|
t.Fatalf("album = %q", metadata["ALBUM"])
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range []string{
|
||||||
|
"ALBUMARTIST",
|
||||||
|
"DATE",
|
||||||
|
"TRACKNUMBER",
|
||||||
|
"DISCNUMBER",
|
||||||
|
"ISRC",
|
||||||
|
"GENRE",
|
||||||
|
"ORGANIZATION",
|
||||||
|
"COPYRIGHT",
|
||||||
|
"LYRICS",
|
||||||
|
"UNSYNCEDLYRICS",
|
||||||
|
} {
|
||||||
|
if _, exists := metadata[key]; exists {
|
||||||
|
t.Fatalf("did not expect key %s in metadata: %#v", key, metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildReEnrichSearchQuerySkipsPlaceholderArtist(t *testing.T) {
|
||||||
|
req := reEnrichRequest{
|
||||||
|
TrackName: "Sign of the Times",
|
||||||
|
ArtistName: "Unknown Artist",
|
||||||
|
AlbumName: "Harry Styles",
|
||||||
|
}
|
||||||
|
|
||||||
|
query := buildReEnrichSearchQuery(req)
|
||||||
|
if query != "Sign of the Times" {
|
||||||
|
t.Fatalf("query = %q", query)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = reEnrichRequest{
|
||||||
|
TrackName: "Unknown Title",
|
||||||
|
ArtistName: "Unknown Artist",
|
||||||
|
AlbumName: "Harry Styles",
|
||||||
|
}
|
||||||
|
query = buildReEnrichSearchQuery(req)
|
||||||
|
if query != "Harry Styles" {
|
||||||
|
t.Fatalf("fallback album query = %q", query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyReEnrichTrackMetadataCopiesComposerAndTotals(t *testing.T) {
|
||||||
|
req := reEnrichRequest{}
|
||||||
|
|
||||||
|
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
|
||||||
|
Name: "Resolved Song",
|
||||||
|
Artists: "Resolved Artist",
|
||||||
|
TrackNumber: 7,
|
||||||
|
TotalTracks: 12,
|
||||||
|
DiscNumber: 2,
|
||||||
|
TotalDiscs: 3,
|
||||||
|
Composer: "Composer",
|
||||||
|
})
|
||||||
|
|
||||||
|
if req.TrackNumber != 7 || req.TotalTracks != 12 {
|
||||||
|
t.Fatalf("track metadata = %d/%d", req.TrackNumber, req.TotalTracks)
|
||||||
|
}
|
||||||
|
if req.DiscNumber != 2 || req.TotalDiscs != 3 {
|
||||||
|
t.Fatalf("disc metadata = %d/%d", req.DiscNumber, req.TotalDiscs)
|
||||||
|
}
|
||||||
|
if req.TrackName != "Resolved Song" || req.ArtistName != "Resolved Artist" {
|
||||||
|
t.Fatalf("basic tags = %q / %q", req.TrackName, req.ArtistName)
|
||||||
|
}
|
||||||
|
if req.Composer != "Composer" {
|
||||||
|
t.Fatalf("composer = %q", req.Composer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildReEnrichFFmpegMetadataFormatsTotalsAndComposer(t *testing.T) {
|
||||||
|
req := reEnrichRequest{
|
||||||
|
TrackNumber: 7,
|
||||||
|
TotalTracks: 12,
|
||||||
|
DiscNumber: 2,
|
||||||
|
TotalDiscs: 3,
|
||||||
|
Composer: "Composer",
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := buildReEnrichFFmpegMetadata(&req, "")
|
||||||
|
|
||||||
|
if metadata["TRACKNUMBER"] != "7/12" {
|
||||||
|
t.Fatalf("TRACKNUMBER = %q", metadata["TRACKNUMBER"])
|
||||||
|
}
|
||||||
|
if metadata["DISCNUMBER"] != "2/3" {
|
||||||
|
t.Fatalf("DISCNUMBER = %q", metadata["DISCNUMBER"])
|
||||||
|
}
|
||||||
|
if metadata["COMPOSER"] != "Composer" {
|
||||||
|
t.Fatalf("COMPOSER = %q", metadata["COMPOSER"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+276
-146
@@ -43,41 +43,101 @@ func compareVersions(v1, v2 string) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoadedExtension struct {
|
type loadedExtension struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Manifest *ExtensionManifest `json:"manifest"`
|
Manifest *ExtensionManifest `json:"manifest"`
|
||||||
VM *goja.Runtime `json:"-"`
|
VM *goja.Runtime `json:"-"`
|
||||||
VMMu sync.Mutex `json:"-"`
|
VMMu sync.Mutex `json:"-"`
|
||||||
runtime *ExtensionRuntime
|
runtime *extensionRuntime
|
||||||
Enabled bool `json:"enabled"`
|
initialized bool
|
||||||
Error string `json:"error,omitempty"`
|
Enabled bool `json:"enabled"`
|
||||||
DataDir string `json:"data_dir"`
|
Error string `json:"error,omitempty"`
|
||||||
SourceDir string `json:"source_dir"`
|
DataDir string `json:"data_dir"`
|
||||||
IconPath string `json:"icon_path"`
|
SourceDir string `json:"source_dir"`
|
||||||
|
IconPath string `json:"icon_path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExtensionManager struct {
|
func getExtensionInitSettings(extensionID string) map[string]interface{} {
|
||||||
|
settings := GetExtensionSettingsStore().GetAll(extensionID)
|
||||||
|
if len(settings) == 0 {
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := make(map[string]interface{}, len(settings))
|
||||||
|
for key, value := range settings {
|
||||||
|
if strings.HasPrefix(key, "_") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered[key] = value
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureRuntimeReadyLocked(ext *loadedExtension, applyStoredSettings bool) error {
|
||||||
|
if ext.VM == nil || ext.runtime == nil {
|
||||||
|
if err := initializeVMLocked(ext); err != nil {
|
||||||
|
ext.Error = err.Error()
|
||||||
|
ext.Enabled = false
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if applyStoredSettings && !ext.initialized {
|
||||||
|
settings := getExtensionInitSettings(ext.ID)
|
||||||
|
if len(settings) > 0 {
|
||||||
|
if err := initializeExtensionWithSettingsLocked(ext, settings); err != nil {
|
||||||
|
teardownVMLocked(ext)
|
||||||
|
ext.Error = err.Error()
|
||||||
|
ext.Enabled = false
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ext.initialized = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ext.Error = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ext *loadedExtension) ensureRuntimeReady() error {
|
||||||
|
ext.VMMu.Lock()
|
||||||
|
defer ext.VMMu.Unlock()
|
||||||
|
|
||||||
|
return ensureRuntimeReadyLocked(ext, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ext *loadedExtension) lockReadyVM() (*goja.Runtime, error) {
|
||||||
|
ext.VMMu.Lock()
|
||||||
|
if err := ensureRuntimeReadyLocked(ext, true); err != nil {
|
||||||
|
ext.VMMu.Unlock()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ext.VM, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type extensionManager struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
extensions map[string]*LoadedExtension
|
extensions map[string]*loadedExtension
|
||||||
extensionsDir string
|
extensionsDir string
|
||||||
dataDir string
|
dataDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
globalExtManager *ExtensionManager
|
globalExtManager *extensionManager
|
||||||
globalExtManagerOnce sync.Once
|
globalExtManagerOnce sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetExtensionManager() *ExtensionManager {
|
func getExtensionManager() *extensionManager {
|
||||||
globalExtManagerOnce.Do(func() {
|
globalExtManagerOnce.Do(func() {
|
||||||
globalExtManager = &ExtensionManager{
|
globalExtManager = &extensionManager{
|
||||||
extensions: make(map[string]*LoadedExtension),
|
extensions: make(map[string]*loadedExtension),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return globalExtManager
|
return globalExtManager
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
func (m *extensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
@@ -94,7 +154,7 @@ func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtension, error) {
|
func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtension, error) {
|
||||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||||
}
|
}
|
||||||
@@ -212,7 +272,7 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := &LoadedExtension{
|
ext := &loadedExtension{
|
||||||
ID: manifest.Name,
|
ID: manifest.Name,
|
||||||
Manifest: manifest,
|
Manifest: manifest,
|
||||||
Enabled: false, // New extensions start disabled
|
Enabled: false, // New extensions start disabled
|
||||||
@@ -220,10 +280,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
SourceDir: extDir,
|
SourceDir: extDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.initializeVM(ext); err != nil {
|
if err := validateExtensionLoad(ext); err != nil {
|
||||||
ext.Error = err.Error()
|
ext.Error = err.Error()
|
||||||
ext.Enabled = false
|
ext.Enabled = false
|
||||||
GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err)
|
GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.extensions[manifest.Name] = ext
|
m.extensions[manifest.Name] = ext
|
||||||
@@ -232,7 +292,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
return ext, nil
|
return ext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
func initializeVMLocked(ext *loadedExtension) error {
|
||||||
|
ext.VM = nil
|
||||||
|
ext.runtime = nil
|
||||||
|
ext.initialized = false
|
||||||
vm := goja.New()
|
vm := goja.New()
|
||||||
ext.VM = vm
|
ext.VM = vm
|
||||||
|
|
||||||
@@ -242,7 +305,7 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
|||||||
return fmt.Errorf("failed to read index.js: %w", err)
|
return fmt.Errorf("failed to read index.js: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime := NewExtensionRuntime(ext)
|
runtime := newExtensionRuntime(ext)
|
||||||
ext.runtime = runtime
|
ext.runtime = runtime
|
||||||
runtime.RegisterAPIs(vm)
|
runtime.RegisterAPIs(vm)
|
||||||
runtime.RegisterGoBackendAPIs(vm)
|
runtime.RegisterGoBackendAPIs(vm)
|
||||||
@@ -279,7 +342,137 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
func (m *extensionManager) initializeVM(ext *loadedExtension) error {
|
||||||
|
ext.VMMu.Lock()
|
||||||
|
defer ext.VMMu.Unlock()
|
||||||
|
return initializeVMLocked(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func initializeExtensionWithSettingsLocked(
|
||||||
|
ext *loadedExtension,
|
||||||
|
settings map[string]interface{},
|
||||||
|
) error {
|
||||||
|
if ext.VM == nil {
|
||||||
|
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsJSON, err := json.Marshal(settings)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to save settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
(function() {
|
||||||
|
var settings = %s;
|
||||||
|
if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
|
||||||
|
try {
|
||||||
|
extension.initialize(settings);
|
||||||
|
return { success: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: e.toString() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true, message: 'no initialize function' };
|
||||||
|
})()
|
||||||
|
`, string(settingsJSON))
|
||||||
|
|
||||||
|
result, err := ext.VM.RunString(script)
|
||||||
|
if err != nil {
|
||||||
|
ext.Error = fmt.Sprintf("initialize failed: %v", err)
|
||||||
|
ext.Enabled = false
|
||||||
|
GoLog("[Extension] Initialize error for %s: %v\n", ext.ID, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != nil && !goja.IsUndefined(result) {
|
||||||
|
exported := result.Export()
|
||||||
|
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||||
|
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||||
|
errMsg := "unknown error"
|
||||||
|
if e, ok := resultMap["error"].(string); ok {
|
||||||
|
errMsg = e
|
||||||
|
}
|
||||||
|
ext.Error = errMsg
|
||||||
|
ext.Enabled = false
|
||||||
|
GoLog("[Extension] Initialize failed for %s: %s\n", ext.ID, errMsg)
|
||||||
|
return fmt.Errorf("initialize failed: %s", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ext.initialized = true
|
||||||
|
GoLog("[Extension] Initialized %s\n", ext.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCleanupLocked(ext *loadedExtension) error {
|
||||||
|
if ext.VM != nil {
|
||||||
|
script := `
|
||||||
|
(function() {
|
||||||
|
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
||||||
|
try {
|
||||||
|
extension.cleanup();
|
||||||
|
return { success: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: e.toString() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true, message: 'no cleanup function' };
|
||||||
|
})()
|
||||||
|
`
|
||||||
|
|
||||||
|
result, err := ext.VM.RunString(script)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != nil && !goja.IsUndefined(result) {
|
||||||
|
exported := result.Export()
|
||||||
|
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||||
|
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||||
|
errMsg := "unknown error"
|
||||||
|
if e, ok := resultMap["error"].(string); ok {
|
||||||
|
errMsg = e
|
||||||
|
}
|
||||||
|
return fmt.Errorf("cleanup failed: %s", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != nil && !goja.IsUndefined(result) && !goja.IsNull(result) {
|
||||||
|
GoLog("[Extension] Cleanup called for %s\n", ext.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func teardownVMLocked(ext *loadedExtension) {
|
||||||
|
if err := runCleanupLocked(ext); err != nil {
|
||||||
|
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
|
||||||
|
}
|
||||||
|
if ext.runtime != nil {
|
||||||
|
if err := ext.runtime.flushStorageNow(); err != nil {
|
||||||
|
GoLog("[Extension] Failed to flush storage for %s: %v\n", ext.ID, err)
|
||||||
|
}
|
||||||
|
ext.runtime.closeStorageFlusher()
|
||||||
|
}
|
||||||
|
ext.runtime = nil
|
||||||
|
ext.VM = nil
|
||||||
|
ext.initialized = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateExtensionLoad(ext *loadedExtension) error {
|
||||||
|
ext.VMMu.Lock()
|
||||||
|
defer ext.VMMu.Unlock()
|
||||||
|
|
||||||
|
if err := initializeVMLocked(ext); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
teardownVMLocked(ext)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *extensionManager) UnloadExtension(extensionID string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
@@ -288,21 +481,9 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
|||||||
return fmt.Errorf("Extension not found")
|
return fmt.Errorf("Extension not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
if ext.VM != nil {
|
ext.VMMu.Lock()
|
||||||
cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null")
|
teardownVMLocked(ext)
|
||||||
if err != nil {
|
ext.VMMu.Unlock()
|
||||||
GoLog("[Extension] Error calling cleanup for %s: %v\n", extensionID, err)
|
|
||||||
} else if cleanup != nil && !goja.IsUndefined(cleanup) && !goja.IsNull(cleanup) {
|
|
||||||
GoLog("[Extension] Cleanup called for %s\n", extensionID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ext.runtime != nil {
|
|
||||||
if err := ext.runtime.flushStorageNow(); err != nil {
|
|
||||||
GoLog("[Extension] Failed to flush storage for %s: %v\n", extensionID, err)
|
|
||||||
}
|
|
||||||
ext.runtime.closeStorageFlusher()
|
|
||||||
ext.runtime = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(m.extensions, extensionID)
|
delete(m.extensions, extensionID)
|
||||||
GoLog("[Extension] Unloaded extension: %s\n", extensionID)
|
GoLog("[Extension] Unloaded extension: %s\n", extensionID)
|
||||||
@@ -310,7 +491,7 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) {
|
func (m *extensionManager) GetExtension(extensionID string) (*loadedExtension, error) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
@@ -321,18 +502,18 @@ func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, e
|
|||||||
return ext, nil
|
return ext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
|
func (m *extensionManager) GetAllExtensions() []*loadedExtension {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
result := make([]*LoadedExtension, 0, len(m.extensions))
|
result := make([]*loadedExtension, 0, len(m.extensions))
|
||||||
for _, ext := range m.extensions {
|
for _, ext := range m.extensions {
|
||||||
result = append(result, ext)
|
result = append(result, ext)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
|
func (m *extensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
@@ -341,7 +522,21 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
|
|||||||
return fmt.Errorf("Extension not found")
|
return fmt.Errorf("Extension not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
ext.Enabled = enabled
|
if enabled {
|
||||||
|
ext.Enabled = true
|
||||||
|
if err := ext.ensureRuntimeReady(); err != nil {
|
||||||
|
store := GetExtensionSettingsStore()
|
||||||
|
ext.Enabled = false
|
||||||
|
_ = store.Set(extensionID, "_enabled", false)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ext.Enabled = false
|
||||||
|
ext.Error = ""
|
||||||
|
ext.VMMu.Lock()
|
||||||
|
teardownVMLocked(ext)
|
||||||
|
ext.VMMu.Unlock()
|
||||||
|
}
|
||||||
GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled])
|
GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled])
|
||||||
|
|
||||||
store := GetExtensionSettingsStore()
|
store := GetExtensionSettingsStore()
|
||||||
@@ -352,7 +547,7 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
|
func (m *extensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
|
||||||
var loaded []string
|
var loaded []string
|
||||||
var errors []error
|
var errors []error
|
||||||
|
|
||||||
@@ -390,7 +585,7 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
|
|||||||
return loaded, errors
|
return loaded, errors
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) {
|
func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedExtension, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
@@ -420,7 +615,7 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
|||||||
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := &LoadedExtension{
|
ext := &loadedExtension{
|
||||||
ID: manifest.Name,
|
ID: manifest.Name,
|
||||||
Manifest: manifest,
|
Manifest: manifest,
|
||||||
Enabled: false, // Will be restored from settings store
|
Enabled: false, // Will be restored from settings store
|
||||||
@@ -436,10 +631,10 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.initializeVM(ext); err != nil {
|
if err := validateExtensionLoad(ext); err != nil {
|
||||||
ext.Error = err.Error()
|
ext.Error = err.Error()
|
||||||
ext.Enabled = false
|
ext.Enabled = false
|
||||||
GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err)
|
GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.extensions[manifest.Name] = ext
|
m.extensions[manifest.Name] = ext
|
||||||
@@ -448,7 +643,7 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
|||||||
return ext, nil
|
return ext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) RemoveExtension(extensionID string) error {
|
func (m *extensionManager) RemoveExtension(extensionID string) error {
|
||||||
ext, err := m.GetExtension(extensionID)
|
ext, err := m.GetExtension(extensionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -468,7 +663,7 @@ func (m *ExtensionManager) RemoveExtension(extensionID string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only allows upgrades (new version > current version), not downgrades
|
// Only allows upgrades (new version > current version), not downgrades
|
||||||
func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) {
|
func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension, error) {
|
||||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||||
}
|
}
|
||||||
@@ -582,7 +777,7 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := &LoadedExtension{
|
ext := &loadedExtension{
|
||||||
ID: newManifest.Name,
|
ID: newManifest.Name,
|
||||||
Manifest: newManifest,
|
Manifest: newManifest,
|
||||||
Enabled: wasEnabled, // Preserve enabled state from before upgrade
|
Enabled: wasEnabled, // Preserve enabled state from before upgrade
|
||||||
@@ -590,10 +785,14 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
SourceDir: extDir,
|
SourceDir: extDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.initializeVM(ext); err != nil {
|
if wasEnabled {
|
||||||
|
if err := ext.ensureRuntimeReady(); err != nil {
|
||||||
|
GoLog("[Extension] Failed to initialize upgraded extension %s: %v\n", newManifest.Name, err)
|
||||||
|
}
|
||||||
|
} else if err := validateExtensionLoad(ext); err != nil {
|
||||||
ext.Error = err.Error()
|
ext.Error = err.Error()
|
||||||
ext.Enabled = false
|
ext.Enabled = false
|
||||||
GoLog("[Extension] Failed to initialize VM for %s: %v\n", newManifest.Name, err)
|
GoLog("[Extension] Failed to validate upgraded extension %s: %v\n", newManifest.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -613,7 +812,7 @@ type ExtensionUpgradeInfo struct {
|
|||||||
IsInstalled bool `json:"is_installed"`
|
IsInstalled bool `json:"is_installed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
||||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||||
}
|
}
|
||||||
@@ -672,7 +871,7 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
|||||||
return info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
|
func (m *extensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
|
||||||
info, err := m.checkExtensionUpgradeInternal(filePath)
|
info, err := m.checkExtensionUpgradeInternal(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -686,7 +885,7 @@ func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, e
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||||
extensions := m.GetAllExtensions()
|
extensions := m.GetAllExtensions()
|
||||||
|
|
||||||
type ExtensionInfo struct {
|
type ExtensionInfo struct {
|
||||||
@@ -709,6 +908,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
HasDownloadProvider bool `json:"has_download_provider"`
|
HasDownloadProvider bool `json:"has_download_provider"`
|
||||||
HasLyricsProvider bool `json:"has_lyrics_provider"`
|
HasLyricsProvider bool `json:"has_lyrics_provider"`
|
||||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
|
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
|
||||||
|
SkipLyrics bool `json:"skip_lyrics"`
|
||||||
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
|
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
|
||||||
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
|
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
|
||||||
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
|
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
|
||||||
@@ -766,6 +966,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
HasDownloadProvider: ext.Manifest.IsDownloadProvider(),
|
HasDownloadProvider: ext.Manifest.IsDownloadProvider(),
|
||||||
HasLyricsProvider: ext.Manifest.IsLyricsProvider(),
|
HasLyricsProvider: ext.Manifest.IsLyricsProvider(),
|
||||||
SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment,
|
SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment,
|
||||||
|
SkipLyrics: ext.Manifest.SkipLyrics,
|
||||||
SearchBehavior: ext.Manifest.SearchBehavior,
|
SearchBehavior: ext.Manifest.SearchBehavior,
|
||||||
TrackMatching: ext.Manifest.TrackMatching,
|
TrackMatching: ext.Manifest.TrackMatching,
|
||||||
PostProcessing: ext.Manifest.PostProcessing,
|
PostProcessing: ext.Manifest.PostProcessing,
|
||||||
@@ -781,7 +982,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
|
func (m *extensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
@@ -790,59 +991,16 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
|
|||||||
return fmt.Errorf("Extension not found")
|
return fmt.Errorf("Extension not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
if ext.VM == nil {
|
ext.VMMu.Lock()
|
||||||
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
defer ext.VMMu.Unlock()
|
||||||
}
|
|
||||||
|
|
||||||
settingsJSON, err := json.Marshal(settings)
|
if err := ensureRuntimeReadyLocked(ext, false); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to save settings")
|
|
||||||
}
|
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
|
||||||
(function() {
|
|
||||||
var settings = %s;
|
|
||||||
if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
|
|
||||||
try {
|
|
||||||
extension.initialize(settings);
|
|
||||||
return { success: true };
|
|
||||||
} catch (e) {
|
|
||||||
return { success: false, error: e.toString() };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { success: true, message: 'no initialize function' };
|
|
||||||
})()
|
|
||||||
`, string(settingsJSON))
|
|
||||||
|
|
||||||
result, err := ext.VM.RunString(script)
|
|
||||||
if err != nil {
|
|
||||||
ext.Error = fmt.Sprintf("initialize failed: %v", err)
|
|
||||||
ext.Enabled = false
|
|
||||||
GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return initializeExtensionWithSettingsLocked(ext, settings)
|
||||||
if result != nil && !goja.IsUndefined(result) {
|
|
||||||
exported := result.Export()
|
|
||||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
|
||||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
|
||||||
errMsg := "unknown error"
|
|
||||||
if e, ok := resultMap["error"].(string); ok {
|
|
||||||
errMsg = e
|
|
||||||
}
|
|
||||||
ext.Error = errMsg
|
|
||||||
ext.Enabled = false
|
|
||||||
GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, errMsg)
|
|
||||||
return fmt.Errorf("initialize failed: %s", errMsg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[Extension] Initialized %s\n", extensionID)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
func (m *extensionManager) CleanupExtension(extensionID string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
@@ -854,46 +1012,17 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
|||||||
if ext.VM == nil {
|
if ext.VM == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
ext.VMMu.Lock()
|
||||||
script := `
|
defer ext.VMMu.Unlock()
|
||||||
(function() {
|
if err := runCleanupLocked(ext); err != nil {
|
||||||
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
|
||||||
try {
|
|
||||||
extension.cleanup();
|
|
||||||
return { success: true };
|
|
||||||
} catch (e) {
|
|
||||||
return { success: false, error: e.toString() };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { success: true, message: 'no cleanup function' };
|
|
||||||
})()
|
|
||||||
`
|
|
||||||
|
|
||||||
result, err := ext.VM.RunString(script)
|
|
||||||
if err != nil {
|
|
||||||
GoLog("[Extension] Cleanup error for %s: %v\n", extensionID, err)
|
GoLog("[Extension] Cleanup error for %s: %v\n", extensionID, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if result != nil && !goja.IsUndefined(result) {
|
|
||||||
exported := result.Export()
|
|
||||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
|
||||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
|
||||||
errMsg := "unknown error"
|
|
||||||
if e, ok := resultMap["error"].(string); ok {
|
|
||||||
errMsg = e
|
|
||||||
}
|
|
||||||
GoLog("[Extension] Cleanup failed for %s: %s\n", extensionID, errMsg)
|
|
||||||
return fmt.Errorf("cleanup failed: %s", errMsg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[Extension] Cleaned up %s\n", extensionID)
|
GoLog("[Extension] Cleaned up %s\n", extensionID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) UnloadAllExtensions() {
|
func (m *extensionManager) UnloadAllExtensions() {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
extensionIDs := make([]string, 0, len(m.extensions))
|
extensionIDs := make([]string, 0, len(m.extensions))
|
||||||
for id := range m.extensions {
|
for id := range m.extensions {
|
||||||
@@ -908,7 +1037,7 @@ func (m *ExtensionManager) UnloadAllExtensions() {
|
|||||||
GoLog("[Extension] All extensions unloaded\n")
|
GoLog("[Extension] All extensions unloaded\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
|
func (m *extensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
@@ -917,13 +1046,14 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
|
|||||||
return nil, fmt.Errorf("extension not found: %s", extensionID)
|
return nil, fmt.Errorf("extension not found: %s", extensionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if ext.VM == nil {
|
|
||||||
return nil, fmt.Errorf("extension VM not initialized")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !ext.Enabled {
|
if !ext.Enabled {
|
||||||
return nil, fmt.Errorf("extension is disabled")
|
return nil, fmt.Errorf("extension is disabled")
|
||||||
}
|
}
|
||||||
|
vm, err := ext.lockReadyVM()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer ext.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
@@ -943,7 +1073,7 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
|
|||||||
})()
|
})()
|
||||||
`, actionName, actionName, actionName)
|
`, actionName, actionName, actionName)
|
||||||
|
|
||||||
result, err := RunWithTimeoutAndRecover(ext.VM, script, DefaultJSTimeout)
|
result, err := RunWithTimeoutAndRecover(vm, script, DefaultJSTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Extension] InvokeAction error for %s.%s: %v\n", extensionID, actionName, err)
|
GoLog("[Extension] InvokeAction error for %s.%s: %v\n", extensionID, actionName, err)
|
||||||
return nil, fmt.Errorf("action failed: %v", err)
|
return nil, fmt.Errorf("action failed: %v", err)
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ type ExtensionManifest struct {
|
|||||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
|
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
|
||||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
||||||
|
SkipLyrics bool `json:"skipLyrics,omitempty"`
|
||||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
||||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
||||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
||||||
|
|||||||
+432
-132
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,10 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
|
func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
|
||||||
original := GetMetadataProviderPriority()
|
original := GetMetadataProviderPriority()
|
||||||
@@ -19,6 +23,183 @@ func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSetExtensionFallbackProviderIDsSkipsBuiltInsAndDuplicates(t *testing.T) {
|
||||||
|
original := GetExtensionFallbackProviderIDs()
|
||||||
|
defer SetExtensionFallbackProviderIDs(original)
|
||||||
|
|
||||||
|
SetExtensionFallbackProviderIDs([]string{"ext-a", "tidal", "ext-a", " ext-b "})
|
||||||
|
|
||||||
|
got := GetExtensionFallbackProviderIDs()
|
||||||
|
want := []string{"ext-a", "ext-b"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("unexpected fallback provider length: got %v want %v", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("unexpected fallback provider at %d: got %v want %v", i, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsExtensionFallbackAllowedDefaultsToAllExtensions(t *testing.T) {
|
||||||
|
original := GetExtensionFallbackProviderIDs()
|
||||||
|
defer SetExtensionFallbackProviderIDs(original)
|
||||||
|
|
||||||
|
SetExtensionFallbackProviderIDs(nil)
|
||||||
|
|
||||||
|
if !isExtensionFallbackAllowed("custom-ext") {
|
||||||
|
t.Fatal("expected custom extension to be allowed when no fallback allowlist is configured")
|
||||||
|
}
|
||||||
|
if !isExtensionFallbackAllowed("qobuz") {
|
||||||
|
t.Fatal("expected built-in provider to remain allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsExtensionFallbackAllowedRespectsAllowlist(t *testing.T) {
|
||||||
|
original := GetExtensionFallbackProviderIDs()
|
||||||
|
defer SetExtensionFallbackProviderIDs(original)
|
||||||
|
|
||||||
|
SetExtensionFallbackProviderIDs([]string{"allowed-ext"})
|
||||||
|
|
||||||
|
if !isExtensionFallbackAllowed("allowed-ext") {
|
||||||
|
t.Fatal("expected explicitly allowed extension to be permitted")
|
||||||
|
}
|
||||||
|
if isExtensionFallbackAllowed("blocked-ext") {
|
||||||
|
t.Fatal("expected extension outside allowlist to be blocked")
|
||||||
|
}
|
||||||
|
if isExtensionFallbackAllowed("deezer") {
|
||||||
|
t.Fatal("expected retired Deezer downloader to respect extension fallback allowlist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) {
|
||||||
|
original := GetProviderPriority()
|
||||||
|
defer SetProviderPriority(original)
|
||||||
|
|
||||||
|
SetProviderPriority([]string{"deezer", "qobuz", "custom-ext"})
|
||||||
|
|
||||||
|
got := GetProviderPriority()
|
||||||
|
want := []string{"qobuz", "custom-ext", "tidal"}
|
||||||
|
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 TestNormalizeDownloadDecryptionInfoPromotesLegacyKey(t *testing.T) {
|
||||||
|
normalized := normalizeDownloadDecryptionInfo(nil, " 001122 ")
|
||||||
|
if normalized == nil {
|
||||||
|
t.Fatal("expected legacy decryption key to produce normalized descriptor")
|
||||||
|
}
|
||||||
|
if normalized.Strategy != genericFFmpegMOVDecryptionStrategy {
|
||||||
|
t.Fatalf("strategy = %q", normalized.Strategy)
|
||||||
|
}
|
||||||
|
if normalized.Key != "001122" {
|
||||||
|
t.Fatalf("key = %q", normalized.Key)
|
||||||
|
}
|
||||||
|
if normalized.InputFormat != "mov" {
|
||||||
|
t.Fatalf("input format = %q", normalized.InputFormat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeDownloadDecryptionInfoCanonicalizesMovAliases(t *testing.T) {
|
||||||
|
normalized := normalizeDownloadDecryptionInfo(&DownloadDecryptionInfo{
|
||||||
|
Strategy: "mp4_decryption_key",
|
||||||
|
Key: "abcd",
|
||||||
|
InputFormat: "",
|
||||||
|
}, "")
|
||||||
|
if normalized == nil {
|
||||||
|
t.Fatal("expected descriptor to remain available")
|
||||||
|
}
|
||||||
|
if normalized.Strategy != genericFFmpegMOVDecryptionStrategy {
|
||||||
|
t.Fatalf("strategy = %q", normalized.Strategy)
|
||||||
|
}
|
||||||
|
if normalized.InputFormat != "mov" {
|
||||||
|
t.Fatalf("input format = %q", normalized.InputFormat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildOutputPathAddsExplicitOutputDirToAllowedDirs(t *testing.T) {
|
||||||
|
SetAllowedDownloadDirs(nil)
|
||||||
|
|
||||||
|
outputDir := t.TempDir()
|
||||||
|
outputPath := buildOutputPath(DownloadRequest{
|
||||||
|
TrackName: "Song",
|
||||||
|
ArtistName: "Artist",
|
||||||
|
OutputDir: outputDir,
|
||||||
|
OutputExt: ".flac",
|
||||||
|
FilenameFormat: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
if !isPathInAllowedDirs(outputPath) {
|
||||||
|
t.Fatalf("expected output path %q to be allowed", outputPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildOutputPathForExtensionAddsExplicitOutputPathDirToAllowedDirs(t *testing.T) {
|
||||||
|
SetAllowedDownloadDirs(nil)
|
||||||
|
|
||||||
|
outputDir := t.TempDir()
|
||||||
|
outputPath := filepath.Join(outputDir, "custom.flac")
|
||||||
|
ext := &loadedExtension{DataDir: t.TempDir()}
|
||||||
|
|
||||||
|
resolved := buildOutputPathForExtension(DownloadRequest{
|
||||||
|
OutputPath: outputPath,
|
||||||
|
}, ext)
|
||||||
|
|
||||||
|
if resolved != outputPath {
|
||||||
|
t.Fatalf("resolved output path = %q", resolved)
|
||||||
|
}
|
||||||
|
if !isPathInAllowedDirs(outputPath) {
|
||||||
|
t.Fatalf("expected output path %q to be allowed", outputPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildOutputPathForExtensionUsesTempDirForFDOutput(t *testing.T) {
|
||||||
|
SetAllowedDownloadDirs(nil)
|
||||||
|
|
||||||
|
ext := &loadedExtension{DataDir: t.TempDir()}
|
||||||
|
resolved := buildOutputPathForExtension(DownloadRequest{
|
||||||
|
TrackName: "Song",
|
||||||
|
ArtistName: "Artist",
|
||||||
|
OutputDir: filepath.Join("Artist", "Album"),
|
||||||
|
OutputFD: 123,
|
||||||
|
OutputExt: ".flac",
|
||||||
|
}, ext)
|
||||||
|
|
||||||
|
expectedBase := filepath.Join(ext.DataDir, "downloads")
|
||||||
|
if !isPathWithinBase(expectedBase, resolved) {
|
||||||
|
t.Fatalf("expected SAF extension output under %q, got %q", expectedBase, resolved)
|
||||||
|
}
|
||||||
|
if !isPathInAllowedDirs(resolved) {
|
||||||
|
t.Fatalf("expected resolved output path %q to be allowed", resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanEmbedGenreLabelRequiresExistingAbsoluteLocalFile(t *testing.T) {
|
||||||
|
tempFile := filepath.Join(t.TempDir(), "track.flac")
|
||||||
|
if err := os.WriteFile(tempFile, []byte("fLaC"), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to create temp file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if canEmbedGenreLabel("relative.flac") {
|
||||||
|
t.Fatal("expected relative path to be rejected")
|
||||||
|
}
|
||||||
|
if canEmbedGenreLabel("content://example") {
|
||||||
|
t.Fatal("expected content URI to be rejected")
|
||||||
|
}
|
||||||
|
if canEmbedGenreLabel(filepath.Join(t.TempDir(), "missing.flac")) {
|
||||||
|
t.Fatal("expected missing file to be rejected")
|
||||||
|
}
|
||||||
|
if !canEmbedGenreLabel(tempFile) {
|
||||||
|
t.Fatalf("expected existing absolute file %q to be accepted", tempFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
|
func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
|
||||||
originalPriority := GetMetadataProviderPriority()
|
originalPriority := GetMetadataProviderPriority()
|
||||||
originalSearch := searchBuiltInMetadataTracksFunc
|
originalSearch := searchBuiltInMetadataTracksFunc
|
||||||
@@ -51,7 +232,7 @@ func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false)
|
tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
|
t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
|
||||||
|
|||||||
@@ -80,14 +80,18 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
|
|||||||
state.IsAuthenticated = accessToken != ""
|
state.IsAuthenticated = accessToken != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExtensionRuntime struct {
|
type extensionRuntime struct {
|
||||||
extensionID string
|
extensionID string
|
||||||
manifest *ExtensionManifest
|
manifest *ExtensionManifest
|
||||||
settings map[string]interface{}
|
settings map[string]interface{}
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
cookieJar http.CookieJar
|
downloadClient *http.Client
|
||||||
dataDir string
|
cookieJar http.CookieJar
|
||||||
vm *goja.Runtime
|
dataDir string
|
||||||
|
vm *goja.Runtime
|
||||||
|
|
||||||
|
activeDownloadMu sync.RWMutex
|
||||||
|
activeDownloadItemID string
|
||||||
|
|
||||||
storageMu sync.RWMutex
|
storageMu sync.RWMutex
|
||||||
storageCache map[string]interface{}
|
storageCache map[string]interface{}
|
||||||
@@ -119,10 +123,10 @@ var (
|
|||||||
privateIPCacheMu sync.RWMutex
|
privateIPCacheMu sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
func newExtensionRuntime(ext *loadedExtension) *extensionRuntime {
|
||||||
jar, _ := newSimpleCookieJar()
|
jar, _ := newSimpleCookieJar()
|
||||||
|
|
||||||
runtime := &ExtensionRuntime{
|
runtime := &extensionRuntime{
|
||||||
extensionID: ext.ID,
|
extensionID: ext.ID,
|
||||||
manifest: ext.Manifest,
|
manifest: ext.Manifest,
|
||||||
settings: make(map[string]interface{}),
|
settings: make(map[string]interface{}),
|
||||||
@@ -132,13 +136,38 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
|||||||
storageFlushDelay: defaultStorageFlushDelay,
|
storageFlushDelay: defaultStorageFlushDelay,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runtime.httpClient = newExtensionHTTPClient(ext, jar, 30*time.Second)
|
||||||
|
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout)
|
||||||
|
|
||||||
|
return runtime
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) setActiveDownloadItemID(itemID string) {
|
||||||
|
r.activeDownloadMu.Lock()
|
||||||
|
defer r.activeDownloadMu.Unlock()
|
||||||
|
r.activeDownloadItemID = strings.TrimSpace(itemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) clearActiveDownloadItemID() {
|
||||||
|
r.activeDownloadMu.Lock()
|
||||||
|
defer r.activeDownloadMu.Unlock()
|
||||||
|
r.activeDownloadItemID = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) getActiveDownloadItemID() string {
|
||||||
|
r.activeDownloadMu.RLock()
|
||||||
|
defer r.activeDownloadMu.RUnlock()
|
||||||
|
return r.activeDownloadItemID
|
||||||
|
}
|
||||||
|
|
||||||
|
func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
|
||||||
// Extension sandbox enforces HTTPS-only domains. Do not apply global
|
// Extension sandbox enforces HTTPS-only domains. Do not apply global
|
||||||
// allow_http scheme downgrade here, because some extension APIs (e.g.
|
// allow_http scheme downgrade here, because some extension APIs (e.g.
|
||||||
// spotify-web) will redirect http -> https and can end up in 301 loops.
|
// spotify-web) will redirect http -> https and can end up in 301 loops.
|
||||||
// We still reuse sharedTransport so insecure TLS compatibility mode remains effective.
|
// We still reuse sharedTransport so insecure TLS compatibility mode remains effective.
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Transport: sharedTransport,
|
Transport: sharedTransport,
|
||||||
Timeout: 30 * time.Second,
|
Timeout: timeout,
|
||||||
Jar: jar,
|
Jar: jar,
|
||||||
}
|
}
|
||||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||||
@@ -165,9 +194,7 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
runtime.httpClient = client
|
return client
|
||||||
|
|
||||||
return runtime
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type RedirectBlockedError struct {
|
type RedirectBlockedError struct {
|
||||||
@@ -302,11 +329,11 @@ func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
|
|||||||
return j.cookies[u.Host]
|
return j.cookies[u.Host]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
|
func (r *extensionRuntime) SetSettings(settings map[string]interface{}) {
|
||||||
r.settings = settings
|
r.settings = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||||
r.vm = vm
|
r.vm = vm
|
||||||
|
|
||||||
httpObj := vm.NewObject()
|
httpObj := vm.NewObject()
|
||||||
@@ -350,7 +377,9 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
fileObj.Set("exists", r.fileExists)
|
fileObj.Set("exists", r.fileExists)
|
||||||
fileObj.Set("delete", r.fileDelete)
|
fileObj.Set("delete", r.fileDelete)
|
||||||
fileObj.Set("read", r.fileRead)
|
fileObj.Set("read", r.fileRead)
|
||||||
|
fileObj.Set("readBytes", r.fileReadBytes)
|
||||||
fileObj.Set("write", r.fileWrite)
|
fileObj.Set("write", r.fileWrite)
|
||||||
|
fileObj.Set("writeBytes", r.fileWriteBytes)
|
||||||
fileObj.Set("copy", r.fileCopy)
|
fileObj.Set("copy", r.fileCopy)
|
||||||
fileObj.Set("move", r.fileMove)
|
fileObj.Set("move", r.fileMove)
|
||||||
fileObj.Set("getSize", r.fileGetSize)
|
fileObj.Set("getSize", r.fileGetSize)
|
||||||
@@ -380,6 +409,8 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
utilsObj.Set("stringifyJSON", r.stringifyJSON)
|
utilsObj.Set("stringifyJSON", r.stringifyJSON)
|
||||||
utilsObj.Set("encrypt", r.cryptoEncrypt)
|
utilsObj.Set("encrypt", r.cryptoEncrypt)
|
||||||
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
||||||
|
utilsObj.Set("encryptBlockCipher", r.encryptBlockCipher)
|
||||||
|
utilsObj.Set("decryptBlockCipher", r.decryptBlockCipher)
|
||||||
utilsObj.Set("generateKey", r.cryptoGenerateKey)
|
utilsObj.Set("generateKey", r.cryptoGenerateKey)
|
||||||
utilsObj.Set("randomUserAgent", r.randomUserAgent)
|
utilsObj.Set("randomUserAgent", r.randomUserAgent)
|
||||||
vm.Set("utils", utilsObj)
|
vm.Set("utils", utilsObj)
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func summarizeURLForLog(urlStr string) string {
|
|||||||
return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, parsed.Path)
|
return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, parsed.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -99,7 +99,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
||||||
extensionAuthStateMu.RLock()
|
extensionAuthStateMu.RLock()
|
||||||
defer extensionAuthStateMu.RUnlock()
|
defer extensionAuthStateMu.RUnlock()
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(state.AuthCode)
|
return r.vm.ToValue(state.AuthCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
@@ -149,7 +149,7 @@ func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(true)
|
return r.vm.ToValue(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
||||||
extensionAuthStateMu.Lock()
|
extensionAuthStateMu.Lock()
|
||||||
delete(extensionAuthState, r.extensionID)
|
delete(extensionAuthState, r.extensionID)
|
||||||
extensionAuthStateMu.Unlock()
|
extensionAuthStateMu.Unlock()
|
||||||
@@ -162,7 +162,7 @@ func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(true)
|
return r.vm.ToValue(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
|
||||||
extensionAuthStateMu.RLock()
|
extensionAuthStateMu.RLock()
|
||||||
defer extensionAuthStateMu.RUnlock()
|
defer extensionAuthStateMu.RUnlock()
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
|
|||||||
return r.vm.ToValue(state.IsAuthenticated)
|
return r.vm.ToValue(state.IsAuthenticated)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
||||||
extensionAuthStateMu.RLock()
|
extensionAuthStateMu.RLock()
|
||||||
defer extensionAuthStateMu.RUnlock()
|
defer extensionAuthStateMu.RUnlock()
|
||||||
|
|
||||||
@@ -201,7 +201,6 @@ func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(result)
|
return r.vm.ToValue(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Length should be between 43-128 characters (RFC 7636)
|
|
||||||
func generatePKCEVerifier(length int) (string, error) {
|
func generatePKCEVerifier(length int) (string, error) {
|
||||||
if length < 43 {
|
if length < 43 {
|
||||||
length = 43
|
length = 43
|
||||||
@@ -226,11 +225,10 @@ func generatePKCEVerifier(length int) (string, error) {
|
|||||||
|
|
||||||
func generatePKCEChallenge(verifier string) string {
|
func generatePKCEChallenge(verifier string) string {
|
||||||
hash := sha256.Sum256([]byte(verifier))
|
hash := sha256.Sum256([]byte(verifier))
|
||||||
// Base64url encode without padding (RFC 7636)
|
|
||||||
return base64.RawURLEncoding.EncodeToString(hash[:])
|
return base64.RawURLEncoding.EncodeToString(hash[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||||
length := 64
|
length := 64
|
||||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||||
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
|
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
|
||||||
@@ -267,7 +265,7 @@ func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
||||||
extensionAuthStateMu.RLock()
|
extensionAuthStateMu.RLock()
|
||||||
defer extensionAuthStateMu.RUnlock()
|
defer extensionAuthStateMu.RUnlock()
|
||||||
|
|
||||||
@@ -283,8 +281,7 @@ func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// config: { authUrl, clientId, redirectUri, scope, extraParams }
|
func (r *extensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
|
||||||
func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
|
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -388,8 +385,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// config: { tokenUrl, clientId, redirectUri, code, extraParams }
|
func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
|
||||||
func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
|
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
"golang.org/x/crypto/blowfish"
|
||||||
|
)
|
||||||
|
|
||||||
|
type runtimeBlockCipherOptions struct {
|
||||||
|
Algorithm string
|
||||||
|
Mode string
|
||||||
|
Key []byte
|
||||||
|
IV []byte
|
||||||
|
InputEncoding string
|
||||||
|
OutputEncoding string
|
||||||
|
Padding string
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRuntimeOptionsArgument(call goja.FunctionCall, index int) map[string]interface{} {
|
||||||
|
if len(call.Arguments) <= index {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
value := call.Arguments[index]
|
||||||
|
if goja.IsUndefined(value) || goja.IsNull(value) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
exported := value.Export()
|
||||||
|
if options, ok := exported.(map[string]interface{}); ok {
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeOptionString(options map[string]interface{}, key, defaultValue string) string {
|
||||||
|
if options == nil {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
raw, ok := options[key]
|
||||||
|
if !ok || raw == nil {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
switch value := raw.(type) {
|
||||||
|
case string:
|
||||||
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
case []byte:
|
||||||
|
if len(value) > 0 {
|
||||||
|
return string(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeOptionBool(options map[string]interface{}, key string, defaultValue bool) bool {
|
||||||
|
if options == nil {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
raw, ok := options[key]
|
||||||
|
if !ok || raw == nil {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
switch value := raw.(type) {
|
||||||
|
case bool:
|
||||||
|
return value
|
||||||
|
case int:
|
||||||
|
return value != 0
|
||||||
|
case int64:
|
||||||
|
return value != 0
|
||||||
|
case float64:
|
||||||
|
return value != 0
|
||||||
|
case string:
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "1", "true", "yes", "on":
|
||||||
|
return true
|
||||||
|
case "0", "false", "no", "off":
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeOptionInt64(options map[string]interface{}, key string, defaultValue int64) int64 {
|
||||||
|
if options == nil {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
raw, ok := options[key]
|
||||||
|
if !ok || raw == nil {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
switch value := raw.(type) {
|
||||||
|
case int:
|
||||||
|
return int64(value)
|
||||||
|
case int32:
|
||||||
|
return int64(value)
|
||||||
|
case int64:
|
||||||
|
return value
|
||||||
|
case float32:
|
||||||
|
return int64(value)
|
||||||
|
case float64:
|
||||||
|
return int64(value)
|
||||||
|
case string:
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
var parsed int64
|
||||||
|
if _, err := fmt.Sscanf(value, "%d", &parsed); err == nil {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeOptionHasKey(options map[string]interface{}, key string) bool {
|
||||||
|
if options == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, exists := options[key]
|
||||||
|
return exists
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeRuntimeBytesString(input, encoding string) ([]byte, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
||||||
|
case "", "utf8", "utf-8", "text":
|
||||||
|
return []byte(input), nil
|
||||||
|
case "base64":
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(input))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid base64 data: %w", err)
|
||||||
|
}
|
||||||
|
return decoded, nil
|
||||||
|
case "hex":
|
||||||
|
decoded, err := hex.DecodeString(strings.TrimSpace(input))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid hex data: %w", err)
|
||||||
|
}
|
||||||
|
return decoded, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported byte encoding: %s", encoding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeRuntimeBytesValue(raw interface{}, encoding string) ([]byte, error) {
|
||||||
|
switch value := raw.(type) {
|
||||||
|
case string:
|
||||||
|
return decodeRuntimeBytesString(value, encoding)
|
||||||
|
case []byte:
|
||||||
|
cloned := make([]byte, len(value))
|
||||||
|
copy(cloned, value)
|
||||||
|
return cloned, nil
|
||||||
|
case []interface{}:
|
||||||
|
decoded := make([]byte, len(value))
|
||||||
|
for i, item := range value {
|
||||||
|
switch num := item.(type) {
|
||||||
|
case int:
|
||||||
|
decoded[i] = byte(num)
|
||||||
|
case int64:
|
||||||
|
decoded[i] = byte(num)
|
||||||
|
case float64:
|
||||||
|
decoded[i] = byte(int(num))
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported byte array item at index %d", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return decoded, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported byte payload type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeRuntimeBytes(data []byte, encoding string) (string, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
||||||
|
case "", "base64":
|
||||||
|
return base64.StdEncoding.EncodeToString(data), nil
|
||||||
|
case "hex":
|
||||||
|
return hex.EncodeToString(data), nil
|
||||||
|
case "utf8", "utf-8", "text":
|
||||||
|
return string(data), nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported byte encoding: %s", encoding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRuntimeBlockCipherOptions(options map[string]interface{}) (*runtimeBlockCipherOptions, error) {
|
||||||
|
parsed := &runtimeBlockCipherOptions{
|
||||||
|
Algorithm: strings.ToLower(runtimeOptionString(options, "algorithm", "")),
|
||||||
|
Mode: strings.ToLower(runtimeOptionString(options, "mode", "cbc")),
|
||||||
|
InputEncoding: strings.ToLower(runtimeOptionString(options, "inputEncoding", "base64")),
|
||||||
|
OutputEncoding: strings.ToLower(runtimeOptionString(options, "outputEncoding", "base64")),
|
||||||
|
Padding: strings.ToLower(runtimeOptionString(options, "padding", "none")),
|
||||||
|
}
|
||||||
|
if parsed.Algorithm == "" {
|
||||||
|
return nil, fmt.Errorf("algorithm is required")
|
||||||
|
}
|
||||||
|
if parsed.Mode == "" {
|
||||||
|
return nil, fmt.Errorf("mode is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := decodeRuntimeBytesString(runtimeOptionString(options, "key", ""), runtimeOptionString(options, "keyEncoding", "utf8"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid key: %w", err)
|
||||||
|
}
|
||||||
|
if len(key) == 0 {
|
||||||
|
return nil, fmt.Errorf("key is required")
|
||||||
|
}
|
||||||
|
parsed.Key = key
|
||||||
|
|
||||||
|
iv, err := decodeRuntimeBytesString(runtimeOptionString(options, "iv", ""), runtimeOptionString(options, "ivEncoding", "utf8"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid iv: %w", err)
|
||||||
|
}
|
||||||
|
parsed.IV = iv
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRuntimeBlockCipher(options *runtimeBlockCipherOptions) (cipher.Block, error) {
|
||||||
|
switch options.Algorithm {
|
||||||
|
case "blowfish":
|
||||||
|
return blowfish.NewCipher(options.Key)
|
||||||
|
case "aes":
|
||||||
|
return aes.NewCipher(options.Key)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported block cipher algorithm: %s", options.Algorithm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyPKCS7Padding(data []byte, blockSize int) []byte {
|
||||||
|
padding := blockSize - (len(data) % blockSize)
|
||||||
|
if padding == 0 {
|
||||||
|
padding = blockSize
|
||||||
|
}
|
||||||
|
out := make([]byte, len(data)+padding)
|
||||||
|
copy(out, data)
|
||||||
|
for i := len(data); i < len(out); i++ {
|
||||||
|
out[i] = byte(padding)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func removePKCS7Padding(data []byte, blockSize int) ([]byte, error) {
|
||||||
|
if len(data) == 0 || len(data)%blockSize != 0 {
|
||||||
|
return nil, fmt.Errorf("invalid padded payload length")
|
||||||
|
}
|
||||||
|
padding := int(data[len(data)-1])
|
||||||
|
if padding <= 0 || padding > blockSize || padding > len(data) {
|
||||||
|
return nil, fmt.Errorf("invalid PKCS7 padding")
|
||||||
|
}
|
||||||
|
for i := len(data) - padding; i < len(data); i++ {
|
||||||
|
if int(data[i]) != padding {
|
||||||
|
return nil, fmt.Errorf("invalid PKCS7 padding")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data[:len(data)-padding], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt bool) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "data and options are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
options := parseRuntimeOptionsArgument(call, 1)
|
||||||
|
parsedOptions, err := parseRuntimeBlockCipherOptions(options)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if parsedOptions.Mode != "cbc" {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("unsupported block cipher mode: %s", parsedOptions.Mode),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
inputData, err := decodeRuntimeBytesValue(call.Arguments[0].Export(), parsedOptions.InputEncoding)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := newRuntimeBlockCipher(parsedOptions)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parsedOptions.IV) != block.BlockSize() {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("iv must be %d bytes for %s", block.BlockSize(), parsedOptions.Algorithm),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
data := inputData
|
||||||
|
if !decrypt && parsedOptions.Padding == "pkcs7" {
|
||||||
|
data = applyPKCS7Padding(data, block.BlockSize())
|
||||||
|
}
|
||||||
|
if len(data)%block.BlockSize() != 0 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("input length must be a multiple of %d bytes", block.BlockSize()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
output := make([]byte, len(data))
|
||||||
|
if decrypt {
|
||||||
|
cipher.NewCBCDecrypter(block, parsedOptions.IV).CryptBlocks(output, data)
|
||||||
|
if parsedOptions.Padding == "pkcs7" {
|
||||||
|
output, err = removePKCS7Padding(output, block.BlockSize())
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cipher.NewCBCEncrypter(block, parsedOptions.IV).CryptBlocks(output, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := encodeRuntimeBytes(output, parsedOptions.OutputEncoding)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"data": encoded,
|
||||||
|
"block_size": block.BlockSize(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) encryptBlockCipher(call goja.FunctionCall) goja.Value {
|
||||||
|
return r.transformBlockCipher(call, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) decryptBlockCipher(call goja.FunctionCall) goja.Value {
|
||||||
|
return r.transformBlockCipher(call, true)
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newBinaryTestRuntime(t *testing.T, withFilePermission bool) *goja.Runtime {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ext := &loadedExtension{
|
||||||
|
ID: "binary-test-ext",
|
||||||
|
Manifest: &ExtensionManifest{
|
||||||
|
Name: "binary-test-ext",
|
||||||
|
Permissions: ExtensionPermissions{
|
||||||
|
File: withFilePermission,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := newExtensionRuntime(ext)
|
||||||
|
vm := goja.New()
|
||||||
|
runtime.RegisterAPIs(vm)
|
||||||
|
return vm
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSONResult[T any](t *testing.T, value goja.Value) T {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var decoded T
|
||||||
|
if err := json.Unmarshal([]byte(value.String()), &decoded); err != nil {
|
||||||
|
t.Fatalf("failed to decode JSON result: %v", err)
|
||||||
|
}
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_FileByteAPIs(t *testing.T) {
|
||||||
|
vm := newBinaryTestRuntime(t, true)
|
||||||
|
|
||||||
|
result, err := vm.RunString(`
|
||||||
|
(function() {
|
||||||
|
var first = file.writeBytes("bytes.bin", "AAEC", {encoding: "base64", truncate: true});
|
||||||
|
if (!first.success) throw new Error(first.error);
|
||||||
|
|
||||||
|
var second = file.writeBytes("bytes.bin", "0304ff", {encoding: "hex", append: true});
|
||||||
|
if (!second.success) throw new Error(second.error);
|
||||||
|
|
||||||
|
var all = file.readBytes("bytes.bin", {encoding: "hex"});
|
||||||
|
if (!all.success) throw new Error(all.error);
|
||||||
|
|
||||||
|
var slice = file.readBytes("bytes.bin", {offset: 2, length: 2, encoding: "hex"});
|
||||||
|
if (!slice.success) throw new Error(slice.error);
|
||||||
|
|
||||||
|
var tail = file.readBytes("bytes.bin", {offset: 6, length: 4, encoding: "hex"});
|
||||||
|
if (!tail.success) throw new Error(tail.error);
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
all: all.data,
|
||||||
|
slice: slice.data,
|
||||||
|
size: all.size,
|
||||||
|
sliceBytes: slice.bytes_read,
|
||||||
|
sliceEof: slice.eof,
|
||||||
|
tailBytes: tail.bytes_read,
|
||||||
|
tailEof: tail.eof
|
||||||
|
});
|
||||||
|
})()
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("file byte APIs failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded := decodeJSONResult[struct {
|
||||||
|
All string `json:"all"`
|
||||||
|
Slice string `json:"slice"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
SliceBytes int `json:"sliceBytes"`
|
||||||
|
SliceEof bool `json:"sliceEof"`
|
||||||
|
TailBytes int `json:"tailBytes"`
|
||||||
|
TailEof bool `json:"tailEof"`
|
||||||
|
}](t, result)
|
||||||
|
|
||||||
|
if decoded.All != "0001020304ff" {
|
||||||
|
t.Fatalf("all = %q", decoded.All)
|
||||||
|
}
|
||||||
|
if decoded.Slice != "0203" {
|
||||||
|
t.Fatalf("slice = %q", decoded.Slice)
|
||||||
|
}
|
||||||
|
if decoded.Size != 6 {
|
||||||
|
t.Fatalf("size = %d", decoded.Size)
|
||||||
|
}
|
||||||
|
if decoded.SliceBytes != 2 {
|
||||||
|
t.Fatalf("slice bytes = %d", decoded.SliceBytes)
|
||||||
|
}
|
||||||
|
if decoded.SliceEof {
|
||||||
|
t.Fatal("slice should not be EOF")
|
||||||
|
}
|
||||||
|
if decoded.TailBytes != 0 || !decoded.TailEof {
|
||||||
|
t.Fatalf("tail read mismatch: bytes=%d eof=%v", decoded.TailBytes, decoded.TailEof)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_BlockCipherCBCSupportsBlowfish(t *testing.T) {
|
||||||
|
vm := newBinaryTestRuntime(t, false)
|
||||||
|
|
||||||
|
result, err := vm.RunString(`
|
||||||
|
(function() {
|
||||||
|
var options = {
|
||||||
|
algorithm: "blowfish",
|
||||||
|
mode: "cbc",
|
||||||
|
key: "0123456789ABCDEFF0E1D2C3B4A59687",
|
||||||
|
keyEncoding: "hex",
|
||||||
|
iv: "0001020304050607",
|
||||||
|
ivEncoding: "hex",
|
||||||
|
inputEncoding: "hex",
|
||||||
|
outputEncoding: "hex",
|
||||||
|
padding: "none"
|
||||||
|
};
|
||||||
|
var enc = utils.encryptBlockCipher("00112233445566778899aabbccddeeff", options);
|
||||||
|
if (!enc.success) throw new Error(enc.error);
|
||||||
|
var dec = utils.decryptBlockCipher(enc.data, options);
|
||||||
|
if (!dec.success) throw new Error(dec.error);
|
||||||
|
return JSON.stringify({enc: enc.data, dec: dec.data});
|
||||||
|
})()
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("blowfish block cipher failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded := decodeJSONResult[struct {
|
||||||
|
Enc string `json:"enc"`
|
||||||
|
Dec string `json:"dec"`
|
||||||
|
}](t, result)
|
||||||
|
|
||||||
|
if decoded.Dec != "00112233445566778899aabbccddeeff" {
|
||||||
|
t.Fatalf("dec = %q", decoded.Dec)
|
||||||
|
}
|
||||||
|
if decoded.Enc == decoded.Dec {
|
||||||
|
t.Fatal("expected ciphertext to differ from plaintext")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_BlockCipherCBCSupportsAES(t *testing.T) {
|
||||||
|
vm := newBinaryTestRuntime(t, false)
|
||||||
|
|
||||||
|
result, err := vm.RunString(`
|
||||||
|
(function() {
|
||||||
|
var options = {
|
||||||
|
algorithm: "aes",
|
||||||
|
mode: "cbc",
|
||||||
|
key: "000102030405060708090a0b0c0d0e0f",
|
||||||
|
keyEncoding: "hex",
|
||||||
|
iv: "0f0e0d0c0b0a09080706050403020100",
|
||||||
|
ivEncoding: "hex",
|
||||||
|
inputEncoding: "utf8",
|
||||||
|
outputEncoding: "base64",
|
||||||
|
padding: "pkcs7"
|
||||||
|
};
|
||||||
|
var enc = utils.encryptBlockCipher("hello generic cbc", options);
|
||||||
|
if (!enc.success) throw new Error(enc.error);
|
||||||
|
var dec = utils.decryptBlockCipher(enc.data, {
|
||||||
|
algorithm: "aes",
|
||||||
|
mode: "cbc",
|
||||||
|
key: options.key,
|
||||||
|
keyEncoding: options.keyEncoding,
|
||||||
|
iv: options.iv,
|
||||||
|
ivEncoding: options.ivEncoding,
|
||||||
|
inputEncoding: "base64",
|
||||||
|
outputEncoding: "utf8",
|
||||||
|
padding: "pkcs7"
|
||||||
|
});
|
||||||
|
if (!dec.success) throw new Error(dec.error);
|
||||||
|
return dec.data;
|
||||||
|
})()
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("aes block cipher failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.String() != "hello generic cbc" {
|
||||||
|
t.Fatalf("unexpected decrypted value: %q", result.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,7 +50,7 @@ func ClearFFmpegCommand(commandID string) {
|
|||||||
delete(ffmpegCommands, commandID)
|
delete(ffmpegCommands, commandID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -107,7 +107,7 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -134,7 +134,7 @@ func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ func isPathWithinBase(baseDir, targetPath string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
func (r *extensionRuntime) validatePath(path string) (string, error) {
|
||||||
if !r.manifest.Permissions.File {
|
if !r.manifest.Permissions.File {
|
||||||
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
|
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
|
||||||
}
|
}
|
||||||
@@ -106,7 +106,7 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
|||||||
return absPath, nil
|
return absPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -174,7 +174,12 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := r.httpClient.Do(req)
|
client := r.downloadClient
|
||||||
|
if client == nil {
|
||||||
|
client = r.httpClient
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -200,13 +205,22 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
contentLength := resp.ContentLength
|
contentLength := resp.ContentLength
|
||||||
|
activeItemID := r.getActiveDownloadItemID()
|
||||||
|
if activeItemID != "" && contentLength > 0 {
|
||||||
|
SetItemBytesTotal(activeItemID, contentLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
var progressWriter interface{ Write([]byte) (int, error) } = out
|
||||||
|
if activeItemID != "" {
|
||||||
|
progressWriter = NewItemProgressWriter(out, activeItemID)
|
||||||
|
}
|
||||||
|
|
||||||
var written int64
|
var written int64
|
||||||
buf := make([]byte, 32*1024)
|
buf := make([]byte, 32*1024)
|
||||||
for {
|
for {
|
||||||
nr, er := resp.Body.Read(buf)
|
nr, er := resp.Body.Read(buf)
|
||||||
if nr > 0 {
|
if nr > 0 {
|
||||||
nw, ew := out.Write(buf[0:nr])
|
nw, ew := progressWriter.Write(buf[0:nr])
|
||||||
if nw < 0 || nr < nw {
|
if nw < 0 || nr < nw {
|
||||||
nw = 0
|
nw = 0
|
||||||
if ew == nil {
|
if ew == nil {
|
||||||
@@ -215,6 +229,12 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
written += int64(nw)
|
written += int64(nw)
|
||||||
if ew != nil {
|
if ew != nil {
|
||||||
|
if ew == ErrDownloadCancelled {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "download cancelled",
|
||||||
|
})
|
||||||
|
}
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": fmt.Sprintf("failed to write file: %v", ew),
|
"error": fmt.Sprintf("failed to write file: %v", ew),
|
||||||
@@ -251,7 +271,7 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
@@ -266,7 +286,7 @@ func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(err == nil)
|
return r.vm.ToValue(err == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -295,7 +315,7 @@ func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -326,7 +346,105 @@ func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "path is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
path := call.Arguments[0].String()
|
||||||
|
fullPath, err := r.validatePath(path)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
options := parseRuntimeOptionsArgument(call, 1)
|
||||||
|
offset := runtimeOptionInt64(options, "offset", 0)
|
||||||
|
length := runtimeOptionInt64(options, "length", -1)
|
||||||
|
encoding := runtimeOptionString(options, "encoding", "base64")
|
||||||
|
if offset < 0 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "offset must be >= 0",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
info, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
size := info.Size()
|
||||||
|
if offset > size {
|
||||||
|
offset = size
|
||||||
|
}
|
||||||
|
if _, err := file.Seek(offset, io.SeekStart); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to seek file: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var data []byte
|
||||||
|
switch {
|
||||||
|
case length == 0:
|
||||||
|
data = []byte{}
|
||||||
|
case length > 0:
|
||||||
|
buf := make([]byte, int(length))
|
||||||
|
n, readErr := file.Read(buf)
|
||||||
|
if readErr != nil && readErr != io.EOF {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to read file: %v", readErr),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
data = buf[:n]
|
||||||
|
default:
|
||||||
|
data, err = io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to read file: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := encodeRuntimeBytes(data, encoding)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"data": encoded,
|
||||||
|
"bytes_read": len(data),
|
||||||
|
"offset": offset,
|
||||||
|
"size": size,
|
||||||
|
"eof": offset+int64(len(data)) >= size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -366,7 +484,108 @@ func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) fileWriteBytes(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "path and data are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
path := call.Arguments[0].String()
|
||||||
|
fullPath, err := r.validatePath(path)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
options := parseRuntimeOptionsArgument(call, 2)
|
||||||
|
appendMode := runtimeOptionBool(options, "append", false)
|
||||||
|
truncate := runtimeOptionBool(options, "truncate", false)
|
||||||
|
hasOffset := runtimeOptionHasKey(options, "offset")
|
||||||
|
offset := runtimeOptionInt64(options, "offset", 0)
|
||||||
|
encoding := runtimeOptionString(options, "encoding", "base64")
|
||||||
|
|
||||||
|
if appendMode && hasOffset {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "append and offset cannot be used together",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "offset must be >= 0",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := decodeRuntimeBytesValue(call.Arguments[1].Export(), encoding)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Dir(fullPath)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to create directory: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
flags := os.O_CREATE | os.O_WRONLY
|
||||||
|
if appendMode {
|
||||||
|
flags |= os.O_APPEND
|
||||||
|
}
|
||||||
|
if truncate {
|
||||||
|
flags |= os.O_TRUNC
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.OpenFile(fullPath, flags, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if hasOffset && !appendMode {
|
||||||
|
if _, err := file.Seek(offset, io.SeekStart); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to seek file: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
written, err := file.Write(data)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
info, statErr := file.Stat()
|
||||||
|
size := int64(0)
|
||||||
|
if statErr == nil {
|
||||||
|
size = info.Size()
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"path": fullPath,
|
||||||
|
"bytes_written": written,
|
||||||
|
"size": size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -439,7 +658,7 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -487,7 +706,7 @@ func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ type HTTPResponse struct {
|
|||||||
Headers map[string]string `json:"headers"`
|
Headers map[string]string `json:"headers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
func (r *extensionRuntime) validateDomain(urlStr string) error {
|
||||||
parsed, err := url.Parse(urlStr)
|
parsed, err := url.Parse(urlStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid URL: %w", err)
|
return fmt.Errorf("invalid URL: %w", err)
|
||||||
@@ -49,7 +49,7 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"error": "URL is required",
|
"error": "URL is required",
|
||||||
@@ -118,12 +118,13 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
|||||||
"statusCode": resp.StatusCode,
|
"statusCode": resp.StatusCode,
|
||||||
"status": resp.StatusCode,
|
"status": resp.StatusCode,
|
||||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
|
"url": resp.Request.URL.String(),
|
||||||
"body": string(body),
|
"body": string(body),
|
||||||
"headers": respHeaders,
|
"headers": respHeaders,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"error": "URL is required",
|
"error": "URL is required",
|
||||||
@@ -214,12 +215,13 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
|||||||
"statusCode": resp.StatusCode,
|
"statusCode": resp.StatusCode,
|
||||||
"status": resp.StatusCode,
|
"status": resp.StatusCode,
|
||||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
|
"url": resp.Request.URL.String(),
|
||||||
"body": string(body),
|
"body": string(body),
|
||||||
"headers": respHeaders,
|
"headers": respHeaders,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"error": "URL is required",
|
"error": "URL is required",
|
||||||
@@ -322,24 +324,25 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
|||||||
"statusCode": resp.StatusCode,
|
"statusCode": resp.StatusCode,
|
||||||
"status": resp.StatusCode,
|
"status": resp.StatusCode,
|
||||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
|
"url": resp.Request.URL.String(),
|
||||||
"body": string(body),
|
"body": string(body),
|
||||||
"headers": respHeaders,
|
"headers": respHeaders,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
|
||||||
return r.httpMethodShortcut("PUT", call)
|
return r.httpMethodShortcut("PUT", call)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
||||||
return r.httpMethodShortcut("DELETE", call)
|
return r.httpMethodShortcut("DELETE", call)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
|
||||||
return r.httpMethodShortcut("PATCH", call)
|
return r.httpMethodShortcut("PATCH", call)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"error": "URL is required",
|
"error": "URL is required",
|
||||||
@@ -446,12 +449,13 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
|||||||
"statusCode": resp.StatusCode,
|
"statusCode": resp.StatusCode,
|
||||||
"status": resp.StatusCode,
|
"status": resp.StatusCode,
|
||||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
|
"url": resp.Request.URL.String(),
|
||||||
"body": string(body),
|
"body": string(body),
|
||||||
"headers": respHeaders,
|
"headers": respHeaders,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
|
||||||
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
|
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
|
||||||
jar.mu.Lock()
|
jar.mu.Lock()
|
||||||
jar.cookies = make(map[string][]*http.Cookie)
|
jar.cookies = make(map[string][]*http.Cookie)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(0.0)
|
return r.vm.ToValue(0.0)
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@ func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.V
|
|||||||
return r.vm.ToValue(similarity)
|
return r.vm.ToValue(similarity)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
@@ -43,7 +43,7 @@ func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.
|
|||||||
return r.vm.ToValue(diff <= tolerance)
|
return r.vm.ToValue(diff <= tolerance)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,7 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// These polyfills make porting browser/Node.js libraries easier
|
func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||||
// without compromising sandbox security.
|
|
||||||
|
|
||||||
// Returns a Promise-like object with json(), text() methods.
|
|
||||||
func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.createFetchError("URL is required")
|
return r.createFetchError("URL is required")
|
||||||
}
|
}
|
||||||
@@ -38,7 +34,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
method = strings.ToUpper(m)
|
method = strings.ToUpper(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Body - support string, object (auto-stringify), or nil
|
|
||||||
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
|
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
|
||||||
switch v := bodyArg.(type) {
|
switch v := bodyArg.(type) {
|
||||||
case string:
|
case string:
|
||||||
@@ -110,7 +105,7 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
responseObj.Set("status", resp.StatusCode)
|
responseObj.Set("status", resp.StatusCode)
|
||||||
responseObj.Set("statusText", http.StatusText(resp.StatusCode))
|
responseObj.Set("statusText", http.StatusText(resp.StatusCode))
|
||||||
responseObj.Set("headers", respHeaders)
|
responseObj.Set("headers", respHeaders)
|
||||||
responseObj.Set("url", urlStr)
|
responseObj.Set("url", resp.Request.URL.String())
|
||||||
|
|
||||||
bodyString := string(body)
|
bodyString := string(body)
|
||||||
|
|
||||||
@@ -138,7 +133,7 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
return responseObj
|
return responseObj
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
|
func (r *extensionRuntime) createFetchError(message string) goja.Value {
|
||||||
errorObj := r.vm.NewObject()
|
errorObj := r.vm.NewObject()
|
||||||
errorObj.Set("ok", false)
|
errorObj.Set("ok", false)
|
||||||
errorObj.Set("status", 0)
|
errorObj.Set("status", 0)
|
||||||
@@ -153,7 +148,7 @@ func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
|
|||||||
return errorObj
|
return errorObj
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -169,7 +164,7 @@ func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(string(decoded))
|
return r.vm.ToValue(string(decoded))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -177,7 +172,7 @@ func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
func (r *extensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||||
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
|
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
|
||||||
encoder := call.This
|
encoder := call.This
|
||||||
encoder.Set("encoding", "utf-8")
|
encoder.Set("encoding", "utf-8")
|
||||||
@@ -197,7 +192,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value {
|
encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value {
|
||||||
// Simplified implementation
|
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return vm.ToValue(map[string]interface{}{"read": 0, "written": 0})
|
return vm.ToValue(map[string]interface{}{"read": 0, "written": 0})
|
||||||
}
|
}
|
||||||
@@ -258,7 +252,7 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
func (r *extensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||||
vm.Set("URL", func(call goja.ConstructorCall) *goja.Object {
|
vm.Set("URL", func(call goja.ConstructorCall) *goja.Object {
|
||||||
urlObj := call.This
|
urlObj := call.This
|
||||||
|
|
||||||
@@ -422,8 +416,7 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSON is already built-in to Goja; this ensures a fallback exists.
|
func (r *extensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
|
||||||
func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
|
|
||||||
jsonScript := `
|
jsonScript := `
|
||||||
if (typeof JSON === 'undefined') {
|
if (typeof JSON === 'undefined') {
|
||||||
var JSON = {
|
var JSON = {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const (
|
|||||||
storageFlushRetryDelay = 2 * time.Second
|
storageFlushRetryDelay = 2 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *ExtensionRuntime) getStoragePath() string {
|
func (r *extensionRuntime) getStoragePath() string {
|
||||||
return filepath.Join(r.dataDir, "storage.json")
|
return filepath.Join(r.dataDir, "storage.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ func cloneInterfaceMap(src map[string]interface{}) map[string]interface{} {
|
|||||||
return dst
|
return dst
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) ensureStorageLoaded() error {
|
func (r *extensionRuntime) ensureStorageLoaded() error {
|
||||||
r.storageMu.RLock()
|
r.storageMu.RLock()
|
||||||
if r.storageLoaded {
|
if r.storageLoaded {
|
||||||
r.storageMu.RUnlock()
|
r.storageMu.RUnlock()
|
||||||
@@ -74,7 +74,7 @@ func (r *ExtensionRuntime) ensureStorageLoaded() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
|
func (r *extensionRuntime) loadStorage() (map[string]interface{}, error) {
|
||||||
if err := r.ensureStorageLoaded(); err != nil {
|
if err := r.ensureStorageLoaded(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -84,7 +84,7 @@ func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
|
|||||||
return cloneInterfaceMap(r.storageCache), nil
|
return cloneInterfaceMap(r.storageCache), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) queueStorageFlushLocked(delay time.Duration) {
|
func (r *extensionRuntime) queueStorageFlushLocked(delay time.Duration) {
|
||||||
if r.storageClosed {
|
if r.storageClosed {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ func (r *ExtensionRuntime) queueStorageFlushLocked(delay time.Duration) {
|
|||||||
r.storageTimer = time.AfterFunc(delay, r.flushStorageDirtyAsync)
|
r.storageTimer = time.AfterFunc(delay, r.flushStorageDirtyAsync)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) persistStorageSnapshot(storage map[string]interface{}) error {
|
func (r *extensionRuntime) persistStorageSnapshot(storage map[string]interface{}) error {
|
||||||
data, err := json.Marshal(storage)
|
data, err := json.Marshal(storage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -106,13 +106,13 @@ func (r *ExtensionRuntime) persistStorageSnapshot(storage map[string]interface{}
|
|||||||
return os.WriteFile(r.getStoragePath(), data, 0600)
|
return os.WriteFile(r.getStoragePath(), data, 0600)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) flushStorageDirtyAsync() {
|
func (r *extensionRuntime) flushStorageDirtyAsync() {
|
||||||
if err := r.flushStorageDirty(); err != nil {
|
if err := r.flushStorageDirty(); err != nil {
|
||||||
GoLog("[Extension:%s] Storage flush error: %v\n", r.extensionID, err)
|
GoLog("[Extension:%s] Storage flush error: %v\n", r.extensionID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) flushStorageDirty() error {
|
func (r *extensionRuntime) flushStorageDirty() error {
|
||||||
r.storageMu.Lock()
|
r.storageMu.Lock()
|
||||||
if r.storageClosed {
|
if r.storageClosed {
|
||||||
r.storageTimer = nil
|
r.storageTimer = nil
|
||||||
@@ -140,7 +140,7 @@ func (r *ExtensionRuntime) flushStorageDirty() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) flushStorageNow() error {
|
func (r *extensionRuntime) flushStorageNow() error {
|
||||||
r.storageMu.Lock()
|
r.storageMu.Lock()
|
||||||
if r.storageTimer != nil {
|
if r.storageTimer != nil {
|
||||||
r.storageTimer.Stop()
|
r.storageTimer.Stop()
|
||||||
@@ -157,7 +157,7 @@ func (r *ExtensionRuntime) flushStorageNow() error {
|
|||||||
return r.persistStorageSnapshot(snapshot)
|
return r.persistStorageSnapshot(snapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) closeStorageFlusher() {
|
func (r *extensionRuntime) closeStorageFlusher() {
|
||||||
r.storageMu.Lock()
|
r.storageMu.Lock()
|
||||||
r.storageClosed = true
|
r.storageClosed = true
|
||||||
r.storageDirty = false
|
r.storageDirty = false
|
||||||
@@ -168,7 +168,7 @@ func (r *ExtensionRuntime) closeStorageFlusher() {
|
|||||||
r.storageMu.Unlock()
|
r.storageMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
@@ -193,7 +193,7 @@ func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(value)
|
return r.vm.ToValue(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
@@ -225,7 +225,7 @@ func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(true)
|
return r.vm.ToValue(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
@@ -254,15 +254,15 @@ func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(true)
|
return r.vm.ToValue(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) getCredentialsPath() string {
|
func (r *extensionRuntime) getCredentialsPath() string {
|
||||||
return filepath.Join(r.dataDir, ".credentials.enc")
|
return filepath.Join(r.dataDir, ".credentials.enc")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) getSaltPath() string {
|
func (r *extensionRuntime) getSaltPath() string {
|
||||||
return filepath.Join(r.dataDir, ".cred_salt")
|
return filepath.Join(r.dataDir, ".cred_salt")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
func (r *extensionRuntime) getOrCreateSalt() ([]byte, error) {
|
||||||
saltPath := r.getSaltPath()
|
saltPath := r.getSaltPath()
|
||||||
|
|
||||||
salt, err := os.ReadFile(saltPath)
|
salt, err := os.ReadFile(saltPath)
|
||||||
@@ -282,7 +282,7 @@ func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
|||||||
return salt, nil
|
return salt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
|
func (r *extensionRuntime) getEncryptionKey() ([]byte, error) {
|
||||||
salt, err := r.getOrCreateSalt()
|
salt, err := r.getOrCreateSalt()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -293,7 +293,7 @@ func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
|
|||||||
return hash[:], nil
|
return hash[:], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) ensureCredentialsLoaded() error {
|
func (r *extensionRuntime) ensureCredentialsLoaded() error {
|
||||||
r.credentialsMu.RLock()
|
r.credentialsMu.RLock()
|
||||||
if r.credentialsLoaded {
|
if r.credentialsLoaded {
|
||||||
r.credentialsMu.RUnlock()
|
r.credentialsMu.RUnlock()
|
||||||
@@ -340,7 +340,7 @@ func (r *ExtensionRuntime) ensureCredentialsLoaded() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
func (r *extensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
||||||
if err := r.ensureCredentialsLoaded(); err != nil {
|
if err := r.ensureCredentialsLoaded(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -350,7 +350,7 @@ func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
|||||||
return cloneInterfaceMap(r.credentialsCache), nil
|
return cloneInterfaceMap(r.credentialsCache), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
func (r *extensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
||||||
data, err := json.Marshal(creds)
|
data, err := json.Marshal(creds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -377,7 +377,7 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -414,7 +414,7 @@ func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
@@ -439,7 +439,7 @@ func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(value)
|
return r.vm.ToValue(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
@@ -464,7 +464,7 @@ func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value
|
|||||||
return r.vm.ToValue(true)
|
return r.vm.ToValue(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setStorageValue(t *testing.T, runtime *ExtensionRuntime, key string, value interface{}) {
|
func setStorageValue(t *testing.T, runtime *extensionRuntime, key string, value interface{}) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
result := runtime.storageSet(goja.FunctionCall{
|
result := runtime.storageSet(goja.FunctionCall{
|
||||||
Arguments: []goja.Value{
|
Arguments: []goja.Value{
|
||||||
@@ -39,7 +39,7 @@ func readStorageMap(t *testing.T, storagePath string) map[string]interface{} {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
|
func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
|
||||||
ext := &LoadedExtension{
|
ext := &loadedExtension{
|
||||||
ID: "storage-test",
|
ID: "storage-test",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
Name: "storage-test",
|
Name: "storage-test",
|
||||||
@@ -47,7 +47,7 @@ func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
|
|||||||
DataDir: t.TempDir(),
|
DataDir: t.TempDir(),
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime := NewExtensionRuntime(ext)
|
runtime := newExtensionRuntime(ext)
|
||||||
runtime.storageFlushDelay = 25 * time.Millisecond
|
runtime.storageFlushDelay = 25 * time.Millisecond
|
||||||
runtime.RegisterAPIs(goja.New())
|
runtime.RegisterAPIs(goja.New())
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestUnloadExtension_FlushesPendingStorage(t *testing.T) {
|
func TestUnloadExtension_FlushesPendingStorage(t *testing.T) {
|
||||||
ext := &LoadedExtension{
|
ext := &loadedExtension{
|
||||||
ID: "unload-storage-test",
|
ID: "unload-storage-test",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
Name: "unload-storage-test",
|
Name: "unload-storage-test",
|
||||||
@@ -95,13 +95,13 @@ func TestUnloadExtension_FlushesPendingStorage(t *testing.T) {
|
|||||||
VM: goja.New(),
|
VM: goja.New(),
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime := NewExtensionRuntime(ext)
|
runtime := newExtensionRuntime(ext)
|
||||||
runtime.storageFlushDelay = time.Hour
|
runtime.storageFlushDelay = time.Hour
|
||||||
runtime.RegisterAPIs(ext.VM)
|
runtime.RegisterAPIs(ext.VM)
|
||||||
ext.runtime = runtime
|
ext.runtime = runtime
|
||||||
|
|
||||||
manager := &ExtensionManager{
|
manager := &extensionManager{
|
||||||
extensions: map[string]*LoadedExtension{
|
extensions: map[string]*loadedExtension{
|
||||||
ext.ID: ext,
|
ext.ID: ext,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,7 @@ func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,7 @@ func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(string(decoded))
|
return r.vm.ToValue(string(decoded))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,7 @@ func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -54,7 +54,7 @@ func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -66,7 +66,7 @@ func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil)))
|
return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -78,7 +78,7 @@ func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
|
return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue([]byte{})
|
return r.vm.ToValue([]byte{})
|
||||||
}
|
}
|
||||||
@@ -130,7 +130,7 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(jsArray)
|
return r.vm.ToValue(jsArray)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
@@ -145,7 +145,7 @@ func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(result)
|
return r.vm.ToValue(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -160,7 +160,7 @@ func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(string(data))
|
return r.vm.ToValue(string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -187,7 +187,7 @@ func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -222,7 +222,7 @@ func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
|
||||||
length := 32
|
length := 32
|
||||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||||
if l, ok := call.Arguments[0].Export().(float64); ok {
|
if l, ok := call.Arguments[0].Export().(float64); ok {
|
||||||
@@ -245,35 +245,35 @@ func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
|
||||||
return r.vm.ToValue(getRandomUserAgent())
|
return r.vm.ToValue(getRandomUserAgent())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
||||||
msg := r.formatLogArgs(call.Arguments)
|
msg := r.formatLogArgs(call.Arguments)
|
||||||
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
|
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) logInfo(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) logInfo(call goja.FunctionCall) goja.Value {
|
||||||
msg := r.formatLogArgs(call.Arguments)
|
msg := r.formatLogArgs(call.Arguments)
|
||||||
GoLog("[Extension:%s:INFO] %s\n", r.extensionID, msg)
|
GoLog("[Extension:%s:INFO] %s\n", r.extensionID, msg)
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) logWarn(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) logWarn(call goja.FunctionCall) goja.Value {
|
||||||
msg := r.formatLogArgs(call.Arguments)
|
msg := r.formatLogArgs(call.Arguments)
|
||||||
GoLog("[Extension:%s:WARN] %s\n", r.extensionID, msg)
|
GoLog("[Extension:%s:WARN] %s\n", r.extensionID, msg)
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) logError(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) logError(call goja.FunctionCall) goja.Value {
|
||||||
msg := r.formatLogArgs(call.Arguments)
|
msg := r.formatLogArgs(call.Arguments)
|
||||||
GoLog("[Extension:%s:ERROR] %s\n", r.extensionID, msg)
|
GoLog("[Extension:%s:ERROR] %s\n", r.extensionID, msg)
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
|
func (r *extensionRuntime) formatLogArgs(args []goja.Value) string {
|
||||||
parts := make([]string, len(args))
|
parts := make([]string, len(args))
|
||||||
for i, arg := range args {
|
for i, arg := range args {
|
||||||
parts[i] = fmt.Sprintf("%v", arg.Export())
|
parts[i] = fmt.Sprintf("%v", arg.Export())
|
||||||
@@ -281,7 +281,7 @@ func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
|
|||||||
return strings.Join(parts, " ")
|
return strings.Join(parts, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -289,7 +289,7 @@ func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.
|
|||||||
return r.vm.ToValue(sanitizeFilename(input))
|
return r.vm.ToValue(sanitizeFilename(input))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||||
gobackendObj := vm.Get("gobackend")
|
gobackendObj := vm.Get("gobackend")
|
||||||
if gobackendObj == nil || goja.IsUndefined(gobackendObj) {
|
if gobackendObj == nil || goja.IsUndefined(gobackendObj) {
|
||||||
gobackendObj = vm.NewObject()
|
gobackendObj = vm.NewObject()
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const (
|
|||||||
CategoryIntegration = "integration"
|
CategoryIntegration = "integration"
|
||||||
)
|
)
|
||||||
|
|
||||||
type StoreExtension struct {
|
type storeExtension struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"display_name,omitempty"`
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
@@ -41,7 +41,7 @@ type StoreExtension struct {
|
|||||||
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
|
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *StoreExtension) getDisplayName() string {
|
func (e *storeExtension) getDisplayName() string {
|
||||||
if e.DisplayName != "" {
|
if e.DisplayName != "" {
|
||||||
return e.DisplayName
|
return e.DisplayName
|
||||||
}
|
}
|
||||||
@@ -51,34 +51,34 @@ func (e *StoreExtension) getDisplayName() string {
|
|||||||
return e.Name
|
return e.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *StoreExtension) getDownloadURL() string {
|
func (e *storeExtension) getDownloadURL() string {
|
||||||
if e.DownloadURL != "" {
|
if e.DownloadURL != "" {
|
||||||
return e.DownloadURL
|
return e.DownloadURL
|
||||||
}
|
}
|
||||||
return e.DownloadURLAlt
|
return e.DownloadURLAlt
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *StoreExtension) getIconURL() string {
|
func (e *storeExtension) getIconURL() string {
|
||||||
if e.IconURL != "" {
|
if e.IconURL != "" {
|
||||||
return e.IconURL
|
return e.IconURL
|
||||||
}
|
}
|
||||||
return e.IconURLAlt
|
return e.IconURLAlt
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *StoreExtension) getMinAppVersion() string {
|
func (e *storeExtension) getMinAppVersion() string {
|
||||||
if e.MinAppVersion != "" {
|
if e.MinAppVersion != "" {
|
||||||
return e.MinAppVersion
|
return e.MinAppVersion
|
||||||
}
|
}
|
||||||
return e.MinAppVersionAlt
|
return e.MinAppVersionAlt
|
||||||
}
|
}
|
||||||
|
|
||||||
type StoreRegistry struct {
|
type storeRegistry struct {
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
Extensions []StoreExtension `json:"extensions"`
|
Extensions []storeExtension `json:"extensions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type StoreExtensionResponse struct {
|
type storeExtensionResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
@@ -97,8 +97,8 @@ type StoreExtensionResponse struct {
|
|||||||
HasUpdate bool `json:"has_update"`
|
HasUpdate bool `json:"has_update"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *StoreExtension) ToResponse() StoreExtensionResponse {
|
func (e *storeExtension) toResponse() storeExtensionResponse {
|
||||||
return StoreExtensionResponse{
|
resp := storeExtensionResponse{
|
||||||
ID: e.ID,
|
ID: e.ID,
|
||||||
Name: e.Name,
|
Name: e.Name,
|
||||||
DisplayName: e.getDisplayName(),
|
DisplayName: e.getDisplayName(),
|
||||||
@@ -108,25 +108,30 @@ func (e *StoreExtension) ToResponse() StoreExtensionResponse {
|
|||||||
DownloadURL: e.getDownloadURL(),
|
DownloadURL: e.getDownloadURL(),
|
||||||
IconURL: e.getIconURL(),
|
IconURL: e.getIconURL(),
|
||||||
Category: e.Category,
|
Category: e.Category,
|
||||||
Tags: e.Tags,
|
|
||||||
Downloads: e.Downloads,
|
Downloads: e.Downloads,
|
||||||
UpdatedAt: e.UpdatedAt,
|
UpdatedAt: e.UpdatedAt,
|
||||||
MinAppVersion: e.getMinAppVersion(),
|
MinAppVersion: e.getMinAppVersion(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(e.Tags) > 0 {
|
||||||
|
resp.Tags = append([]string(nil), e.Tags...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExtensionStore struct {
|
type extensionStore struct {
|
||||||
registryURL string
|
registryURL string
|
||||||
cacheDir string
|
cacheDir string
|
||||||
cache *StoreRegistry
|
cache *storeRegistry
|
||||||
cacheMu sync.RWMutex
|
cacheMu sync.RWMutex
|
||||||
cacheTime time.Time
|
cacheTime time.Time
|
||||||
cacheTTL time.Duration
|
cacheTTL time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
extensionStore *ExtensionStore
|
globalExtensionStore *extensionStore
|
||||||
extensionStoreMu sync.Mutex
|
extensionStoreMu sync.Mutex
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -134,24 +139,22 @@ const (
|
|||||||
cacheFileName = "store_cache.json"
|
cacheFileName = "store_cache.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
func InitExtensionStore(cacheDir string) *ExtensionStore {
|
func initExtensionStore(cacheDir string) *extensionStore {
|
||||||
extensionStoreMu.Lock()
|
extensionStoreMu.Lock()
|
||||||
defer extensionStoreMu.Unlock()
|
defer extensionStoreMu.Unlock()
|
||||||
|
|
||||||
if extensionStore == nil {
|
if globalExtensionStore == nil {
|
||||||
extensionStore = &ExtensionStore{
|
globalExtensionStore = &extensionStore{
|
||||||
registryURL: "", // No default - user must provide a registry URL
|
registryURL: "",
|
||||||
cacheDir: cacheDir,
|
cacheDir: cacheDir,
|
||||||
cacheTTL: cacheTTL,
|
cacheTTL: cacheTTL,
|
||||||
}
|
}
|
||||||
extensionStore.loadDiskCache()
|
globalExtensionStore.loadDiskCache()
|
||||||
}
|
}
|
||||||
return extensionStore
|
return globalExtensionStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetRegistryURL updates the registry URL and clears the in-memory cache
|
func (s *extensionStore) setRegistryURL(registryURL string) {
|
||||||
// so the next fetch will use the new URL. Disk cache is also cleared.
|
|
||||||
func (s *ExtensionStore) SetRegistryURL(registryURL string) {
|
|
||||||
s.cacheMu.Lock()
|
s.cacheMu.Lock()
|
||||||
defer s.cacheMu.Unlock()
|
defer s.cacheMu.Unlock()
|
||||||
|
|
||||||
@@ -163,7 +166,6 @@ func (s *ExtensionStore) SetRegistryURL(registryURL string) {
|
|||||||
s.cache = nil
|
s.cache = nil
|
||||||
s.cacheTime = time.Time{}
|
s.cacheTime = time.Time{}
|
||||||
|
|
||||||
// Clear disk cache since it's from a different registry
|
|
||||||
if s.cacheDir != "" {
|
if s.cacheDir != "" {
|
||||||
cachePath := filepath.Join(s.cacheDir, cacheFileName)
|
cachePath := filepath.Join(s.cacheDir, cacheFileName)
|
||||||
os.Remove(cachePath)
|
os.Remove(cachePath)
|
||||||
@@ -172,20 +174,19 @@ func (s *ExtensionStore) SetRegistryURL(registryURL string) {
|
|||||||
LogInfo("ExtensionStore", "Registry URL updated to: %s", registryURL)
|
LogInfo("ExtensionStore", "Registry URL updated to: %s", registryURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRegistryURL returns the currently configured registry URL.
|
func (s *extensionStore) getRegistryURL() string {
|
||||||
func (s *ExtensionStore) GetRegistryURL() string {
|
|
||||||
s.cacheMu.RLock()
|
s.cacheMu.RLock()
|
||||||
defer s.cacheMu.RUnlock()
|
defer s.cacheMu.RUnlock()
|
||||||
return s.registryURL
|
return s.registryURL
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetExtensionStore() *ExtensionStore {
|
func getExtensionStore() *extensionStore {
|
||||||
extensionStoreMu.Lock()
|
extensionStoreMu.Lock()
|
||||||
defer extensionStoreMu.Unlock()
|
defer extensionStoreMu.Unlock()
|
||||||
return extensionStore
|
return globalExtensionStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) loadDiskCache() {
|
func (s *extensionStore) loadDiskCache() {
|
||||||
if s.cacheDir == "" {
|
if s.cacheDir == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -197,7 +198,7 @@ func (s *ExtensionStore) loadDiskCache() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var cacheData struct {
|
var cacheData struct {
|
||||||
Registry StoreRegistry `json:"registry"`
|
Registry storeRegistry `json:"registry"`
|
||||||
CacheTime int64 `json:"cache_time"`
|
CacheTime int64 `json:"cache_time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,13 +211,13 @@ func (s *ExtensionStore) loadDiskCache() {
|
|||||||
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
|
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) saveDiskCache() {
|
func (s *extensionStore) saveDiskCache() {
|
||||||
if s.cacheDir == "" || s.cache == nil {
|
if s.cacheDir == "" || s.cache == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheData := struct {
|
cacheData := struct {
|
||||||
Registry StoreRegistry `json:"registry"`
|
Registry storeRegistry `json:"registry"`
|
||||||
CacheTime int64 `json:"cache_time"`
|
CacheTime int64 `json:"cache_time"`
|
||||||
}{
|
}{
|
||||||
Registry: *s.cache,
|
Registry: *s.cache,
|
||||||
@@ -232,11 +233,10 @@ func (s *ExtensionStore) saveDiskCache() {
|
|||||||
os.WriteFile(cachePath, data, 0644)
|
os.WriteFile(cachePath, data, 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) {
|
func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error) {
|
||||||
s.cacheMu.Lock()
|
s.cacheMu.Lock()
|
||||||
defer s.cacheMu.Unlock()
|
defer s.cacheMu.Unlock()
|
||||||
|
|
||||||
// Check if a registry URL has been configured
|
|
||||||
if s.registryURL == "" {
|
if s.registryURL == "" {
|
||||||
return nil, fmt.Errorf("no registry URL configured. Please add a repository URL first")
|
return nil, fmt.Errorf("no registry URL configured. Please add a repository URL first")
|
||||||
}
|
}
|
||||||
@@ -276,7 +276,7 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
|
|||||||
return nil, fmt.Errorf("failed to read registry: %w", err)
|
return nil, fmt.Errorf("failed to read registry: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var registry StoreRegistry
|
var registry storeRegistry
|
||||||
if err := json.Unmarshal(body, ®istry); err != nil {
|
if err := json.Unmarshal(body, ®istry); err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse registry: %w", err)
|
return nil, fmt.Errorf("failed to parse registry: %w", err)
|
||||||
}
|
}
|
||||||
@@ -289,13 +289,13 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
|
|||||||
return ®istry, nil
|
return ®istry, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
|
func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExtensionResponse, error) {
|
||||||
registry, err := s.FetchRegistry(false)
|
registry, err := s.fetchRegistry(forceRefresh)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
installed := make(map[string]string) // id -> version
|
installed := make(map[string]string) // id -> version
|
||||||
|
|
||||||
if manager != nil {
|
if manager != nil {
|
||||||
@@ -304,29 +304,32 @@ func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, er
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]StoreExtensionResponse, len(registry.Extensions))
|
LogDebug("ExtensionStore", "Building store response for %d registry extensions (%d installed)", len(registry.Extensions), len(installed))
|
||||||
for i, ext := range registry.Extensions {
|
|
||||||
resp := ext.ToResponse()
|
|
||||||
|
|
||||||
|
result := make([]storeExtensionResponse, 0, len(registry.Extensions))
|
||||||
|
for i := range registry.Extensions {
|
||||||
|
ext := ®istry.Extensions[i]
|
||||||
|
resp := ext.toResponse()
|
||||||
if installedVersion, ok := installed[ext.ID]; ok {
|
if installedVersion, ok := installed[ext.ID]; ok {
|
||||||
resp.IsInstalled = true
|
resp.IsInstalled = true
|
||||||
resp.InstalledVersion = installedVersion
|
resp.InstalledVersion = installedVersion
|
||||||
resp.HasUpdate = compareVersions(ext.Version, installedVersion) > 0
|
resp.HasUpdate = compareVersions(ext.Version, installedVersion) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
result[i] = resp
|
result = append(result, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LogDebug("ExtensionStore", "Built store response payload for %d extensions", len(result))
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
|
func (s *extensionStore) downloadExtension(extensionID string, destPath string) error {
|
||||||
registry, err := s.FetchRegistry(false)
|
registry, err := s.fetchRegistry(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var ext *StoreExtension
|
var ext *storeExtension
|
||||||
for _, e := range registry.Extensions {
|
for _, e := range registry.Extensions {
|
||||||
if e.ID == extensionID {
|
if e.ID == extensionID {
|
||||||
ext = &e
|
ext = &e
|
||||||
@@ -371,33 +374,22 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveRegistryURL normalises a user-supplied URL into a direct registry.json URL.
|
func resolveRegistryURL(input string) (string, error) {
|
||||||
//
|
|
||||||
// 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)
|
input = strings.TrimSpace(input)
|
||||||
if input == "" {
|
if input == "" {
|
||||||
return "", fmt.Errorf("registry URL is empty")
|
return "", fmt.Errorf("registry URL is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Already a fully-qualified raw URL – keep it.
|
|
||||||
if strings.Contains(input, "raw.githubusercontent.com") {
|
if strings.Contains(input, "raw.githubusercontent.com") {
|
||||||
return input, nil
|
return input, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to match https://github.com/<owner>/<repo>[/...]
|
|
||||||
const ghPrefix = "https://github.com/"
|
const ghPrefix = "https://github.com/"
|
||||||
if !strings.HasPrefix(input, ghPrefix) {
|
if !strings.HasPrefix(input, ghPrefix) {
|
||||||
// Also accept http:// and upgrade silently.
|
|
||||||
const ghPrefixHTTP = "http://github.com/"
|
const ghPrefixHTTP = "http://github.com/"
|
||||||
if strings.HasPrefix(input, ghPrefixHTTP) {
|
if strings.HasPrefix(input, ghPrefixHTTP) {
|
||||||
input = "https://github.com/" + input[len(ghPrefixHTTP):]
|
input = "https://github.com/" + input[len(ghPrefixHTTP):]
|
||||||
} else {
|
} else {
|
||||||
// Not a GitHub URL – return as-is.
|
|
||||||
return input, nil
|
return input, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -417,8 +409,6 @@ func ResolveRegistryURL(input string) (string, error) {
|
|||||||
return resolved, nil
|
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 {
|
func resolveGitHubDefaultBranch(owner, repo string) string {
|
||||||
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repo)
|
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repo)
|
||||||
client := NewHTTPClientWithTimeout(10 * time.Second)
|
client := NewHTTPClientWithTimeout(10 * time.Second)
|
||||||
@@ -460,7 +450,7 @@ func requireHTTPSURL(rawURL string, context string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) GetCategories() []string {
|
func (s *extensionStore) getCategories() []string {
|
||||||
return []string{
|
return []string{
|
||||||
CategoryMetadata,
|
CategoryMetadata,
|
||||||
CategoryDownload,
|
CategoryDownload,
|
||||||
@@ -470,8 +460,8 @@ func (s *ExtensionStore) GetCategories() []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) {
|
func (s *extensionStore) searchExtensions(query string, category string) ([]storeExtensionResponse, error) {
|
||||||
extensions, err := s.GetExtensionsWithStatus()
|
extensions, err := s.getExtensionsWithStatus(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -480,7 +470,7 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
|
|||||||
return extensions, nil
|
return extensions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var result []StoreExtensionResponse
|
result := make([]storeExtensionResponse, 0, len(extensions))
|
||||||
queryLower := toLower(query)
|
queryLower := toLower(query)
|
||||||
|
|
||||||
for _, ext := range extensions {
|
for _, ext := range extensions {
|
||||||
@@ -493,7 +483,6 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
|
|||||||
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
||||||
!containsIgnoreCase(ext.Description, queryLower) &&
|
!containsIgnoreCase(ext.Description, queryLower) &&
|
||||||
!containsIgnoreCase(ext.Author, queryLower) {
|
!containsIgnoreCase(ext.Author, queryLower) {
|
||||||
// Check tags
|
|
||||||
found := false
|
found := false
|
||||||
for _, tag := range ext.Tags {
|
for _, tag := range ext.Tags {
|
||||||
if containsIgnoreCase(tag, queryLower) {
|
if containsIgnoreCase(tag, queryLower) {
|
||||||
@@ -513,7 +502,7 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) ClearCache() {
|
func (s *extensionStore) clearCache() {
|
||||||
s.cacheMu.Lock()
|
s.cacheMu.Lock()
|
||||||
defer s.cacheMu.Unlock()
|
defer s.cacheMu.Unlock()
|
||||||
|
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ func TestIsDomainAllowed(t *testing.T) {
|
|||||||
|
|
||||||
func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
||||||
// Create a mock extension with limited network permissions
|
// Create a mock extension with limited network permissions
|
||||||
ext := &LoadedExtension{
|
ext := &loadedExtension{
|
||||||
ID: "test-ext",
|
ID: "test-ext",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
Name: "test-ext",
|
Name: "test-ext",
|
||||||
@@ -110,7 +110,7 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
|||||||
DataDir: t.TempDir(),
|
DataDir: t.TempDir(),
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime := NewExtensionRuntime(ext)
|
runtime := newExtensionRuntime(ext)
|
||||||
|
|
||||||
if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil {
|
if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil {
|
||||||
t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err)
|
t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err)
|
||||||
@@ -132,7 +132,7 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
|||||||
func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
ext := &LoadedExtension{
|
ext := &loadedExtension{
|
||||||
ID: "test-ext",
|
ID: "test-ext",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
Name: "test-ext",
|
Name: "test-ext",
|
||||||
@@ -143,7 +143,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
|||||||
DataDir: tempDir,
|
DataDir: tempDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime := NewExtensionRuntime(ext)
|
runtime := newExtensionRuntime(ext)
|
||||||
|
|
||||||
validPath, err := runtime.validatePath("test.txt")
|
validPath, err := runtime.validatePath("test.txt")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -177,7 +177,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
|||||||
t.Error("Expected absolute path to be blocked")
|
t.Error("Expected absolute path to be blocked")
|
||||||
}
|
}
|
||||||
|
|
||||||
extNoFile := &LoadedExtension{
|
extNoFile := &loadedExtension{
|
||||||
ID: "test-ext-no-file",
|
ID: "test-ext-no-file",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
Name: "test-ext-no-file",
|
Name: "test-ext-no-file",
|
||||||
@@ -187,7 +187,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
|||||||
},
|
},
|
||||||
DataDir: tempDir,
|
DataDir: tempDir,
|
||||||
}
|
}
|
||||||
runtimeNoFile := NewExtensionRuntime(extNoFile)
|
runtimeNoFile := newExtensionRuntime(extNoFile)
|
||||||
_, err = runtimeNoFile.validatePath("test.txt")
|
_, err = runtimeNoFile.validatePath("test.txt")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Expected file access to be denied without file permission")
|
t.Error("Expected file access to be denied without file permission")
|
||||||
@@ -195,7 +195,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
||||||
ext := &LoadedExtension{
|
ext := &loadedExtension{
|
||||||
ID: "test-ext",
|
ID: "test-ext",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
Name: "test-ext",
|
Name: "test-ext",
|
||||||
@@ -203,7 +203,7 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
|||||||
DataDir: t.TempDir(),
|
DataDir: t.TempDir(),
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime := NewExtensionRuntime(ext)
|
runtime := newExtensionRuntime(ext)
|
||||||
vm := goja.New()
|
vm := goja.New()
|
||||||
runtime.RegisterAPIs(vm)
|
runtime.RegisterAPIs(vm)
|
||||||
|
|
||||||
@@ -243,7 +243,7 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
|||||||
|
|
||||||
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
||||||
// Create extension with limited network permissions
|
// Create extension with limited network permissions
|
||||||
ext := &LoadedExtension{
|
ext := &loadedExtension{
|
||||||
ID: "test-ext",
|
ID: "test-ext",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
Name: "test-ext",
|
Name: "test-ext",
|
||||||
@@ -254,7 +254,7 @@ func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
|||||||
DataDir: t.TempDir(),
|
DataDir: t.TempDir(),
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime := NewExtensionRuntime(ext)
|
runtime := newExtensionRuntime(ext)
|
||||||
|
|
||||||
privateIPs := []string{
|
privateIPs := []string{
|
||||||
"http://localhost/admin",
|
"http://localhost/admin",
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ func (e *JSExecutionError) Error() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||||
|
if vm == nil {
|
||||||
|
return nil, fmt.Errorf("extension runtime unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
if timeout <= 0 {
|
if timeout <= 0 {
|
||||||
timeout = DefaultJSTimeout
|
timeout = DefaultJSTimeout
|
||||||
}
|
}
|
||||||
@@ -49,7 +53,7 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
|||||||
IsTimeout: true,
|
IsTimeout: true,
|
||||||
}}
|
}}
|
||||||
} else {
|
} else {
|
||||||
GoLog("[ExtensionRuntime] panic during JS execution: %v\n%s\n", r, string(debug.Stack()))
|
GoLog("[extensionRuntime] panic during JS execution: %v\n%s\n", r, string(debug.Stack()))
|
||||||
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
|
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,6 +73,11 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
|||||||
|
|
||||||
vm.Interrupt("execution timeout")
|
vm.Interrupt("execution timeout")
|
||||||
|
|
||||||
|
// MUST wait for the goroutine to finish before returning.
|
||||||
|
// The Goja VM is NOT thread-safe — if we return while the goroutine
|
||||||
|
// is still executing JS (e.g. blocked on an HTTP call), the next
|
||||||
|
// caller will access the VM concurrently and crash with a nil
|
||||||
|
// pointer dereference.
|
||||||
select {
|
select {
|
||||||
case res := <-resultCh:
|
case res := <-resultCh:
|
||||||
if res.err != nil {
|
if res.err != nil {
|
||||||
@@ -78,7 +87,10 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
|||||||
Message: "execution timeout exceeded",
|
Message: "execution timeout exceeded",
|
||||||
IsTimeout: true,
|
IsTimeout: true,
|
||||||
}
|
}
|
||||||
case <-time.After(1 * time.Second):
|
case <-time.After(60 * time.Second):
|
||||||
|
// Goroutine is truly stuck (e.g. HTTP read with no timeout).
|
||||||
|
// Log a warning — the VM should NOT be reused after this.
|
||||||
|
GoLog("[extensionRuntime] WARNING: JS goroutine did not exit within 60s after interrupt, VM may be unsafe\n")
|
||||||
return nil, &JSExecutionError{
|
return nil, &JSExecutionError{
|
||||||
Message: "execution timeout exceeded (force)",
|
Message: "execution timeout exceeded (force)",
|
||||||
IsTimeout: true,
|
IsTimeout: true,
|
||||||
@@ -92,8 +104,9 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
|||||||
func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||||
result, err := RunWithTimeout(vm, script, timeout)
|
result, err := RunWithTimeout(vm, script, timeout)
|
||||||
|
|
||||||
// Clear any interrupt state so VM can be reused
|
if vm != nil {
|
||||||
vm.ClearInterrupt()
|
vm.ClearInterrupt()
|
||||||
|
}
|
||||||
|
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-15
@@ -2,28 +2,28 @@ module github.com/zarz/spotiflac_android/go_backend
|
|||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
toolchain go1.25.7
|
toolchain go1.25.8
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5
|
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
|
||||||
github.com/go-flac/flacpicture/v2 v2.0.2
|
github.com/go-flac/flacpicture/v2 v2.0.2
|
||||||
github.com/go-flac/flacvorbis/v2 v2.0.2
|
github.com/go-flac/flacvorbis/v2 v2.0.2
|
||||||
github.com/go-flac/go-flac/v2 v2.0.4
|
github.com/go-flac/go-flac/v2 v2.0.4
|
||||||
github.com/refraction-networking/utls v1.8.2
|
github.com/refraction-networking/utls v1.8.2
|
||||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864
|
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60
|
||||||
golang.org/x/net v0.50.0
|
golang.org/x/net v0.52.0
|
||||||
|
golang.org/x/text v0.35.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/andybalholm/brotli v1.0.6 // indirect
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
|
||||||
github.com/klauspost/compress v1.17.4 // indirect
|
github.com/klauspost/compress v1.18.5 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.49.0 // indirect
|
||||||
golang.org/x/mod v0.33.0 // indirect
|
golang.org/x/mod v0.34.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/tools v0.43.0 // indirect
|
||||||
golang.org/x/tools v0.42.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
+30
-46
@@ -1,67 +1,51 @@
|
|||||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||||
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
|
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||||
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk=
|
||||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||||
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw=
|
|
||||||
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
|
||||||
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
||||||
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
|
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
|
||||||
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
|
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
|
||||||
github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g=
|
github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g=
|
||||||
github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sYBf0=
|
github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sYBf0=
|
||||||
github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
|
github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
|
||||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
||||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
|
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
|
||||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
|
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||||
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||||
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
||||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4=
|
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60 h1:MOzyaj0wu2xneBkzkg9LHNYjDBB4W5vP043A2SYQRPA=
|
||||||
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
|
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60/go.mod h1:th6VJvzjMbrYF8SduQY5rpD0HG0GleGxjadkqSxFs3k=
|
||||||
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af h1:VqXrZNyqFISxo0rNDFZQlRDRIp7RXSJDeh/LbrK+W1k=
|
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||||
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af/go.mod h1:tbwefIr7RlQD1OpZ0KEZ9nux/uiihAOGdafgZfJkmII=
|
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4=
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ=
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
|
||||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
|
||||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
|
||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
|
||||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
|
||||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
|
||||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
|
||||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
@@ -66,9 +66,6 @@ var sharedTransport = &http.Transport{
|
|||||||
DisableCompression: true,
|
DisableCompression: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// metadataTransport is a separate transport for metadata API calls (Deezer, Spotify, SongLink).
|
|
||||||
// Isolated from download traffic so that download failures cannot poison
|
|
||||||
// the connection pool used by metadata enrichment.
|
|
||||||
var metadataTransport = &http.Transport{
|
var metadataTransport = &http.Transport{
|
||||||
DialContext: (&net.Dialer{
|
DialContext: (&net.Dialer{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
@@ -104,8 +101,6 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMetadataHTTPClient creates an HTTP client using the isolated metadata transport.
|
|
||||||
// Use this for API calls that should not be affected by download traffic.
|
|
||||||
func NewMetadataHTTPClient(timeout time.Duration) *http.Client {
|
func NewMetadataHTTPClient(timeout time.Duration) *http.Client {
|
||||||
return &http.Client{
|
return &http.Client{
|
||||||
Transport: newCompatibilityTransport(metadataTransport),
|
Transport: newCompatibilityTransport(metadataTransport),
|
||||||
@@ -229,7 +224,6 @@ func cloneRequestWithHTTPScheme(req *http.Request, scheme string) (*http.Request
|
|||||||
return reqCopy, nil
|
return reqCopy, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also checks for ISP blocking on errors
|
|
||||||
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
|
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
@@ -239,7 +233,6 @@ func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Respo
|
|||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// RetryConfig holds configuration for retry logic
|
|
||||||
type RetryConfig struct {
|
type RetryConfig struct {
|
||||||
MaxRetries int
|
MaxRetries int
|
||||||
InitialDelay time.Duration
|
InitialDelay time.Duration
|
||||||
@@ -300,14 +293,11 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for ISP blocking via HTTP status codes
|
|
||||||
// Some ISPs return 403 or 451 when blocking content
|
|
||||||
if resp.StatusCode == 403 || resp.StatusCode == 451 {
|
if resp.StatusCode == 403 || resp.StatusCode == 451 {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
bodyStr := strings.ToLower(string(body))
|
bodyStr := strings.ToLower(string(body))
|
||||||
|
|
||||||
// Check if response looks like ISP blocking page
|
|
||||||
ispBlockingIndicators := []string{
|
ispBlockingIndicators := []string{
|
||||||
"blocked", "forbidden", "access denied", "not available in your",
|
"blocked", "forbidden", "access denied", "not available in your",
|
||||||
"restricted", "censored", "unavailable for legal", "blocked by",
|
"restricted", "censored", "unavailable for legal", "blocked by",
|
||||||
@@ -518,7 +508,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns true if ISP blocking was detected
|
|
||||||
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
||||||
ispErr := IsISPBlocking(err, requestURL)
|
ispErr := IsISPBlocking(err, requestURL)
|
||||||
if ispErr != nil {
|
if ispErr != nil {
|
||||||
@@ -553,7 +542,6 @@ func extractDomain(rawURL string) string {
|
|||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
// If ISP blocking is detected, returns a more descriptive error
|
|
||||||
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
|
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -6,17 +6,10 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
// iOS version: uTLS is not supported on iOS due to cgo DNS resolver issues
|
|
||||||
// Fall back to standard HTTP client
|
|
||||||
|
|
||||||
// GetCloudflareBypassClient returns the standard HTTP client on iOS
|
|
||||||
// uTLS is not available on iOS due to cgo DNS resolver compatibility issues
|
|
||||||
func GetCloudflareBypassClient() *http.Client {
|
func GetCloudflareBypassClient() *http.Client {
|
||||||
return sharedClient
|
return sharedClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// DoRequestWithCloudflareBypass on iOS just uses the standard client
|
|
||||||
// uTLS Chrome fingerprint bypass is not available on iOS
|
|
||||||
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
resp, err := sharedClient.Do(req)
|
resp, err := sharedClient.Do(req)
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ import (
|
|||||||
"golang.org/x/net/http2"
|
"golang.org/x/net/http2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// uTLS transport that mimics Chrome's TLS fingerprint to bypass Cloudflare
|
|
||||||
// Uses HTTP/2 for optimal performance as uTLS works best with HTTP/2
|
|
||||||
type utlsTransport struct {
|
type utlsTransport struct {
|
||||||
dialer *net.Dialer
|
dialer *net.Dialer
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -98,21 +96,15 @@ var cloudflareBypassClient = &http.Client{
|
|||||||
Timeout: DefaultTimeout,
|
Timeout: DefaultTimeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCloudflareBypassClient returns an HTTP client that mimics Chrome's TLS fingerprint
|
|
||||||
// Use this when requests are blocked by Cloudflare (common when using VPN)
|
|
||||||
func GetCloudflareBypassClient() *http.Client {
|
func GetCloudflareBypassClient() *http.Client {
|
||||||
return cloudflareBypassClient
|
return cloudflareBypassClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// DoRequestWithCloudflareBypass attempts request with standard client first,
|
|
||||||
// then retries with uTLS Chrome fingerprint if Cloudflare blocks it.
|
|
||||||
// This is useful when using VPN as Cloudflare detects Go's default TLS fingerprint.
|
|
||||||
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
resp, err := sharedClient.Do(req)
|
resp, err := sharedClient.Do(req)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Check for Cloudflare challenge page (403 with specific markers)
|
|
||||||
if resp.StatusCode == 403 || resp.StatusCode == 503 {
|
if resp.StatusCode == 403 || resp.StatusCode == 503 {
|
||||||
body, readErr := io.ReadAll(resp.Body)
|
body, readErr := io.ReadAll(resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
@@ -143,7 +135,6 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not Cloudflare, return original response (recreate body)
|
|
||||||
return &http.Response{
|
return &http.Response{
|
||||||
Status: resp.Status,
|
Status: resp.Status,
|
||||||
StatusCode: resp.StatusCode,
|
StatusCode: resp.StatusCode,
|
||||||
@@ -154,7 +145,6 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if error might be TLS-related (Cloudflare blocking)
|
|
||||||
errStr := strings.ToLower(err.Error())
|
errStr := strings.ToLower(err.Error())
|
||||||
tlsRelated := strings.Contains(errStr, "tls") ||
|
tlsRelated := strings.Contains(errStr, "tls") ||
|
||||||
strings.Contains(errStr, "handshake") ||
|
strings.Contains(errStr, "handshake") ||
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// IDHSClient is a client for I Don't Have Spotify API
|
|
||||||
// Used as fallback when SongLink fails or is rate limited
|
|
||||||
type IDHSClient struct {
|
type IDHSClient struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
}
|
}
|
||||||
@@ -55,7 +53,6 @@ func NewIDHSClient() *IDHSClient {
|
|||||||
return globalIDHSClient
|
return globalIDHSClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search converts a music link to links on other platforms
|
|
||||||
func (c *IDHSClient) Search(link string, adapters []string) (*IDHSSearchResponse, error) {
|
func (c *IDHSClient) Search(link string, adapters []string) (*IDHSSearchResponse, error) {
|
||||||
idhsRateLimiter.WaitForSlot()
|
idhsRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
@@ -109,7 +106,6 @@ func (c *IDHSClient) Search(link string, adapters []string) (*IDHSSearchResponse
|
|||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAvailabilityFromSpotify checks track availability using IDHS as fallback
|
|
||||||
func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
|
func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
|
||||||
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
||||||
|
|
||||||
|
|||||||
+146
-55
@@ -13,25 +13,31 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type LibraryScanResult struct {
|
type LibraryScanResult struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
TrackName string `json:"trackName"`
|
TrackName string `json:"trackName"`
|
||||||
ArtistName string `json:"artistName"`
|
ArtistName string `json:"artistName"`
|
||||||
AlbumName string `json:"albumName"`
|
AlbumName string `json:"albumName"`
|
||||||
AlbumArtist string `json:"albumArtist,omitempty"`
|
AlbumArtist string `json:"albumArtist,omitempty"`
|
||||||
FilePath string `json:"filePath"`
|
FilePath string `json:"filePath"`
|
||||||
CoverPath string `json:"coverPath,omitempty"`
|
CoverPath string `json:"coverPath,omitempty"`
|
||||||
ScannedAt string `json:"scannedAt"`
|
ScannedAt string `json:"scannedAt"`
|
||||||
FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds
|
FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds
|
||||||
ISRC string `json:"isrc,omitempty"`
|
ISRC string `json:"isrc,omitempty"`
|
||||||
TrackNumber int `json:"trackNumber,omitempty"`
|
TrackNumber int `json:"trackNumber,omitempty"`
|
||||||
DiscNumber int `json:"discNumber,omitempty"`
|
TotalTracks int `json:"totalTracks,omitempty"`
|
||||||
Duration int `json:"duration,omitempty"`
|
DiscNumber int `json:"discNumber,omitempty"`
|
||||||
ReleaseDate string `json:"releaseDate,omitempty"`
|
TotalDiscs int `json:"totalDiscs,omitempty"`
|
||||||
BitDepth int `json:"bitDepth,omitempty"`
|
Duration int `json:"duration,omitempty"`
|
||||||
SampleRate int `json:"sampleRate,omitempty"`
|
ReleaseDate string `json:"releaseDate,omitempty"`
|
||||||
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis)
|
BitDepth int `json:"bitDepth,omitempty"`
|
||||||
Genre string `json:"genre,omitempty"`
|
SampleRate int `json:"sampleRate,omitempty"`
|
||||||
Format string `json:"format,omitempty"`
|
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis)
|
||||||
|
Genre string `json:"genre,omitempty"`
|
||||||
|
Composer string `json:"composer,omitempty"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
Copyright string `json:"copyright,omitempty"`
|
||||||
|
Format string `json:"format,omitempty"`
|
||||||
|
MetadataFromFilename bool `json:"metadataFromFilename,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LibraryScanProgress struct {
|
type LibraryScanProgress struct {
|
||||||
@@ -65,6 +71,9 @@ var supportedAudioFormats = map[string]bool{
|
|||||||
".mp3": true,
|
".mp3": true,
|
||||||
".opus": true,
|
".opus": true,
|
||||||
".ogg": true,
|
".ogg": true,
|
||||||
|
".ape": true,
|
||||||
|
".wv": true,
|
||||||
|
".mpc": true,
|
||||||
".cue": true,
|
".cue": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,11 +178,9 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||||
errorCount := 0
|
errorCount := 0
|
||||||
|
|
||||||
// Track audio files referenced by .cue sheets to avoid duplicates
|
|
||||||
cueReferencedAudioFiles := make(map[string]bool)
|
cueReferencedAudioFiles := make(map[string]bool)
|
||||||
parsedCueFiles := make(map[string]scannedCueFileInfo)
|
parsedCueFiles := make(map[string]scannedCueFileInfo)
|
||||||
|
|
||||||
// First pass: scan .cue files to collect referenced audio paths
|
|
||||||
for _, fileInfo := range audioFileInfos {
|
for _, fileInfo := range audioFileInfos {
|
||||||
filePath := fileInfo.path
|
filePath := fileInfo.path
|
||||||
ext := strings.ToLower(filepath.Ext(filePath))
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
@@ -208,7 +215,6 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
|
|
||||||
ext := strings.ToLower(filepath.Ext(filePath))
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
|
||||||
// Handle .cue files: produce multiple track results
|
|
||||||
if ext == ".cue" {
|
if ext == ".cue" {
|
||||||
var cueResults []LibraryScanResult
|
var cueResults []LibraryScanResult
|
||||||
cueInfo, ok := parsedCueFiles[filePath]
|
cueInfo, ok := parsedCueFiles[filePath]
|
||||||
@@ -219,6 +225,7 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
cueInfo.audioPath,
|
cueInfo.audioPath,
|
||||||
"",
|
"",
|
||||||
fileInfo.modTime,
|
fileInfo.modTime,
|
||||||
|
"",
|
||||||
scanTime,
|
scanTime,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -234,8 +241,6 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip audio files that are referenced by a .cue sheet
|
|
||||||
// (they will be represented by the cue sheet's track entries instead)
|
|
||||||
if cueReferencedAudioFiles[filePath] {
|
if cueReferencedAudioFiles[filePath] {
|
||||||
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
|
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
|
||||||
continue
|
continue
|
||||||
@@ -271,10 +276,14 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func scanAudioFileWithKnownModTime(filePath, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
func scanAudioFileWithKnownModTime(filePath, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
||||||
return scanAudioFileWithKnownModTimeAndDisplayName(filePath, "", scanTime, knownModTime)
|
return scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, "", "", scanTime, knownModTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
||||||
|
return scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displayNameHint, "", scanTime, knownModTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displayNameHint, coverCacheKey, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
||||||
ext := resolveLibraryAudioExt(filePath, displayNameHint)
|
ext := resolveLibraryAudioExt(filePath, displayNameHint)
|
||||||
|
|
||||||
result := &LibraryScanResult{
|
result := &LibraryScanResult{
|
||||||
@@ -293,8 +302,13 @@ func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scan
|
|||||||
libraryCoverCacheMu.RLock()
|
libraryCoverCacheMu.RLock()
|
||||||
coverCacheDir := libraryCoverCacheDir
|
coverCacheDir := libraryCoverCacheDir
|
||||||
libraryCoverCacheMu.RUnlock()
|
libraryCoverCacheMu.RUnlock()
|
||||||
if coverCacheDir != "" && ext != ".m4a" {
|
if coverCacheDir != "" {
|
||||||
coverPath, err := SaveCoverToCacheWithHint(filePath, displayNameHint, coverCacheDir)
|
coverPath, err := SaveCoverToCacheWithHintAndKey(
|
||||||
|
filePath,
|
||||||
|
displayNameHint,
|
||||||
|
coverCacheDir,
|
||||||
|
coverCacheKey,
|
||||||
|
)
|
||||||
if err == nil && coverPath != "" {
|
if err == nil && coverPath != "" {
|
||||||
result.CoverPath = coverPath
|
result.CoverPath = coverPath
|
||||||
}
|
}
|
||||||
@@ -302,13 +316,15 @@ func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scan
|
|||||||
|
|
||||||
switch ext {
|
switch ext {
|
||||||
case ".flac":
|
case ".flac":
|
||||||
return scanFLACFile(filePath, result)
|
return scanFLACFile(filePath, result, displayNameHint)
|
||||||
case ".m4a":
|
case ".m4a":
|
||||||
return scanM4AFile(filePath, result)
|
return scanM4AFile(filePath, result, displayNameHint)
|
||||||
case ".mp3":
|
case ".mp3":
|
||||||
return scanMP3File(filePath, result)
|
return scanMP3File(filePath, result, displayNameHint)
|
||||||
case ".opus", ".ogg":
|
case ".opus", ".ogg":
|
||||||
return scanOggFile(filePath, result, displayNameHint)
|
return scanOggFile(filePath, result, displayNameHint)
|
||||||
|
case ".ape", ".wv", ".mpc":
|
||||||
|
return scanAPEFile(filePath, result, displayNameHint)
|
||||||
default:
|
default:
|
||||||
return scanFromFilename(filePath, displayNameHint, result)
|
return scanFromFilename(filePath, displayNameHint, result)
|
||||||
}
|
}
|
||||||
@@ -342,10 +358,10 @@ func applyDefaultLibraryMetadata(filePath, displayNameHint string, result *Libra
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||||
metadata, err := ReadMetadata(filePath)
|
metadata, err := ReadMetadata(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return scanFromFilename(filePath, "", result)
|
return scanFromFilename(filePath, displayNameHint, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.TrackName = metadata.Title
|
result.TrackName = metadata.Title
|
||||||
@@ -354,9 +370,14 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul
|
|||||||
result.AlbumArtist = metadata.AlbumArtist
|
result.AlbumArtist = metadata.AlbumArtist
|
||||||
result.ISRC = metadata.ISRC
|
result.ISRC = metadata.ISRC
|
||||||
result.TrackNumber = metadata.TrackNumber
|
result.TrackNumber = metadata.TrackNumber
|
||||||
|
result.TotalTracks = metadata.TotalTracks
|
||||||
result.DiscNumber = metadata.DiscNumber
|
result.DiscNumber = metadata.DiscNumber
|
||||||
|
result.TotalDiscs = metadata.TotalDiscs
|
||||||
result.ReleaseDate = metadata.Date
|
result.ReleaseDate = metadata.Date
|
||||||
result.Genre = metadata.Genre
|
result.Genre = metadata.Genre
|
||||||
|
result.Composer = metadata.Composer
|
||||||
|
result.Label = metadata.Label
|
||||||
|
result.Copyright = metadata.Copyright
|
||||||
|
|
||||||
quality, err := GetAudioQuality(filePath)
|
quality, err := GetAudioQuality(filePath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -367,26 +388,53 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applyDefaultLibraryMetadata(filePath, "", result)
|
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||||
|
metadata, err := ReadM4ATags(filePath)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[LibraryScan] M4A read error for %s: %v\n", filePath, err)
|
||||||
|
return scanFromFilename(filePath, displayNameHint, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if 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.TotalTracks = metadata.TotalTracks
|
||||||
|
result.DiscNumber = metadata.DiscNumber
|
||||||
|
result.TotalDiscs = metadata.TotalDiscs
|
||||||
|
result.ReleaseDate = metadata.Date
|
||||||
|
if result.ReleaseDate == "" {
|
||||||
|
result.ReleaseDate = metadata.Year
|
||||||
|
}
|
||||||
|
result.Genre = metadata.Genre
|
||||||
|
result.Composer = metadata.Composer
|
||||||
|
result.Label = metadata.Label
|
||||||
|
result.Copyright = metadata.Copyright
|
||||||
|
}
|
||||||
|
|
||||||
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, displayNameHint, result)
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint string) (*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, displayNameHint, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.TrackName = metadata.Title
|
result.TrackName = metadata.Title
|
||||||
@@ -394,7 +442,9 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
|||||||
result.AlbumName = metadata.Album
|
result.AlbumName = metadata.Album
|
||||||
result.AlbumArtist = metadata.AlbumArtist
|
result.AlbumArtist = metadata.AlbumArtist
|
||||||
result.TrackNumber = metadata.TrackNumber
|
result.TrackNumber = metadata.TrackNumber
|
||||||
|
result.TotalTracks = metadata.TotalTracks
|
||||||
result.DiscNumber = metadata.DiscNumber
|
result.DiscNumber = metadata.DiscNumber
|
||||||
|
result.TotalDiscs = metadata.TotalDiscs
|
||||||
result.Genre = metadata.Genre
|
result.Genre = metadata.Genre
|
||||||
if metadata.Date != "" {
|
if metadata.Date != "" {
|
||||||
result.ReleaseDate = metadata.Date
|
result.ReleaseDate = metadata.Date
|
||||||
@@ -402,6 +452,9 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
|||||||
result.ReleaseDate = metadata.Year
|
result.ReleaseDate = metadata.Year
|
||||||
}
|
}
|
||||||
result.ISRC = metadata.ISRC
|
result.ISRC = metadata.ISRC
|
||||||
|
result.Composer = metadata.Composer
|
||||||
|
result.Label = metadata.Label
|
||||||
|
result.Copyright = metadata.Copyright
|
||||||
|
|
||||||
quality, err := GetMP3Quality(filePath)
|
quality, err := GetMP3Quality(filePath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -413,7 +466,7 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applyDefaultLibraryMetadata(filePath, "", result)
|
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -431,9 +484,14 @@ func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint str
|
|||||||
result.AlbumArtist = metadata.AlbumArtist
|
result.AlbumArtist = metadata.AlbumArtist
|
||||||
result.ISRC = metadata.ISRC
|
result.ISRC = metadata.ISRC
|
||||||
result.TrackNumber = metadata.TrackNumber
|
result.TrackNumber = metadata.TrackNumber
|
||||||
|
result.TotalTracks = metadata.TotalTracks
|
||||||
result.DiscNumber = metadata.DiscNumber
|
result.DiscNumber = metadata.DiscNumber
|
||||||
|
result.TotalDiscs = metadata.TotalDiscs
|
||||||
result.Genre = metadata.Genre
|
result.Genre = metadata.Genre
|
||||||
result.ReleaseDate = metadata.Date
|
result.ReleaseDate = metadata.Date
|
||||||
|
result.Composer = metadata.Composer
|
||||||
|
result.Label = metadata.Label
|
||||||
|
result.Copyright = metadata.Copyright
|
||||||
|
|
||||||
quality, err := GetOggQuality(filePath)
|
quality, err := GetOggQuality(filePath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -450,7 +508,44 @@ func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint str
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func scanAPEFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||||
|
tag, err := ReadAPETags(filePath)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[LibraryScan] APE tag read error for %s: %v\n", filePath, err)
|
||||||
|
return scanFromFilename(filePath, displayNameHint, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := APETagToAudioMetadata(tag)
|
||||||
|
if metadata == nil {
|
||||||
|
return scanFromFilename(filePath, displayNameHint, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.TotalTracks = metadata.TotalTracks
|
||||||
|
result.DiscNumber = metadata.DiscNumber
|
||||||
|
result.TotalDiscs = metadata.TotalDiscs
|
||||||
|
result.Genre = metadata.Genre
|
||||||
|
if metadata.Date != "" {
|
||||||
|
result.ReleaseDate = metadata.Date
|
||||||
|
} else {
|
||||||
|
result.ReleaseDate = metadata.Year
|
||||||
|
}
|
||||||
|
result.Composer = metadata.Composer
|
||||||
|
result.Label = metadata.Label
|
||||||
|
result.Copyright = metadata.Copyright
|
||||||
|
|
||||||
|
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||||
|
result.MetadataFromFilename = true
|
||||||
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
|
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
|
||||||
filename := strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
|
filename := strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
|
||||||
|
|
||||||
@@ -526,8 +621,18 @@ func ReadAudioMetadata(filePath string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string, error) {
|
func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string, error) {
|
||||||
|
return ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayNameHint, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayNameHint, coverCacheKey string) (string, error) {
|
||||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||||
result, err := scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime, 0)
|
result, err := scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(
|
||||||
|
filePath,
|
||||||
|
displayNameHint,
|
||||||
|
coverCacheKey,
|
||||||
|
scanTime,
|
||||||
|
0,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -540,9 +645,6 @@ func ReadAudioMetadataWithDisplayName(filePath, displayNameHint 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 loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) {
|
func loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) {
|
||||||
existingFiles := make(map[string]int64)
|
existingFiles := make(map[string]int64)
|
||||||
if snapshotPath == "" {
|
if snapshotPath == "" {
|
||||||
@@ -620,7 +722,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
libraryScanProgress.TotalFiles = totalFiles
|
libraryScanProgress.TotalFiles = totalFiles
|
||||||
libraryScanProgressMu.Unlock()
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
// Find files to scan (new or modified)
|
|
||||||
var filesToScan []libraryAudioFileInfo
|
var filesToScan []libraryAudioFileInfo
|
||||||
skippedCount := 0
|
skippedCount := 0
|
||||||
existingCueTrackModTimes := make(map[string]int64)
|
existingCueTrackModTimes := make(map[string]int64)
|
||||||
@@ -636,10 +737,8 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
for _, f := range currentFiles {
|
for _, f := range currentFiles {
|
||||||
existingModTime, exists := existingFiles[f.path]
|
existingModTime, exists := existingFiles[f.path]
|
||||||
if !exists {
|
if !exists {
|
||||||
// For .cue files, also check if any virtual path entries exist
|
|
||||||
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
|
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
|
||||||
if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks {
|
if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks {
|
||||||
// CUE file exists in DB via virtual paths; check if modTime changed
|
|
||||||
if f.modTime == cueTrackModTime {
|
if f.modTime == cueTrackModTime {
|
||||||
skippedCount++
|
skippedCount++
|
||||||
} else {
|
} else {
|
||||||
@@ -658,14 +757,11 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
|
|
||||||
var deletedPaths []string
|
var deletedPaths []string
|
||||||
for existingPath := range existingFiles {
|
for existingPath := range existingFiles {
|
||||||
// For CUE virtual paths (e.g. "/path/album.cue#track01"),
|
|
||||||
// check if the base .cue file still exists on disk
|
|
||||||
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
|
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
|
||||||
baseCuePath := existingPath[:idx]
|
baseCuePath := existingPath[:idx]
|
||||||
if currentPathSet[baseCuePath] {
|
if currentPathSet[baseCuePath] {
|
||||||
continue // Base .cue file still exists, not deleted
|
continue
|
||||||
}
|
}
|
||||||
// Base CUE file is gone, mark virtual path as deleted
|
|
||||||
deletedPaths = append(deletedPaths, existingPath)
|
deletedPaths = append(deletedPaths, existingPath)
|
||||||
} else if !currentPathSet[existingPath] {
|
} else if !currentPathSet[existingPath] {
|
||||||
deletedPaths = append(deletedPaths, existingPath)
|
deletedPaths = append(deletedPaths, existingPath)
|
||||||
@@ -696,7 +792,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||||
errorCount := 0
|
errorCount := 0
|
||||||
|
|
||||||
// Track audio files referenced by .cue sheets to avoid duplicates (incremental)
|
|
||||||
cueReferencedAudioFilesInc := make(map[string]bool)
|
cueReferencedAudioFilesInc := make(map[string]bool)
|
||||||
parsedCueFiles := make(map[string]scannedCueFileInfo)
|
parsedCueFiles := make(map[string]scannedCueFileInfo)
|
||||||
for _, f := range filesToScan {
|
for _, f := range filesToScan {
|
||||||
@@ -731,7 +826,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
|
|
||||||
ext := strings.ToLower(filepath.Ext(f.path))
|
ext := strings.ToLower(filepath.Ext(f.path))
|
||||||
|
|
||||||
// Handle .cue files: produce multiple track results
|
|
||||||
if ext == ".cue" {
|
if ext == ".cue" {
|
||||||
var cueResults []LibraryScanResult
|
var cueResults []LibraryScanResult
|
||||||
cueInfo, ok := parsedCueFiles[f.path]
|
cueInfo, ok := parsedCueFiles[f.path]
|
||||||
@@ -742,6 +836,7 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
cueInfo.audioPath,
|
cueInfo.audioPath,
|
||||||
"",
|
"",
|
||||||
f.modTime,
|
f.modTime,
|
||||||
|
"",
|
||||||
scanTime,
|
scanTime,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -756,7 +851,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip audio files referenced by .cue sheets
|
|
||||||
if cueReferencedAudioFilesInc[f.path] {
|
if cueReferencedAudioFilesInc[f.path] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -796,9 +890,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
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) {
|
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
|
||||||
existingFiles := make(map[string]int64)
|
existingFiles := make(map[string]int64)
|
||||||
if existingFilesJSON != "" && existingFilesJSON != "{}" {
|
if existingFilesJSON != "" && existingFilesJSON != "{}" {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestScanFromFilenameMarksMetadataFallback(t *testing.T) {
|
||||||
|
result := &LibraryScanResult{}
|
||||||
|
|
||||||
|
scanned, err := scanFromFilename(
|
||||||
|
"/proc/self/fd/209",
|
||||||
|
"189.mp3",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("scanFromFilename returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !scanned.MetadataFromFilename {
|
||||||
|
t.Fatal("expected filename fallback marker to be set")
|
||||||
|
}
|
||||||
|
if scanned.TrackName != "189" {
|
||||||
|
t.Fatalf("unexpected track name: %q", scanned.TrackName)
|
||||||
|
}
|
||||||
|
if scanned.ArtistName != "Unknown Artist" {
|
||||||
|
t.Fatalf("unexpected artist name: %q", scanned.ArtistName)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-15
@@ -25,7 +25,6 @@ type LogBuffer struct {
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
defaultLogBufferSize = 500
|
defaultLogBufferSize = 500
|
||||||
maxLogMessageLength = 500
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -52,20 +51,12 @@ func GetLogBuffer() *LogBuffer {
|
|||||||
globalLogBuffer = &LogBuffer{
|
globalLogBuffer = &LogBuffer{
|
||||||
entries: make([]LogEntry, 0, defaultLogBufferSize),
|
entries: make([]LogEntry, 0, defaultLogBufferSize),
|
||||||
maxSize: defaultLogBufferSize,
|
maxSize: defaultLogBufferSize,
|
||||||
loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
|
loggingEnabled: false,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return globalLogBuffer
|
return globalLogBuffer
|
||||||
}
|
}
|
||||||
|
|
||||||
func truncateLogMessage(message string) string {
|
|
||||||
runes := []rune(message)
|
|
||||||
if len(runes) <= maxLogMessageLength {
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
return string(runes[:maxLogMessageLength]) + "...[truncated]"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
|
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
|
||||||
lb.mu.Lock()
|
lb.mu.Lock()
|
||||||
defer lb.mu.Unlock()
|
defer lb.mu.Unlock()
|
||||||
@@ -87,7 +78,6 @@ func (lb *LogBuffer) Add(level, tag, message string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message = sanitizeSensitiveLogText(message)
|
message = sanitizeSensitiveLogText(message)
|
||||||
message = truncateLogMessage(message)
|
|
||||||
|
|
||||||
entry := LogEntry{
|
entry := LogEntry{
|
||||||
Timestamp: time.Now().Format("15:04:05.000"),
|
Timestamp: time.Now().Format("15:04:05.000"),
|
||||||
@@ -155,13 +145,10 @@ func LogError(tag, format string, args ...interface{}) {
|
|||||||
GetLogBuffer().Add("ERROR", tag, fmt.Sprintf(format, args...))
|
GetLogBuffer().Add("ERROR", tag, fmt.Sprintf(format, args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GoLog is a drop-in replacement for fmt.Printf that also logs to buffer
|
|
||||||
// It parses the tag from the format string if it starts with [Tag]
|
|
||||||
func GoLog(format string, args ...interface{}) {
|
func GoLog(format string, args ...interface{}) {
|
||||||
message := fmt.Sprintf(format, args...)
|
message := fmt.Sprintf(format, args...)
|
||||||
message = strings.TrimSuffix(message, "\n")
|
message = strings.TrimSuffix(message, "\n")
|
||||||
|
|
||||||
// Extract tag from message if present (e.g., "[Tidal] message")
|
|
||||||
tag := "Go"
|
tag := "Go"
|
||||||
level := "INFO"
|
level := "INFO"
|
||||||
|
|
||||||
@@ -173,7 +160,6 @@ func GoLog(format string, args ...interface{}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine level from message content
|
|
||||||
msgLower := strings.ToLower(message)
|
msgLower := strings.ToLower(message)
|
||||||
if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") {
|
if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") {
|
||||||
level = "ERROR"
|
level = "ERROR"
|
||||||
|
|||||||
+19
-219
@@ -20,9 +20,7 @@ const (
|
|||||||
durationToleranceSec = 10.0
|
durationToleranceSec = 10.0
|
||||||
)
|
)
|
||||||
|
|
||||||
// Lyrics provider names (used in settings and cascade ordering)
|
|
||||||
const (
|
const (
|
||||||
LyricsProviderSpotifyAPI = "spotify_api"
|
|
||||||
LyricsProviderLRCLIB = "lrclib"
|
LyricsProviderLRCLIB = "lrclib"
|
||||||
LyricsProviderNetease = "netease"
|
LyricsProviderNetease = "netease"
|
||||||
LyricsProviderMusixmatch = "musixmatch"
|
LyricsProviderMusixmatch = "musixmatch"
|
||||||
@@ -30,11 +28,8 @@ const (
|
|||||||
LyricsProviderQQMusic = "qqmusic"
|
LyricsProviderQQMusic = "qqmusic"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultLyricsProviders is the default cascade order for lyrics fetching.
|
|
||||||
// LRCLIB first (no proxy dependency), then the others.
|
|
||||||
var DefaultLyricsProviders = []string{
|
var DefaultLyricsProviders = []string{
|
||||||
LyricsProviderLRCLIB,
|
LyricsProviderLRCLIB,
|
||||||
LyricsProviderSpotifyAPI,
|
|
||||||
LyricsProviderMusixmatch,
|
LyricsProviderMusixmatch,
|
||||||
LyricsProviderNetease,
|
LyricsProviderNetease,
|
||||||
LyricsProviderAppleMusic,
|
LyricsProviderAppleMusic,
|
||||||
@@ -46,12 +41,6 @@ var (
|
|||||||
lyricsProviders []string // ordered list of enabled providers
|
lyricsProviders []string // ordered list of enabled providers
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
spotifyLyricsRateLimitMu sync.RWMutex
|
|
||||||
spotifyLyricsRateLimitedTil time.Time
|
|
||||||
)
|
|
||||||
|
|
||||||
// LyricsFetchOptions controls optional provider-specific enhancements.
|
|
||||||
type LyricsFetchOptions struct {
|
type LyricsFetchOptions struct {
|
||||||
IncludeTranslationNetease bool `json:"include_translation_netease"`
|
IncludeTranslationNetease bool `json:"include_translation_netease"`
|
||||||
IncludeRomanizationNetease bool `json:"include_romanization_netease"`
|
IncludeRomanizationNetease bool `json:"include_romanization_netease"`
|
||||||
@@ -71,8 +60,6 @@ var (
|
|||||||
lyricsFetchOptions = defaultLyricsFetchOptions
|
lyricsFetchOptions = defaultLyricsFetchOptions
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetLyricsProviderOrder sets the ordered list of lyrics providers to try.
|
|
||||||
// Providers not in the list are disabled. An empty list resets to defaults.
|
|
||||||
func SetLyricsProviderOrder(providers []string) {
|
func SetLyricsProviderOrder(providers []string) {
|
||||||
lyricsProvidersMu.Lock()
|
lyricsProvidersMu.Lock()
|
||||||
defer lyricsProvidersMu.Unlock()
|
defer lyricsProvidersMu.Unlock()
|
||||||
@@ -82,9 +69,7 @@ func SetLyricsProviderOrder(providers []string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate provider names
|
|
||||||
validNames := map[string]bool{
|
validNames := map[string]bool{
|
||||||
LyricsProviderSpotifyAPI: true,
|
|
||||||
LyricsProviderLRCLIB: true,
|
LyricsProviderLRCLIB: true,
|
||||||
LyricsProviderNetease: true,
|
LyricsProviderNetease: true,
|
||||||
LyricsProviderMusixmatch: true,
|
LyricsProviderMusixmatch: true,
|
||||||
@@ -104,7 +89,6 @@ func SetLyricsProviderOrder(providers []string) {
|
|||||||
GoLog("[Lyrics] Provider order set to: %v\n", valid)
|
GoLog("[Lyrics] Provider order set to: %v\n", valid)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLyricsProviderOrder returns the current lyrics provider order.
|
|
||||||
func GetLyricsProviderOrder() []string {
|
func GetLyricsProviderOrder() []string {
|
||||||
lyricsProvidersMu.RLock()
|
lyricsProvidersMu.RLock()
|
||||||
defer lyricsProvidersMu.RUnlock()
|
defer lyricsProvidersMu.RUnlock()
|
||||||
@@ -118,15 +102,13 @@ func GetLyricsProviderOrder() []string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAvailableLyricsProviders returns metadata about all available providers.
|
|
||||||
func GetAvailableLyricsProviders() []map[string]interface{} {
|
func GetAvailableLyricsProviders() []map[string]interface{} {
|
||||||
return []map[string]interface{}{
|
return []map[string]interface{}{
|
||||||
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced synced lyrics via community API"},
|
|
||||||
{"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"},
|
{"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"},
|
||||||
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": false, "description": "NetEase Cloud Music (good for Asian songs)"},
|
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics via Paxsenix"},
|
||||||
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Largest lyrics database (multi-language)"},
|
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics via Paxsenix"},
|
||||||
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Word-by-word synced lyrics"},
|
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics via Paxsenix"},
|
||||||
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics (good for Chinese songs)"},
|
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics via Paxsenix"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +121,6 @@ func normalizeLyricsFetchOptions(opts LyricsFetchOptions) LyricsFetchOptions {
|
|||||||
return opts
|
return opts
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetLyricsFetchOptions sets provider-specific lyric fetch behavior.
|
|
||||||
func SetLyricsFetchOptions(opts LyricsFetchOptions) {
|
func SetLyricsFetchOptions(opts LyricsFetchOptions) {
|
||||||
normalized := normalizeLyricsFetchOptions(opts)
|
normalized := normalizeLyricsFetchOptions(opts)
|
||||||
|
|
||||||
@@ -155,7 +136,6 @@ func SetLyricsFetchOptions(opts LyricsFetchOptions) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLyricsFetchOptions returns current provider-specific lyric fetch behavior.
|
|
||||||
func GetLyricsFetchOptions() LyricsFetchOptions {
|
func GetLyricsFetchOptions() LyricsFetchOptions {
|
||||||
lyricsFetchOptionsMu.RLock()
|
lyricsFetchOptionsMu.RLock()
|
||||||
defer lyricsFetchOptionsMu.RUnlock()
|
defer lyricsFetchOptionsMu.RUnlock()
|
||||||
@@ -253,18 +233,6 @@ type LRCLibResponse struct {
|
|||||||
SyncedLyrics string `json:"syncedLyrics"`
|
SyncedLyrics string `json:"syncedLyrics"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SpotifyLyricsLine struct {
|
|
||||||
TimeTag string `json:"timeTag"`
|
|
||||||
Words string `json:"words"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SpotifyLyricsAPIResponse struct {
|
|
||||||
Error bool `json:"error"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
SyncType string `json:"syncType"`
|
|
||||||
Lines []SpotifyLyricsLine `json:"lines"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type LyricsLine struct {
|
type LyricsLine struct {
|
||||||
StartTimeMs int64 `json:"startTimeMs"`
|
StartTimeMs int64 `json:"startTimeMs"`
|
||||||
Words string `json:"words"`
|
Words string `json:"words"`
|
||||||
@@ -372,172 +340,6 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo
|
|||||||
return c.parseLRCLibResponse(&results[0]), nil
|
return c.parseLRCLibResponse(&results[0]), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseSpotifyLyricsTimeTagToMs(tag string) int64 {
|
|
||||||
raw := strings.TrimSpace(tag)
|
|
||||||
raw = strings.TrimPrefix(raw, "[")
|
|
||||||
raw = strings.TrimSuffix(raw, "]")
|
|
||||||
if raw == "" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if ms, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
|
||||||
return ms
|
|
||||||
}
|
|
||||||
|
|
||||||
re := regexp.MustCompile(`^(\d{1,2}):(\d{2})\.(\d{1,3})$`)
|
|
||||||
matches := re.FindStringSubmatch(raw)
|
|
||||||
if len(matches) != 4 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
minutes, _ := strconv.ParseInt(matches[1], 10, 64)
|
|
||||||
seconds, _ := strconv.ParseInt(matches[2], 10, 64)
|
|
||||||
fraction := matches[3]
|
|
||||||
fractionInt, _ := strconv.ParseInt(fraction, 10, 64)
|
|
||||||
if len(fraction) == 2 {
|
|
||||||
fractionInt *= 10
|
|
||||||
} else if len(fraction) == 1 {
|
|
||||||
fractionInt *= 100
|
|
||||||
}
|
|
||||||
return minutes*60*1000 + seconds*1000 + fractionInt
|
|
||||||
}
|
|
||||||
|
|
||||||
func getSpotifyLyricsRateLimitUntil() time.Time {
|
|
||||||
spotifyLyricsRateLimitMu.RLock()
|
|
||||||
defer spotifyLyricsRateLimitMu.RUnlock()
|
|
||||||
return spotifyLyricsRateLimitedTil
|
|
||||||
}
|
|
||||||
|
|
||||||
func setSpotifyLyricsRateLimitUntil(until time.Time) {
|
|
||||||
spotifyLyricsRateLimitMu.Lock()
|
|
||||||
spotifyLyricsRateLimitedTil = until
|
|
||||||
spotifyLyricsRateLimitMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseSpotifyRetryAfter(retryAfter string, now time.Time) time.Time {
|
|
||||||
raw := strings.TrimSpace(retryAfter)
|
|
||||||
if raw == "" {
|
|
||||||
return now.Add(10 * time.Minute)
|
|
||||||
}
|
|
||||||
|
|
||||||
if sec, err := strconv.Atoi(raw); err == nil && sec > 0 {
|
|
||||||
return now.Add(time.Duration(sec) * time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
if when, err := http.ParseTime(raw); err == nil && when.After(now) {
|
|
||||||
return when
|
|
||||||
}
|
|
||||||
|
|
||||||
return now.Add(10 * time.Minute)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsResponse, error) {
|
|
||||||
now := time.Now()
|
|
||||||
if limitedUntil := getSpotifyLyricsRateLimitUntil(); limitedUntil.After(now) {
|
|
||||||
waitFor := int(math.Ceil(limitedUntil.Sub(now).Seconds()))
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"Spotify Lyrics API cooldown active (%ds remaining after previous 429)",
|
|
||||||
waitFor,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
spotifyID = strings.TrimSpace(spotifyID)
|
|
||||||
if spotifyID == "" {
|
|
||||||
return nil, fmt.Errorf("spotify ID is empty")
|
|
||||||
}
|
|
||||||
if parsed, err := parseSpotifyURI(spotifyID); err == nil && parsed.Type == "track" && parsed.ID != "" {
|
|
||||||
spotifyID = parsed.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
apiURL := fmt.Sprintf("https://spotify-lyrics-api-pi.vercel.app/?trackid=%s&format=lrc", url.QueryEscape(spotifyID))
|
|
||||||
req, err := http.NewRequest("GET", apiURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to fetch from Spotify Lyrics API: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
if resp.StatusCode == http.StatusTooManyRequests {
|
|
||||||
retryUntil := parseSpotifyRetryAfter(resp.Header.Get("Retry-After"), now)
|
|
||||||
setSpotifyLyricsRateLimitUntil(retryUntil)
|
|
||||||
}
|
|
||||||
var payload map[string]interface{}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err == nil {
|
|
||||||
if 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))
|
|
||||||
}
|
|
||||||
if msg, ok := payload["error"].(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", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
var apiResp SpotifyLyricsAPIResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if apiResp.Error {
|
|
||||||
msg := strings.TrimSpace(apiResp.Message)
|
|
||||||
if msg == "" {
|
|
||||||
msg = "Spotify Lyrics API returned error"
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("%s", msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := &LyricsResponse{
|
|
||||||
Lines: make([]LyricsLine, 0, len(apiResp.Lines)),
|
|
||||||
SyncType: apiResp.SyncType,
|
|
||||||
Instrumental: false,
|
|
||||||
PlainLyrics: "",
|
|
||||||
Provider: "Spotify Lyrics API",
|
|
||||||
Source: "Spotify Lyrics API",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, line := range apiResp.Lines {
|
|
||||||
words := strings.TrimSpace(line.Words)
|
|
||||||
if words == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag)
|
|
||||||
result.Lines = append(result.Lines, LyricsLine{
|
|
||||||
StartTimeMs: startMs,
|
|
||||||
Words: words,
|
|
||||||
EndTimeMs: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(result.Lines) > 1 {
|
|
||||||
for i := 0; i < len(result.Lines)-1; i++ {
|
|
||||||
nextStart := result.Lines[i+1].StartTimeMs
|
|
||||||
if nextStart > result.Lines[i].StartTimeMs {
|
|
||||||
result.Lines[i].EndTimeMs = nextStart
|
|
||||||
}
|
|
||||||
}
|
|
||||||
last := len(result.Lines) - 1
|
|
||||||
if result.Lines[last].EndTimeMs == 0 {
|
|
||||||
result.Lines[last].EndTimeMs = result.Lines[last].StartTimeMs + 5000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(result.Lines) == 0 {
|
|
||||||
return nil, fmt.Errorf("Spotify Lyrics API returned empty lines")
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.SyncType == "" {
|
|
||||||
result.SyncType = "LINE_SYNCED"
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
|
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
|
||||||
var bestSynced *LRCLibResponse
|
var bestSynced *LRCLibResponse
|
||||||
var bestPlain *LRCLibResponse
|
var bestPlain *LRCLibResponse
|
||||||
@@ -562,6 +364,18 @@ func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec
|
|||||||
return bestPlain
|
return bestPlain
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool {
|
func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool {
|
||||||
diff := math.Abs(lrcDuration - targetDuration)
|
diff := math.Abs(lrcDuration - targetDuration)
|
||||||
return diff <= durationToleranceSec
|
return diff <= durationToleranceSec
|
||||||
@@ -571,8 +385,8 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
|||||||
primaryArtist := normalizeArtistName(artistName)
|
primaryArtist := normalizeArtistName(artistName)
|
||||||
fetchOptions := GetLyricsFetchOptions()
|
fetchOptions := GetLyricsFetchOptions()
|
||||||
|
|
||||||
extManager := GetExtensionManager()
|
extManager := getExtensionManager()
|
||||||
var extensionProviders []*ExtensionProviderWrapper
|
var extensionProviders []*extensionProviderWrapper
|
||||||
if extManager != nil {
|
if extManager != nil {
|
||||||
extensionProviders = extManager.GetLyricsProviders()
|
extensionProviders = extManager.GetLyricsProviders()
|
||||||
}
|
}
|
||||||
@@ -624,7 +438,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
|||||||
|
|
||||||
GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder)
|
GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder)
|
||||||
|
|
||||||
// Cascade through all configured built-in providers
|
|
||||||
for _, providerName := range providerOrder {
|
for _, providerName := range providerOrder {
|
||||||
GoLog("[Lyrics] Trying provider: %s\n", providerName)
|
GoLog("[Lyrics] Trying provider: %s\n", providerName)
|
||||||
|
|
||||||
@@ -632,9 +445,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
|||||||
var err error
|
var err error
|
||||||
|
|
||||||
switch providerName {
|
switch providerName {
|
||||||
case LyricsProviderSpotifyAPI:
|
|
||||||
lyrics, err = c.FetchLyricsFromSpotifyAPI(spotifyID)
|
|
||||||
|
|
||||||
case LyricsProviderLRCLIB:
|
case LyricsProviderLRCLIB:
|
||||||
lyrics, err = c.tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack, durationSec)
|
lyrics, err = c.tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack, durationSec)
|
||||||
|
|
||||||
@@ -716,19 +526,16 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
|||||||
return nil, fmt.Errorf("lyrics not found from any source")
|
return nil, fmt.Errorf("lyrics not found from any source")
|
||||||
}
|
}
|
||||||
|
|
||||||
// tryLRCLIB attempts all LRCLIB search strategies (exact match, simplified, search).
|
|
||||||
func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack string, durationSec float64) (*LyricsResponse, error) {
|
func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack string, durationSec float64) (*LyricsResponse, error) {
|
||||||
var lyrics *LyricsResponse
|
var lyrics *LyricsResponse
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// 1. Exact match with primary artist
|
|
||||||
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
|
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
|
||||||
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||||
lyrics.Source = "LRCLIB"
|
lyrics.Source = "LRCLIB"
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Exact match with full artist name
|
|
||||||
if primaryArtist != artistName {
|
if primaryArtist != artistName {
|
||||||
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
||||||
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||||
@@ -737,7 +544,6 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Simplified track name
|
|
||||||
if simplifiedTrack != trackName {
|
if simplifiedTrack != trackName {
|
||||||
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
|
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
|
||||||
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||||
@@ -746,7 +552,6 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Search by query
|
|
||||||
query := primaryArtist + " " + trackName
|
query := primaryArtist + " " + trackName
|
||||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||||
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||||
@@ -754,7 +559,6 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
|
|||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Search with simplified track name
|
|
||||||
if simplifiedTrack != trackName {
|
if simplifiedTrack != trackName {
|
||||||
query = primaryArtist + " " + simplifiedTrack
|
query = primaryArtist + " " + simplifiedTrack
|
||||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||||
@@ -872,8 +676,6 @@ func lyricsHasUsableText(lyrics *LyricsResponse) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectLyricsErrorPayload extracts human-readable error messages from
|
|
||||||
// JSON payloads returned by lyrics proxies when no lyric is available.
|
|
||||||
func detectLyricsErrorPayload(raw string) (string, bool) {
|
func detectLyricsErrorPayload(raw string) (string, bool) {
|
||||||
trimmed := strings.TrimSpace(raw)
|
trimmed := strings.TrimSpace(raw)
|
||||||
if trimmed == "" || !strings.HasPrefix(trimmed, "{") {
|
if trimmed == "" || !strings.HasPrefix(trimmed, "{") {
|
||||||
@@ -945,7 +747,7 @@ func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName stri
|
|||||||
|
|
||||||
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
|
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
|
||||||
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
|
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
|
||||||
builder.WriteString("[by:SpotiFLAC-Mobile]\n")
|
builder.WriteString("[by:Implemented by SpotiFLAC-Mobile using Paxsenix API]\n")
|
||||||
builder.WriteString("\n")
|
builder.WriteString("\n")
|
||||||
|
|
||||||
if lyrics.SyncType == "LINE_SYNCED" {
|
if lyrics.SyncType == "LINE_SYNCED" {
|
||||||
@@ -998,8 +800,6 @@ func simplifyTrackName(name string) string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add a loose fallback form for provider queries where punctuation
|
|
||||||
// and separators differ (e.g. "/" vs "_" vs spaces).
|
|
||||||
if loose := normalizeLooseTitle(result); loose != "" {
|
if loose := normalizeLooseTitle(result); loose != "" {
|
||||||
return loose
|
return loose
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-134
@@ -4,124 +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.
|
|
||||||
// Uses a scraped JWT token for search and a proxy for lyrics.
|
|
||||||
type AppleMusicClient struct {
|
type AppleMusicClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apple Music token manager — singleton with mutex for thread safety
|
type appleMusicSearchResult struct {
|
||||||
type appleTokenManager struct {
|
ID string `json:"id"`
|
||||||
mu sync.Mutex
|
SongName string `json:"songName"`
|
||||||
token string
|
ArtistName string `json:"artistName"`
|
||||||
|
AlbumName string `json:"albumName"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var globalAppleTokenManager = &appleTokenManager{}
|
|
||||||
|
|
||||||
func (m *appleTokenManager) getToken(client *http.Client) (string, error) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
if m.token != "" {
|
|
||||||
return m.token, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Fetch the Apple Music beta page
|
|
||||||
req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to fetch Apple Music page: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read Apple Music page: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Find the index JS file URL
|
|
||||||
indexJsRegex := regexp.MustCompile(`/assets/index~[^/]+\.js`)
|
|
||||||
match := indexJsRegex.Find(body)
|
|
||||||
if match == nil {
|
|
||||||
return "", fmt.Errorf("could not find index JS script URL on Apple Music page")
|
|
||||||
}
|
|
||||||
|
|
||||||
indexJsURL := "https://beta.music.apple.com" + string(match)
|
|
||||||
|
|
||||||
// Step 3: Fetch the JS file
|
|
||||||
jsReq, err := http.NewRequest("GET", indexJsURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create JS request: %w", err)
|
|
||||||
}
|
|
||||||
jsReq.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
|
|
||||||
jsResp, err := client.Do(jsReq)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to fetch Apple Music JS: %w", err)
|
|
||||||
}
|
|
||||||
defer jsResp.Body.Close()
|
|
||||||
|
|
||||||
jsBody, err := io.ReadAll(jsResp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read Apple Music JS: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Extract JWT token (starts with eyJh)
|
|
||||||
tokenRegex := regexp.MustCompile(`eyJh[^"]*`)
|
|
||||||
tokenMatch := tokenRegex.Find(jsBody)
|
|
||||||
if tokenMatch == nil {
|
|
||||||
return "", fmt.Errorf("could not find JWT token in Apple Music JS")
|
|
||||||
}
|
|
||||||
|
|
||||||
m.token = string(tokenMatch)
|
|
||||||
GoLog("[AppleMusic] Token obtained successfully (length: %d)\n", len(m.token))
|
|
||||||
return m.token, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *appleTokenManager) clearToken() {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
m.token = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
type appleMusicSearchResponse struct {
|
|
||||||
Results struct {
|
|
||||||
Songs *struct {
|
|
||||||
Data []struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
} `json:"data"`
|
|
||||||
} `json:"songs"`
|
|
||||||
} `json:"results"`
|
|
||||||
Resources *struct {
|
|
||||||
Songs map[string]struct {
|
|
||||||
Attributes struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
ArtistName string `json:"artistName"`
|
|
||||||
AlbumName string `json:"albumName"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
Artwork struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
} `json:"artwork"`
|
|
||||||
} `json:"attributes"`
|
|
||||||
} `json:"songs"`
|
|
||||||
} `json:"resources"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PaxResponse represents the lyrics proxy response for word-by-word / line lyrics
|
|
||||||
type paxResponse struct {
|
type paxResponse struct {
|
||||||
Type string `json:"type"` // "Syllable" or "Line"
|
Type string `json:"type"` // "Syllable" or "Line"
|
||||||
Content []paxLyrics `json:"content"` // List of lyric lines
|
Content []paxLyrics `json:"content"` // List of lyric lines
|
||||||
@@ -149,32 +50,70 @@ func NewAppleMusicClient() *AppleMusicClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchSong searches for a song on Apple Music and returns its ID.
|
func selectBestAppleMusicSearchResult(results []appleMusicSearchResult, trackName, artistName string, durationSec float64) *appleMusicSearchResult {
|
||||||
func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, error) {
|
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]
|
||||||
|
}
|
||||||
|
|
||||||
|
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,28 +123,23 @@ 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.
|
|
||||||
func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
|
func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
|
||||||
lyricsURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/lyrics?id=%s", songID)
|
lyricsURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/lyrics?id=%s", songID)
|
||||||
|
|
||||||
@@ -313,14 +247,13 @@ func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByW
|
|||||||
return strings.TrimSpace(sb.String())
|
return strings.TrimSpace(sb.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchLyrics searches Apple Music and returns parsed LyricsResponse.
|
|
||||||
func (c *AppleMusicClient) FetchLyrics(
|
func (c *AppleMusicClient) FetchLyrics(
|
||||||
trackName,
|
trackName,
|
||||||
artistName string,
|
artistName string,
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -333,10 +266,8 @@ func (c *AppleMusicClient) FetchLyrics(
|
|||||||
return nil, fmt.Errorf("apple music proxy returned non-lyric payload: %s", errMsg)
|
return nil, fmt.Errorf("apple music proxy returned non-lyric payload: %s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to parse as pax format (word-by-word or line)
|
|
||||||
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
|
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If pax parsing fails, try to parse as direct LRC text
|
|
||||||
lrcText = rawLyrics
|
lrcText = rawLyrics
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,7 +281,6 @@ func (c *AppleMusicClient) FetchLyrics(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to plain text if no timestamps found
|
|
||||||
resultLines := plainTextLyricsLines(lrcText)
|
resultLines := plainTextLyricsLines(lrcText)
|
||||||
|
|
||||||
if len(resultLines) > 0 {
|
if len(resultLines) > 0 {
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MusixmatchClient fetches lyrics from Musixmatch via a proxy server.
|
|
||||||
// The proxy handles Musixmatch authentication internally.
|
|
||||||
type MusixmatchClient struct {
|
type MusixmatchClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
baseURL string
|
baseURL string
|
||||||
@@ -45,144 +45,143 @@ 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.
|
func (c *MusixmatchClient) FetchLyricsInLanguage(trackName, artistName string, durationSec float64, language string) (*LyricsResponse, error) {
|
||||||
func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string) (*LyricsResponse, error) {
|
|
||||||
lang := strings.ToLower(strings.TrimSpace(language))
|
lang := strings.ToLower(strings.TrimSpace(language))
|
||||||
if songID <= 0 || lang == "" {
|
if lang == "" {
|
||||||
return nil, fmt.Errorf("invalid song id or language")
|
return nil, fmt.Errorf("invalid language")
|
||||||
}
|
}
|
||||||
|
|
||||||
fullURL := fmt.Sprintf("%s/v2/full?id=%d&lang=%s", c.baseURL, songID, url.QueryEscape(lang))
|
lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "translate", lang)
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", fullURL, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, err
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("musixmatch language fetch failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return nil, fmt.Errorf("musixmatch language endpoint returned HTTP %d", resp.StatusCode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var result musixmatchSearchResponse
|
lines := parseSyncedLyrics(lrcText)
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
if len(lines) > 0 {
|
||||||
return nil, fmt.Errorf("failed to decode musixmatch language response: %w", err)
|
return &LyricsResponse{
|
||||||
|
Lines: lines,
|
||||||
|
SyncType: "LINE_SYNCED",
|
||||||
|
PlainLyrics: plainLyricsFromTimedLines(lines),
|
||||||
|
Provider: "Musixmatch",
|
||||||
|
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
plainLines := plainTextLyricsLines(lrcText)
|
||||||
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
if len(plainLines) > 0 {
|
||||||
if len(lines) > 0 {
|
return &LyricsResponse{
|
||||||
return &LyricsResponse{
|
Lines: plainLines,
|
||||||
Lines: lines,
|
SyncType: "UNSYNCED",
|
||||||
SyncType: "LINE_SYNCED",
|
PlainLyrics: lrcText,
|
||||||
Provider: "Musixmatch",
|
Provider: "Musixmatch",
|
||||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
|
||||||
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
|
|
||||||
|
|
||||||
if len(lines) > 0 {
|
|
||||||
return &LyricsResponse{
|
|
||||||
Lines: lines,
|
|
||||||
SyncType: "UNSYNCED",
|
|
||||||
PlainLyrics: result.UnsyncedLyrics.Lyrics,
|
|
||||||
Provider: "Musixmatch",
|
|
||||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("no lyrics found on musixmatch for language %s", lang)
|
return nil, fmt.Errorf("no lyrics found on musixmatch for language %s", lang)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchLyrics searches Musixmatch and returns parsed LyricsResponse.
|
|
||||||
func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec float64, preferredLanguage string) (*LyricsResponse, error) {
|
func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec float64, preferredLanguage string) (*LyricsResponse, error) {
|
||||||
result, err := c.searchAndGetLyrics(trackName, artistName)
|
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" {
|
||||||
if err != nil {
|
localized, localizedErr := c.FetchLyricsInLanguage(trackName, artistName, durationSec, preferred)
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" && result.ID > 0 {
|
|
||||||
localized, localizedErr := c.FetchLyricsInLanguage(result.ID, preferred)
|
|
||||||
if localizedErr == nil {
|
if localizedErr == nil {
|
||||||
return localized, nil
|
return localized, nil
|
||||||
}
|
}
|
||||||
GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr)
|
GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "word", "")
|
||||||
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
if err != nil {
|
||||||
if len(lines) > 0 {
|
return nil, err
|
||||||
return &LyricsResponse{
|
|
||||||
Lines: lines,
|
|
||||||
SyncType: "LINE_SYNCED",
|
|
||||||
Provider: "Musixmatch",
|
|
||||||
Source: "Musixmatch",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
lines := parseSyncedLyrics(lrcText)
|
||||||
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
|
if len(lines) > 0 {
|
||||||
|
return &LyricsResponse{
|
||||||
|
Lines: lines,
|
||||||
|
SyncType: "LINE_SYNCED",
|
||||||
|
PlainLyrics: plainLyricsFromTimedLines(lines),
|
||||||
|
Provider: "Musixmatch",
|
||||||
|
Source: "Musixmatch",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
if len(lines) > 0 {
|
plainLines := plainTextLyricsLines(lrcText)
|
||||||
return &LyricsResponse{
|
if len(plainLines) > 0 {
|
||||||
Lines: lines,
|
return &LyricsResponse{
|
||||||
SyncType: "UNSYNCED",
|
Lines: plainLines,
|
||||||
PlainLyrics: result.UnsyncedLyrics.Lyrics,
|
SyncType: "UNSYNCED",
|
||||||
Provider: "Musixmatch",
|
PlainLyrics: lrcText,
|
||||||
Source: "Musixmatch",
|
Provider: "Musixmatch",
|
||||||
}, nil
|
Source: "Musixmatch",
|
||||||
}
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("no lyrics found on musixmatch")
|
return nil, fmt.Errorf("no lyrics found on musixmatch")
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NeteaseClient fetches lyrics from NetEase Cloud Music (music.163.com).
|
|
||||||
// This is a direct public API — no proxy dependency.
|
|
||||||
type NeteaseClient struct {
|
type NeteaseClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
@@ -52,19 +50,15 @@ func NewNeteaseClient() *NeteaseClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchSong searches for a song on Netease and returns the song ID.
|
|
||||||
func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error) {
|
func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error) {
|
||||||
query := trackName + " " + artistName
|
query := trackName + " " + artistName
|
||||||
if strings.TrimSpace(query) == "" {
|
if strings.TrimSpace(query) == "" {
|
||||||
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()
|
||||||
|
|
||||||
@@ -100,14 +94,10 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
|
|||||||
return searchResp.Result.Songs[0].ID, nil
|
return searchResp.Result.Songs[0].ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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()
|
||||||
|
|
||||||
@@ -153,7 +143,6 @@ func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includ
|
|||||||
return lyric, nil
|
return lyric, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchLyrics searches for a track and returns parsed LyricsResponse.
|
|
||||||
func (c *NeteaseClient) FetchLyrics(
|
func (c *NeteaseClient) FetchLyrics(
|
||||||
trackName,
|
trackName,
|
||||||
artistName string,
|
artistName string,
|
||||||
@@ -173,7 +162,6 @@ func (c *NeteaseClient) FetchLyrics(
|
|||||||
|
|
||||||
lines := parseSyncedLyrics(lrcText)
|
lines := parseSyncedLyrics(lrcText)
|
||||||
if len(lines) == 0 {
|
if len(lines) == 0 {
|
||||||
// May be plain text lyrics without timestamps
|
|
||||||
plainLines := strings.Split(lrcText, "\n")
|
plainLines := strings.Split(lrcText, "\n")
|
||||||
for _, line := range plainLines {
|
for _, line := range plainLines {
|
||||||
trimmed := strings.TrimSpace(line)
|
trimmed := strings.TrimSpace(line)
|
||||||
|
|||||||
@@ -1,45 +1,29 @@
|
|||||||
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.
|
|
||||||
// Search uses public QQ Music API, lyrics use the paxsenix proxy.
|
|
||||||
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 +32,28 @@ func NewQQMusicClient() *QQMusicClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// searchSong searches QQ Music and returns the song info needed for lyrics fetch.
|
func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, durationSec float64) (string, error) {
|
||||||
func (c *QQMusicClient) searchSong(trackName, artistName string) (*qqLyricsPayload, error) {
|
payload := qqLyricsMetadataRequest{
|
||||||
query := trackName + " " + artistName
|
Artist: []string{artistName},
|
||||||
if strings.TrimSpace(query) == "" {
|
Title: trackName,
|
||||||
return nil, fmt.Errorf("empty search query")
|
}
|
||||||
|
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,19 +79,24 @@ func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string,
|
|||||||
return bodyStr, nil
|
return bodyStr, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchLyrics searches QQ Music and returns parsed LyricsResponse.
|
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
|
||||||
|
}
|
||||||
|
|
||||||
func (c *QQMusicClient) FetchLyrics(
|
func (c *QQMusicClient) FetchLyrics(
|
||||||
trackName,
|
trackName,
|
||||||
artistName string,
|
artistName string,
|
||||||
durationSec float64,
|
durationSec float64,
|
||||||
multiPersonWordByWord bool,
|
multiPersonWordByWord bool,
|
||||||
) (*LyricsResponse, error) {
|
) (*LyricsResponse, error) {
|
||||||
payload, err := c.searchSong(trackName, artistName)
|
rawLyrics, err := c.fetchLyricsByMetadata(trackName, artistName, durationSec)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
rawLyrics, err := c.fetchLyricsByPayload(payload)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -166,11 +104,13 @@ func (c *QQMusicClient) FetchLyrics(
|
|||||||
return nil, fmt.Errorf("qqmusic proxy returned non-lyric payload: %s", errMsg)
|
return nil, fmt.Errorf("qqmusic proxy returned non-lyric payload: %s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to parse as pax format (word-by-word or line)
|
lrcText, err := formatQQLyricsMetadataToLRC(rawLyrics, multiPersonWordByWord)
|
||||||
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If pax parsing fails, try to use as direct LRC text
|
if fallback, fallbackErr := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord); fallbackErr == nil {
|
||||||
lrcText = rawLyrics
|
lrcText = fallback
|
||||||
|
} else {
|
||||||
|
lrcText = rawLyrics
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lines := parseSyncedLyrics(lrcText)
|
lines := parseSyncedLyrics(lrcText)
|
||||||
|
|||||||
+724
-216
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-flac/flacvorbis/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSplitArtistTagValues(t *testing.T) {
|
||||||
|
got := splitArtistTagValues("Artist A, Artist B feat. Artist C & Artist B")
|
||||||
|
want := []string{"Artist A", "Artist B", "Artist C"}
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("splitArtistTagValues() = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetArtistCommentsSplitVorbis(t *testing.T) {
|
||||||
|
cmt := flacvorbis.New()
|
||||||
|
setArtistComments(cmt, "ARTIST", "Artist A, Artist B", artistTagModeSplitVorbis)
|
||||||
|
|
||||||
|
got := getCommentValues(cmt, "ARTIST")
|
||||||
|
want := []string{"Artist A", "Artist B"}
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("getCommentValues(ARTIST) = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseVorbisCommentsJoinsRepeatedArtists(t *testing.T) {
|
||||||
|
metadata := &AudioMetadata{}
|
||||||
|
parseVorbisComments(
|
||||||
|
buildVorbisCommentPayload(
|
||||||
|
[]string{
|
||||||
|
"TITLE=Song",
|
||||||
|
"ARTIST=Artist A",
|
||||||
|
"ARTIST=Artist B",
|
||||||
|
"ALBUMARTIST=Album Artist A",
|
||||||
|
"ALBUMARTIST=Album Artist B",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
if metadata.Title != "Song" {
|
||||||
|
t.Fatalf("title = %q", metadata.Title)
|
||||||
|
}
|
||||||
|
if metadata.Artist != "Artist A, Artist B" {
|
||||||
|
t.Fatalf("artist = %q", metadata.Artist)
|
||||||
|
}
|
||||||
|
if metadata.AlbumArtist != "Album Artist A, Album Artist B" {
|
||||||
|
t.Fatalf("album artist = %q", metadata.AlbumArtist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildVorbisCommentPayload(comments []string) []byte {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_ = binary.Write(&buf, binary.LittleEndian, uint32(len("spotiflac")))
|
||||||
|
buf.WriteString("spotiflac")
|
||||||
|
_ = binary.Write(&buf, binary.LittleEndian, uint32(len(comments)))
|
||||||
|
for _, comment := range comments {
|
||||||
|
_ = binary.Write(&buf, binary.LittleEndian, uint32(len(comment)))
|
||||||
|
buf.WriteString(comment)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type cacheEntry struct {
|
||||||
|
data interface{}
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *cacheEntry) isExpired() bool {
|
||||||
|
return time.Now().After(e.expiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrackMetadata struct {
|
||||||
|
SpotifyID string `json:"spotify_id,omitempty"`
|
||||||
|
Artists string `json:"artists"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AlbumName string `json:"album_name"`
|
||||||
|
AlbumArtist string `json:"album_artist,omitempty"`
|
||||||
|
DurationMS int `json:"duration_ms"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
TrackNumber int `json:"track_number"`
|
||||||
|
TotalTracks int `json:"total_tracks,omitempty"`
|
||||||
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
|
TotalDiscs int `json:"total_discs,omitempty"`
|
||||||
|
ExternalURL string `json:"external_urls"`
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
AlbumID string `json:"album_id,omitempty"`
|
||||||
|
ArtistID string `json:"artist_id,omitempty"`
|
||||||
|
AlbumType string `json:"album_type,omitempty"`
|
||||||
|
Composer string `json:"composer,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlbumTrackMetadata struct {
|
||||||
|
SpotifyID string `json:"spotify_id,omitempty"`
|
||||||
|
Artists string `json:"artists"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AlbumName string `json:"album_name"`
|
||||||
|
AlbumArtist string `json:"album_artist,omitempty"`
|
||||||
|
DurationMS int `json:"duration_ms"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
TrackNumber int `json:"track_number"`
|
||||||
|
TotalTracks int `json:"total_tracks,omitempty"`
|
||||||
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
|
TotalDiscs int `json:"total_discs,omitempty"`
|
||||||
|
ExternalURL string `json:"external_urls"`
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
AlbumID string `json:"album_id,omitempty"`
|
||||||
|
AlbumURL string `json:"album_url,omitempty"`
|
||||||
|
AlbumType string `json:"album_type,omitempty"`
|
||||||
|
Composer string `json:"composer,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlbumInfoMetadata struct {
|
||||||
|
TotalTracks int `json:"total_tracks"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
Artists string `json:"artists"`
|
||||||
|
ArtistId string `json:"artist_id,omitempty"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
Genre string `json:"genre,omitempty"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
Copyright string `json:"copyright,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlbumResponsePayload struct {
|
||||||
|
AlbumInfo AlbumInfoMetadata `json:"album_info"`
|
||||||
|
TrackList []AlbumTrackMetadata `json:"track_list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlaylistInfoMetadata struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Images string `json:"images,omitempty"`
|
||||||
|
Tracks struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
} `json:"tracks"`
|
||||||
|
Owner struct {
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
} `json:"owner"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlaylistResponsePayload struct {
|
||||||
|
PlaylistInfo PlaylistInfoMetadata `json:"playlist_info"`
|
||||||
|
TrackList []AlbumTrackMetadata `json:"track_list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArtistInfoMetadata struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
Followers int `json:"followers"`
|
||||||
|
Popularity int `json:"popularity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArtistAlbumMetadata struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
TotalTracks int `json:"total_tracks"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
AlbumType string `json:"album_type"`
|
||||||
|
Artists string `json:"artists"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArtistResponsePayload struct {
|
||||||
|
ArtistInfo ArtistInfoMetadata `json:"artist_info"`
|
||||||
|
Albums []ArtistAlbumMetadata `json:"albums"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrackResponse struct {
|
||||||
|
Track TrackMetadata `json:"track"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchArtistResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
Followers int `json:"followers"`
|
||||||
|
Popularity int `json:"popularity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchAlbumResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Artists string `json:"artists"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
TotalTracks int `json:"total_tracks"`
|
||||||
|
AlbumType string `json:"album_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchPlaylistResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
TotalTracks int `json:"total_tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchAllResult struct {
|
||||||
|
Tracks []TrackMetadata `json:"tracks"`
|
||||||
|
Artists []SearchArtistResult `json:"artists"`
|
||||||
|
Albums []SearchAlbumResult `json:"albums"`
|
||||||
|
Playlists []SearchPlaylistResult `json:"playlists"`
|
||||||
|
}
|
||||||
+716
-77
File diff suppressed because it is too large
Load Diff
+153
-11
@@ -1,6 +1,24 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildTestQobuzAlbum(id, title, artist string, tracks ...QobuzTrack) *qobuzAlbumDetails {
|
||||||
|
album := &qobuzAlbumDetails{
|
||||||
|
ID: id,
|
||||||
|
Title: title,
|
||||||
|
ReleaseDateOriginal: "2013-05-20",
|
||||||
|
TracksCount: len(tracks),
|
||||||
|
ProductType: "album",
|
||||||
|
ReleaseType: "album",
|
||||||
|
}
|
||||||
|
album.Artist = qobuzArtistRef{ID: 1, Name: artist}
|
||||||
|
album.Artists = []qobuzArtistRef{{ID: 1, Name: artist}}
|
||||||
|
album.Tracks.Items = tracks
|
||||||
|
return album
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseQobuzURL(t *testing.T) {
|
func TestParseQobuzURL(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -183,15 +201,25 @@ func TestNormalizeQobuzQualityCode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetQobuzDebugKey(t *testing.T) {
|
func TestBuildQobuzMusicDLPayloadUsesOpenTrackURL(t *testing.T) {
|
||||||
got := getQobuzDebugKey()
|
payloadBytes, err := buildQobuzMusicDLPayload(374610875, "7")
|
||||||
if len(got) != len(qobuzDebugKeyObfuscated) {
|
if err != nil {
|
||||||
t.Fatalf("unexpected debug key length: %d", len(got))
|
t.Fatalf("buildQobuzMusicDLPayload returned error: %v", err)
|
||||||
}
|
}
|
||||||
for i := range got {
|
|
||||||
if got[i]^qobuzDebugKeyXORMask != qobuzDebugKeyObfuscated[i] {
|
var payload map[string]any
|
||||||
t.Fatalf("unexpected debug key reconstruction at index %d", i)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,12 +241,13 @@ func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) {
|
|||||||
|
|
||||||
func TestQobuzAvailableProviders(t *testing.T) {
|
func TestQobuzAvailableProviders(t *testing.T) {
|
||||||
providers := NewQobuzDownloader().GetAvailableProviders()
|
providers := NewQobuzDownloader().GetAvailableProviders()
|
||||||
if len(providers) != 5 {
|
if len(providers) != 6 {
|
||||||
t.Fatalf("expected 5 Qobuz providers, got %d", len(providers))
|
t.Fatalf("expected 6 Qobuz providers, got %d", len(providers))
|
||||||
}
|
}
|
||||||
|
|
||||||
want := map[string]string{
|
want := map[string]string{
|
||||||
"musicdl": qobuzAPIKindMusicDL,
|
"musicdl": qobuzAPIKindMusicDL,
|
||||||
|
"zarz": qobuzAPIKindMusicDL,
|
||||||
"dabmusic": qobuzAPIKindStandard,
|
"dabmusic": qobuzAPIKindStandard,
|
||||||
"deeb": qobuzAPIKindStandard,
|
"deeb": qobuzAPIKindStandard,
|
||||||
"qbz": qobuzAPIKindStandard,
|
"qbz": qobuzAPIKindStandard,
|
||||||
@@ -251,6 +280,68 @@ func testQobuzTrack(id int64, title, artist string, duration int) *QobuzTrack {
|
|||||||
return track
|
return track
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSelectQobuzTracksFromAlbumSearchResultsPrefersMatchingTrack(t *testing.T) {
|
||||||
|
summaries := []qobuzAlbumDetails{
|
||||||
|
{ID: "album-a"},
|
||||||
|
{ID: "album-b"},
|
||||||
|
}
|
||||||
|
|
||||||
|
match := *testQobuzTrack(1, "Get Lucky", "Daft Punk", 369)
|
||||||
|
other := *testQobuzTrack(2, "Fragments of Time", "Daft Punk", 280)
|
||||||
|
fallback := *testQobuzTrack(3, "Da Funk", "Daft Punk", 330)
|
||||||
|
|
||||||
|
albums := map[string]*qobuzAlbumDetails{
|
||||||
|
"album-a": buildTestQobuzAlbum("album-a", "Random Access Memories", "Daft Punk", match, other),
|
||||||
|
"album-b": buildTestQobuzAlbum("album-b", "Homework", "Daft Punk", fallback),
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks, err := selectQobuzTracksFromAlbumSearchResults(
|
||||||
|
"daft punk get lucky",
|
||||||
|
3,
|
||||||
|
summaries,
|
||||||
|
func(id string) (*qobuzAlbumDetails, error) { return albums[id], nil },
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(tracks) == 0 {
|
||||||
|
t.Fatal("expected tracks, got none")
|
||||||
|
}
|
||||||
|
if tracks[0].ID != 1 {
|
||||||
|
t.Fatalf("expected Get Lucky to rank first, got track id %d", tracks[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectQobuzTracksFromAlbumSearchResultsDedupesTracks(t *testing.T) {
|
||||||
|
summaries := []qobuzAlbumDetails{
|
||||||
|
{ID: "album-a"},
|
||||||
|
{ID: "album-b"},
|
||||||
|
}
|
||||||
|
|
||||||
|
shared := *testQobuzTrack(42, "Get Lucky", "Daft Punk", 369)
|
||||||
|
|
||||||
|
albums := map[string]*qobuzAlbumDetails{
|
||||||
|
"album-a": buildTestQobuzAlbum("album-a", "Random Access Memories", "Daft Punk", shared),
|
||||||
|
"album-b": buildTestQobuzAlbum("album-b", "Random Access Memories Deluxe", "Daft Punk", shared),
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks, err := selectQobuzTracksFromAlbumSearchResults(
|
||||||
|
"daft punk get lucky",
|
||||||
|
5,
|
||||||
|
summaries,
|
||||||
|
func(id string) (*qobuzAlbumDetails, error) { return albums[id], nil },
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(tracks) != 1 {
|
||||||
|
t.Fatalf("expected 1 deduped track, got %d", len(tracks))
|
||||||
|
}
|
||||||
|
if tracks[0].ID != 42 {
|
||||||
|
t.Fatalf("unexpected deduped track id: %d", tracks[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveQobuzTrackForRequestRejectsSongLinkMismatch(t *testing.T) {
|
func TestResolveQobuzTrackForRequestRejectsSongLinkMismatch(t *testing.T) {
|
||||||
origGetTrackByID := qobuzGetTrackByIDFunc
|
origGetTrackByID := qobuzGetTrackByIDFunc
|
||||||
origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
|
origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
|
||||||
@@ -411,3 +502,54 @@ func TestResolveQobuzTrackForRequestUsesPrefixedQobuzIDWithoutSongLink(t *testin
|
|||||||
t.Fatalf("unexpected resolved track: %+v", track)
|
t.Fatalf("unexpected resolved track: %+v", track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestQobuzTrackMatchesRequest_SongLinkBypassesArtistAndTitle(t *testing.T) {
|
||||||
|
req := DownloadRequest{
|
||||||
|
TrackName: "Ringišpil",
|
||||||
|
ArtistName: "Djordje Balasevic",
|
||||||
|
}
|
||||||
|
|
||||||
|
track := &QobuzTrack{
|
||||||
|
Title: "Different Title",
|
||||||
|
Duration: 0,
|
||||||
|
}
|
||||||
|
track.Performer.Name = "Different Artist"
|
||||||
|
|
||||||
|
if !qobuzTrackMatchesRequest(req, track, "Qobuz", "SongLink Qobuz ID", true) {
|
||||||
|
t.Fatal("expected SongLink Qobuz source to bypass artist/title verification")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQobuzTrackMetadataIncludesComposer(t *testing.T) {
|
||||||
|
track := &QobuzTrack{
|
||||||
|
ID: 40681594,
|
||||||
|
Title: "Sign of the Times",
|
||||||
|
ISRC: "USSM11703595",
|
||||||
|
Duration: 340,
|
||||||
|
TrackNumber: 1,
|
||||||
|
MediaNumber: 1,
|
||||||
|
}
|
||||||
|
track.Performer.ID = 729886
|
||||||
|
track.Performer.Name = "Harry Styles"
|
||||||
|
track.Composer.ID = 729886
|
||||||
|
track.Composer.Name = "Harry Styles"
|
||||||
|
track.Album.ID = "0886446451985"
|
||||||
|
track.Album.Title = "Harry Styles"
|
||||||
|
track.Album.ReleaseDate = "2017-05-12"
|
||||||
|
track.Album.TracksCount = 10
|
||||||
|
track.Album.ReleaseType = "album"
|
||||||
|
track.Album.ProductType = "album"
|
||||||
|
track.Album.Artist.ID = 729886
|
||||||
|
track.Album.Artist.Name = "Harry Styles"
|
||||||
|
track.Album.Artists = []qobuzArtistRef{{ID: 729886, Name: "Harry Styles"}}
|
||||||
|
|
||||||
|
trackMeta := qobuzTrackToTrackMetadata(track)
|
||||||
|
if trackMeta.Composer != "Harry Styles" {
|
||||||
|
t.Fatalf("track composer = %q", trackMeta.Composer)
|
||||||
|
}
|
||||||
|
|
||||||
|
albumTrackMeta := qobuzTrackToAlbumTrackMetadata(track)
|
||||||
|
if albumTrackMeta.Composer != "Harry Styles" {
|
||||||
|
t.Fatalf("album track composer = %q", albumTrackMeta.Composer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+4
-17
@@ -16,16 +16,13 @@ var hiraganaToRomaji = map[rune]string{
|
|||||||
'や': "ya", 'ゆ': "yu", 'よ': "yo",
|
'や': "ya", 'ゆ': "yu", 'よ': "yo",
|
||||||
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
|
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
|
||||||
'わ': "wa", 'を': "wo", 'ん': "n",
|
'わ': "wa", 'を': "wo", 'ん': "n",
|
||||||
// Dakuten (voiced)
|
|
||||||
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
|
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
|
||||||
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
|
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
|
||||||
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
|
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
|
||||||
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
|
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
|
||||||
// Handakuten (semi-voiced)
|
|
||||||
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
|
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
|
||||||
// Small characters
|
|
||||||
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
|
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
|
||||||
'っ': "", // Double consonant marker
|
'っ': "",
|
||||||
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,19 +37,15 @@ var katakanaToRomaji = map[rune]string{
|
|||||||
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
|
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
|
||||||
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
|
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
|
||||||
'ワ': "wa", 'ヲ': "wo", 'ン': "n",
|
'ワ': "wa", 'ヲ': "wo", 'ン': "n",
|
||||||
// Dakuten (voiced)
|
|
||||||
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
|
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
|
||||||
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
|
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
|
||||||
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
|
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
|
||||||
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
|
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
|
||||||
// Handakuten (semi-voiced)
|
|
||||||
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
|
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
|
||||||
// Small characters
|
|
||||||
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
|
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
|
||||||
'ッ': "", // Double consonant marker
|
'ッ': "",
|
||||||
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
|
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
|
||||||
// Extended katakana
|
'ー': "",
|
||||||
'ー': "", // Long vowel mark
|
|
||||||
'ヴ': "vu",
|
'ヴ': "vu",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +75,6 @@ var combinationKatakana = map[string]string{
|
|||||||
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
|
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
|
||||||
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
|
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
|
||||||
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
|
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
|
||||||
// Extended combinations
|
|
||||||
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
|
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
|
||||||
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
||||||
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
||||||
@@ -120,7 +112,6 @@ func JapaneseToRomaji(text string) string {
|
|||||||
i := 0
|
i := 0
|
||||||
|
|
||||||
for i < len(runes) {
|
for i < len(runes) {
|
||||||
// Check for っ/ッ (double consonant)
|
|
||||||
if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') {
|
if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') {
|
||||||
nextRomaji := ""
|
nextRomaji := ""
|
||||||
if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok {
|
if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok {
|
||||||
@@ -129,13 +120,12 @@ func JapaneseToRomaji(text string) string {
|
|||||||
nextRomaji = romaji
|
nextRomaji = romaji
|
||||||
}
|
}
|
||||||
if len(nextRomaji) > 0 {
|
if len(nextRomaji) > 0 {
|
||||||
result.WriteByte(nextRomaji[0]) // Double the first consonant
|
result.WriteByte(nextRomaji[0])
|
||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for two-character combinations
|
|
||||||
if i < len(runes)-1 {
|
if i < len(runes)-1 {
|
||||||
combo := string(runes[i : i+2])
|
combo := string(runes[i : i+2])
|
||||||
if romaji, ok := combinationHiragana[combo]; ok {
|
if romaji, ok := combinationHiragana[combo]; ok {
|
||||||
@@ -150,17 +140,14 @@ func JapaneseToRomaji(text string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single character conversion
|
|
||||||
r := runes[i]
|
r := runes[i]
|
||||||
if romaji, ok := hiraganaToRomaji[r]; ok {
|
if romaji, ok := hiraganaToRomaji[r]; ok {
|
||||||
result.WriteString(romaji)
|
result.WriteString(romaji)
|
||||||
} else if romaji, ok := katakanaToRomaji[r]; ok {
|
} else if romaji, ok := katakanaToRomaji[r]; ok {
|
||||||
result.WriteString(romaji)
|
result.WriteString(romaji)
|
||||||
} else if isKanji(r) {
|
} else if isKanji(r) {
|
||||||
// Keep kanji as-is (would need dictionary for proper conversion)
|
|
||||||
result.WriteRune(r)
|
result.WriteRune(r)
|
||||||
} else {
|
} else {
|
||||||
// Keep other characters (punctuation, spaces, etc.)
|
|
||||||
result.WriteRune(r)
|
result.WriteRune(r)
|
||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+212
-444
@@ -87,38 +87,210 @@ func GetSongLinkRegion() string {
|
|||||||
return region
|
return region
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolveAPIURL = "https://api.zarz.moe/v1/resolve"
|
||||||
|
|
||||||
func songLinkBaseURL() string {
|
func songLinkBaseURL() string {
|
||||||
opts := GetNetworkCompatibilityOptions()
|
|
||||||
if opts.AllowHTTP {
|
|
||||||
return "http://api.song.link/v1-alpha.1/links"
|
|
||||||
}
|
|
||||||
return "https://api.song.link/v1-alpha.1/links"
|
return "https://api.song.link/v1-alpha.1/links"
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildSongLinkURLFromTarget(targetURL string, userCountry string) string {
|
// resolveTrackPlatforms resolves a music URL to all platforms.
|
||||||
if userCountry == "" {
|
// Spotify URLs use the resolve API; if that fails, falls back to SongLink.
|
||||||
userCountry = GetSongLinkRegion()
|
// All other URLs go directly to SongLink.
|
||||||
|
func (s *SongLinkClient) resolveTrackPlatforms(inputURL string) (map[string]songLinkPlatformLink, error) {
|
||||||
|
if isSpotifyURL(inputURL) {
|
||||||
|
payload, err := json.Marshal(map[string]string{"url": inputURL})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encode resolve request: %w", err)
|
||||||
|
}
|
||||||
|
links, err := s.doResolveRequest(payload)
|
||||||
|
if err == nil {
|
||||||
|
return links, nil
|
||||||
|
}
|
||||||
|
GoLog("[SongLink] Resolve proxy failed for %s: %v, falling back to SongLink", inputURL, err)
|
||||||
|
return s.songLinkByTargetURL(inputURL)
|
||||||
}
|
}
|
||||||
apiURL := fmt.Sprintf("%s?url=%s", songLinkBaseURL(), url.QueryEscape(targetURL))
|
return s.songLinkByTargetURL(inputURL)
|
||||||
if userCountry != "" {
|
|
||||||
apiURL = fmt.Sprintf("%s&userCountry=%s", apiURL, url.QueryEscape(userCountry))
|
|
||||||
}
|
|
||||||
return apiURL
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildSongLinkURLByPlatform(platform, entityType, entityID, userCountry string) string {
|
// resolveTrackPlatformsByPlatform resolves using platform + type + id.
|
||||||
if userCountry == "" {
|
// Spotify uses the resolve API with SongLink fallback; all other platforms use SongLink directly.
|
||||||
userCountry = GetSongLinkRegion()
|
func (s *SongLinkClient) resolveTrackPlatformsByPlatform(platform, entityType, entityID string) (map[string]songLinkPlatformLink, error) {
|
||||||
|
if strings.EqualFold(platform, "spotify") {
|
||||||
|
payload, err := json.Marshal(map[string]string{
|
||||||
|
"platform": platform,
|
||||||
|
"type": entityType,
|
||||||
|
"id": entityID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encode resolve request: %w", err)
|
||||||
|
}
|
||||||
|
links, err := s.doResolveRequest(payload)
|
||||||
|
if err == nil {
|
||||||
|
return links, nil
|
||||||
|
}
|
||||||
|
GoLog("[SongLink] Resolve proxy failed for %s/%s/%s: %v, falling back to SongLink", platform, entityType, entityID, err)
|
||||||
|
return s.songLinkByPlatform(platform, entityType, entityID)
|
||||||
}
|
}
|
||||||
apiURL := fmt.Sprintf("%s?platform=%s&type=%s&id=%s",
|
return s.songLinkByPlatform(platform, entityType, entityID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSpotifyURL(u string) bool {
|
||||||
|
lower := strings.ToLower(u)
|
||||||
|
return strings.Contains(lower, "spotify.com/") || strings.Contains(lower, "spotify:")
|
||||||
|
}
|
||||||
|
|
||||||
|
// doResolveRequest sends a JSON payload to the resolve API (api.zarz.moe)
|
||||||
|
// and parses the response into a platform link map.
|
||||||
|
func (s *SongLinkClient) doResolveRequest(payload []byte) (map[string]songLinkPlatformLink, error) {
|
||||||
|
req, err := http.NewRequest("POST", resolveAPIURL, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create resolve request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve API request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("resolve API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := ReadResponseBody(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read resolve response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolveResp struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
SongUrls map[string]json.RawMessage `json:"songUrls"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &resolveResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode resolve response: %w", err)
|
||||||
|
}
|
||||||
|
if !resolveResp.Success {
|
||||||
|
return nil, fmt.Errorf("resolve API returned success=false")
|
||||||
|
}
|
||||||
|
|
||||||
|
keyMap := map[string]string{
|
||||||
|
"Spotify": "spotify",
|
||||||
|
"Deezer": "deezer",
|
||||||
|
"Tidal": "tidal",
|
||||||
|
"YouTubeMusic": "youtubeMusic",
|
||||||
|
"YouTube": "youtube",
|
||||||
|
"AmazonMusic": "amazonMusic",
|
||||||
|
"Qobuz": "qobuz",
|
||||||
|
"AppleMusic": "appleMusic",
|
||||||
|
}
|
||||||
|
|
||||||
|
links := make(map[string]songLinkPlatformLink)
|
||||||
|
for resolveKey, platformKey := range keyMap {
|
||||||
|
rawValue, ok := resolveResp.SongUrls[resolveKey]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if u := extractResolveURLValue(rawValue); u != "" {
|
||||||
|
links[platformKey] = songLinkPlatformLink{URL: u}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(links) == 0 {
|
||||||
|
return nil, fmt.Errorf("resolve API returned no platform links")
|
||||||
|
}
|
||||||
|
|
||||||
|
return links, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractResolveURLValue(raw json.RawMessage) string {
|
||||||
|
trimmed := bytes.TrimSpace(raw)
|
||||||
|
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var direct string
|
||||||
|
if err := json.Unmarshal(trimmed, &direct); err == nil {
|
||||||
|
return strings.TrimSpace(direct)
|
||||||
|
}
|
||||||
|
|
||||||
|
var list []string
|
||||||
|
if err := json.Unmarshal(trimmed, &list); err == nil {
|
||||||
|
for _, candidate := range list {
|
||||||
|
if cleaned := strings.TrimSpace(candidate); cleaned != "" {
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// songLinkByTargetURL calls the SongLink API with a target URL (for non-Spotify URLs).
|
||||||
|
func (s *SongLinkClient) songLinkByTargetURL(targetURL string) (map[string]songLinkPlatformLink, error) {
|
||||||
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("%s?url=%s&userCountry=%s",
|
||||||
|
songLinkBaseURL(),
|
||||||
|
url.QueryEscape(targetURL),
|
||||||
|
url.QueryEscape(GetSongLinkRegion()))
|
||||||
|
|
||||||
|
return s.doSongLinkRequest(apiURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// songLinkByPlatform calls the SongLink API with platform + type + id (for non-Spotify platforms).
|
||||||
|
func (s *SongLinkClient) songLinkByPlatform(platform, entityType, entityID string) (map[string]songLinkPlatformLink, error) {
|
||||||
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("%s?platform=%s&type=%s&id=%s&userCountry=%s",
|
||||||
songLinkBaseURL(),
|
songLinkBaseURL(),
|
||||||
url.QueryEscape(platform),
|
url.QueryEscape(platform),
|
||||||
url.QueryEscape(entityType),
|
url.QueryEscape(entityType),
|
||||||
url.QueryEscape(entityID))
|
url.QueryEscape(entityID),
|
||||||
if userCountry != "" {
|
url.QueryEscape(GetSongLinkRegion()))
|
||||||
apiURL = fmt.Sprintf("%s&userCountry=%s", apiURL, url.QueryEscape(userCountry))
|
|
||||||
|
return s.doSongLinkRequest(apiURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// doSongLinkRequest calls the SongLink API and parses the response.
|
||||||
|
func (s *SongLinkClient) doSongLinkRequest(apiURL string) (map[string]songLinkPlatformLink, error) {
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create SongLink request: %w", err)
|
||||||
}
|
}
|
||||||
return apiURL
|
|
||||||
|
retryConfig := songLinkRetryConfig()
|
||||||
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("SongLink request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == 429 {
|
||||||
|
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("SongLink returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := ReadResponseBody(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read SongLink response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var songLinkResp struct {
|
||||||
|
LinksByPlatform map[string]songLinkPlatformLink `json:"linksByPlatform"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode SongLink response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(songLinkResp.LinksByPlatform) == 0 {
|
||||||
|
return nil, fmt.Errorf("SongLink returned no platform links")
|
||||||
|
}
|
||||||
|
|
||||||
|
return songLinkResp.LinksByPlatform, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||||
@@ -136,145 +308,12 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
|
func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
|
||||||
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, "")
|
links, err := s.resolveTrackPlatforms(spotifyURL)
|
||||||
|
|
||||||
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("resolve proxy failed for Spotify %s: %w", spotifyTrackID, err)
|
||||||
}
|
}
|
||||||
|
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, links), nil
|
||||||
retryConfig := songLinkRetryConfig()
|
|
||||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API lookup failed: %w", pageErr, err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode == 400 {
|
|
||||||
return nil, fmt.Errorf("track not found on SongLink (invalid Spotify ID or track unavailable)")
|
|
||||||
}
|
|
||||||
if resp.StatusCode == 404 {
|
|
||||||
return nil, fmt.Errorf("track not found on any streaming platform")
|
|
||||||
}
|
|
||||||
if resp.StatusCode == 429 {
|
|
||||||
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API rate limit exceeded", pageErr)
|
|
||||||
}
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API returned status %d", pageErr, resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := ReadResponseBody(resp)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var songLinkResp struct {
|
|
||||||
LinksByPlatform map[string]songLinkPlatformLink `json:"linksByPlatform"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
LogWarn("SongLink", "Spotify %s resolved via SongLink API after song.link page failure: %v", spotifyTrackID, pageErr)
|
|
||||||
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, songLinkResp.LinksByPlatform), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SongLinkClient) checkTrackAvailabilityFromSpotifyPage(spotifyTrackID string) (*TrackAvailability, error) {
|
|
||||||
pageURL := fmt.Sprintf("https://song.link/s/%s", spotifyTrackID)
|
|
||||||
req, err := http.NewRequest("GET", pageURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create song.link page request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
|
|
||||||
resp, err := s.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to fetch song.link page: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode == 404 {
|
|
||||||
return nil, fmt.Errorf("track not found on song.link page")
|
|
||||||
}
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return nil, fmt.Errorf("song.link page returned status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := ReadResponseBody(resp)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read song.link page: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
nextDataJSON, err := extractSongLinkNextDataJSON(body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var pageData struct {
|
|
||||||
Props struct {
|
|
||||||
PageProps struct {
|
|
||||||
PageData struct {
|
|
||||||
Sections []struct {
|
|
||||||
Links []struct {
|
|
||||||
Platform string `json:"platform"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
Show bool `json:"show"`
|
|
||||||
} `json:"links"`
|
|
||||||
} `json:"sections"`
|
|
||||||
} `json:"pageData"`
|
|
||||||
} `json:"pageProps"`
|
|
||||||
} `json:"props"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(nextDataJSON, &pageData); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode song.link page data: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
linksByPlatform := make(map[string]songLinkPlatformLink)
|
|
||||||
for _, section := range pageData.Props.PageProps.PageData.Sections {
|
|
||||||
for _, link := range section.Links {
|
|
||||||
if !link.Show || strings.TrimSpace(link.URL) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
linksByPlatform[link.Platform] = songLinkPlatformLink{URL: link.URL}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
||||||
@@ -469,8 +508,6 @@ func extractYouTubeIDFromURL(youtubeURL string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// isNumeric is defined in library_scan.go
|
|
||||||
|
|
||||||
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
|
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
|
||||||
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -505,47 +542,17 @@ type AlbumAvailability struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
|
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
|
||||||
songLinkRateLimiter.WaitForSlot()
|
|
||||||
|
|
||||||
spotifyURL := fmt.Sprintf("https://open.spotify.com/album/%s", spotifyAlbumID)
|
spotifyURL := fmt.Sprintf("https://open.spotify.com/album/%s", spotifyAlbumID)
|
||||||
apiURL := buildSongLinkURLFromTarget(spotifyURL, "")
|
links, err := s.resolveTrackPlatforms(spotifyURL)
|
||||||
|
|
||||||
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("resolve proxy failed for album %s: %w", spotifyAlbumID, err)
|
||||||
}
|
|
||||||
|
|
||||||
retryConfig := songLinkRetryConfig()
|
|
||||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to check album availability: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := ReadResponseBody(resp)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var songLinkResp struct {
|
|
||||||
LinksByPlatform map[string]struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
} `json:"linksByPlatform"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
availability := &AlbumAvailability{
|
availability := &AlbumAvailability{
|
||||||
SpotifyID: spotifyAlbumID,
|
SpotifyID: spotifyAlbumID,
|
||||||
}
|
}
|
||||||
|
|
||||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
if deezerLink, ok := links["deezer"]; ok && deezerLink.URL != "" {
|
||||||
availability.Deezer = true
|
availability.Deezer = true
|
||||||
availability.DeezerURL = deezerLink.URL
|
availability.DeezerURL = deezerLink.URL
|
||||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||||
@@ -588,101 +595,19 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID string) (*TrackAvailability, error) {
|
func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID string) (*TrackAvailability, error) {
|
||||||
songLinkRateLimiter.WaitForSlot()
|
|
||||||
|
|
||||||
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
||||||
apiURL := buildSongLinkURLFromTarget(deezerURL, "")
|
links, err := s.resolveTrackPlatforms(deezerURL)
|
||||||
|
|
||||||
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("resolve failed for Deezer %s: %w", deezerTrackID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
retryConfig := songLinkRetryConfig()
|
availability := buildTrackAvailabilityFromSongLinkLinks("", links)
|
||||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
// Ensure Deezer is always marked available since we started from a Deezer URL
|
||||||
if err != nil {
|
availability.Deezer = true
|
||||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
availability.DeezerID = deezerTrackID
|
||||||
|
if availability.DeezerURL == "" {
|
||||||
|
availability.DeezerURL = deezerURL
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode == 400 {
|
|
||||||
return nil, fmt.Errorf("track not found on SongLink (invalid Deezer ID)")
|
|
||||||
}
|
|
||||||
if resp.StatusCode == 404 {
|
|
||||||
return nil, fmt.Errorf("track not found on any streaming platform")
|
|
||||||
}
|
|
||||||
if resp.StatusCode == 429 {
|
|
||||||
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
|
||||||
}
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := ReadResponseBody(resp)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var songLinkResp struct {
|
|
||||||
LinksByPlatform map[string]struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
} `json:"linksByPlatform"`
|
|
||||||
EntitiesByUniqueId map[string]struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
ArtistName string `json:"artistName"`
|
|
||||||
} `json:"entitiesByUniqueId"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
availability := &TrackAvailability{
|
|
||||||
Deezer: true,
|
|
||||||
DeezerID: deezerTrackID,
|
|
||||||
}
|
|
||||||
|
|
||||||
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
|
||||||
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
|
||||||
availability.Tidal = true
|
|
||||||
availability.TidalURL = tidalLink.URL
|
|
||||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
|
||||||
}
|
|
||||||
|
|
||||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
|
||||||
availability.Amazon = true
|
|
||||||
availability.AmazonURL = amazonLink.URL
|
|
||||||
}
|
|
||||||
|
|
||||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
|
||||||
availability.Qobuz = true
|
|
||||||
availability.QobuzURL = qobuzLink.URL
|
|
||||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
|
||||||
}
|
|
||||||
|
|
||||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
|
||||||
availability.DeezerURL = deezerLink.URL
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefer youtubeMusic URLs — they are usually closer to music catalog matches.
|
|
||||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
|
||||||
availability.YouTube = true
|
|
||||||
availability.YouTubeURL = ytMusicLink.URL
|
|
||||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
|
||||||
}
|
|
||||||
if !availability.YouTube {
|
|
||||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
|
||||||
availability.YouTube = true
|
|
||||||
availability.YouTubeURL = youtubeLink.URL
|
|
||||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return availability, nil
|
return availability, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -694,94 +619,12 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
|||||||
return nil, fmt.Errorf("%s ID is empty", platform)
|
return nil, fmt.Errorf("%s ID is empty", platform)
|
||||||
}
|
}
|
||||||
|
|
||||||
songLinkRateLimiter.WaitForSlot()
|
links, err := s.resolveTrackPlatformsByPlatform(platform, entityType, entityID)
|
||||||
|
|
||||||
apiURL := buildSongLinkURLByPlatform(platform, entityType, entityID, "")
|
|
||||||
|
|
||||||
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("resolve failed for %s %s: %w", platform, entityID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
retryConfig := songLinkRetryConfig()
|
return buildTrackAvailabilityFromSongLinkLinks("", links), nil
|
||||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode == 400 {
|
|
||||||
return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform)
|
|
||||||
}
|
|
||||||
if resp.StatusCode == 404 {
|
|
||||||
return nil, fmt.Errorf("track not found on any streaming platform")
|
|
||||||
}
|
|
||||||
if resp.StatusCode == 429 {
|
|
||||||
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
|
||||||
}
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := ReadResponseBody(resp)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var songLinkResp struct {
|
|
||||||
LinksByPlatform map[string]struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
} `json:"linksByPlatform"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
availability := &TrackAvailability{}
|
|
||||||
|
|
||||||
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
|
||||||
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
|
||||||
availability.Tidal = true
|
|
||||||
availability.TidalURL = tidalLink.URL
|
|
||||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
|
||||||
}
|
|
||||||
|
|
||||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
|
||||||
availability.Amazon = true
|
|
||||||
availability.AmazonURL = amazonLink.URL
|
|
||||||
}
|
|
||||||
|
|
||||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
|
||||||
availability.Qobuz = true
|
|
||||||
availability.QobuzURL = qobuzLink.URL
|
|
||||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
|
||||||
}
|
|
||||||
|
|
||||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
|
||||||
availability.Deezer = true
|
|
||||||
availability.DeezerURL = deezerLink.URL
|
|
||||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefer youtubeMusic URLs — they are usually closer to music catalog matches.
|
|
||||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
|
||||||
availability.YouTube = true
|
|
||||||
availability.YouTubeURL = ytMusicLink.URL
|
|
||||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
|
||||||
}
|
|
||||||
if !availability.YouTube {
|
|
||||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
|
||||||
availability.YouTube = true
|
|
||||||
availability.YouTubeURL = youtubeLink.URL
|
|
||||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return availability, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID string, links map[string]songLinkPlatformLink) *TrackAvailability {
|
func buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID string, links map[string]songLinkPlatformLink) *TrackAvailability {
|
||||||
@@ -894,85 +737,10 @@ func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) {
|
func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) {
|
||||||
songLinkRateLimiter.WaitForSlot()
|
links, err := s.resolveTrackPlatforms(inputURL)
|
||||||
|
|
||||||
apiURL := buildSongLinkURLFromTarget(inputURL, "")
|
|
||||||
|
|
||||||
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("resolve failed for URL %s: %w", inputURL, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
retryConfig := songLinkRetryConfig()
|
return buildTrackAvailabilityFromSongLinkLinks("", links), nil
|
||||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode == 400 || resp.StatusCode == 404 {
|
|
||||||
return nil, fmt.Errorf("track not found on SongLink")
|
|
||||||
}
|
|
||||||
if resp.StatusCode == 429 {
|
|
||||||
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
|
||||||
}
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := ReadResponseBody(resp)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var songLinkResp struct {
|
|
||||||
LinksByPlatform map[string]struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
EntityID string `json:"entityUniqueId"`
|
|
||||||
} `json:"linksByPlatform"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
availability := &TrackAvailability{}
|
|
||||||
|
|
||||||
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
|
||||||
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
|
||||||
}
|
|
||||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
|
||||||
availability.Tidal = true
|
|
||||||
availability.TidalURL = tidalLink.URL
|
|
||||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
|
||||||
}
|
|
||||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
|
||||||
availability.Amazon = true
|
|
||||||
availability.AmazonURL = amazonLink.URL
|
|
||||||
}
|
|
||||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
|
||||||
availability.Qobuz = true
|
|
||||||
availability.QobuzURL = qobuzLink.URL
|
|
||||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
|
||||||
}
|
|
||||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
|
||||||
availability.Deezer = true
|
|
||||||
availability.DeezerURL = deezerLink.URL
|
|
||||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
|
||||||
}
|
|
||||||
// Prefer youtubeMusic URLs — they are usually closer to music catalog matches.
|
|
||||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
|
||||||
availability.YouTube = true
|
|
||||||
availability.YouTubeURL = ytMusicLink.URL
|
|
||||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
|
||||||
}
|
|
||||||
if !availability.YouTube {
|
|
||||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
|
||||||
availability.YouTube = true
|
|
||||||
availability.YouTubeURL = youtubeLink.URL
|
|
||||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return availability, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
+108
-36
@@ -23,26 +23,24 @@ func TestGetRetryAfterDurationMissingHeaderReturnsZero(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckTrackAvailabilityFromSpotifyPrefersSongLinkPage(t *testing.T) {
|
func TestCheckTrackAvailabilityFromSpotifyViaResolveAPI(t *testing.T) {
|
||||||
|
origRetryConfig := songLinkRetryConfig
|
||||||
|
defer func() { songLinkRetryConfig = origRetryConfig }()
|
||||||
|
|
||||||
client := &SongLinkClient{
|
client := &SongLinkClient{
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
switch {
|
if req.URL.Host == "api.zarz.moe" && req.URL.Path == "/v1/resolve" && req.Method == "POST" {
|
||||||
case req.URL.Host == "api.song.link":
|
body := `{"success":true,"isrc":"USRC12345678","songUrls":{"Spotify":"https://open.spotify.com/track/testspotifyid","Deezer":"https://www.deezer.com/track/908604612","AmazonMusic":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C","Tidal":"https://listen.tidal.com/track/134858527","Qobuz":"https://open.qobuz.com/track/195125822","YouTubeMusic":"https://music.youtube.com/watch?v=testvideoid1"}}`
|
||||||
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{
|
return &http.Response{
|
||||||
StatusCode: 200,
|
StatusCode: 200,
|
||||||
Header: make(http.Header),
|
Header: make(http.Header),
|
||||||
Body: io.NopCloser(strings.NewReader(body)),
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
Request: req,
|
Request: req,
|
||||||
}, nil
|
}, nil
|
||||||
default:
|
|
||||||
t.Fatalf("unexpected request: %s", req.URL.String())
|
|
||||||
return nil, nil
|
|
||||||
}
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
|
||||||
|
return nil, nil
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -66,62 +64,136 @@ func TestCheckTrackAvailabilityFromSpotifyPrefersSongLinkPage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckTrackAvailabilityFromSpotifyFallsBackToAPIWhenPageFails(t *testing.T) {
|
func TestCheckTrackAvailabilityFromSpotifyResolveAPIFailure(t *testing.T) {
|
||||||
origRetryConfig := songLinkRetryConfig
|
origRetryConfig := songLinkRetryConfig
|
||||||
songLinkRetryConfig = func() RetryConfig {
|
songLinkRetryConfig = func() RetryConfig {
|
||||||
return RetryConfig{
|
return RetryConfig{MaxRetries: 0, InitialDelay: 0, MaxDelay: 0, BackoffFactor: 1}
|
||||||
MaxRetries: 0,
|
|
||||||
InitialDelay: 0,
|
|
||||||
MaxDelay: 0,
|
|
||||||
BackoffFactor: 1,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() { songLinkRetryConfig = origRetryConfig }()
|
||||||
songLinkRetryConfig = origRetryConfig
|
|
||||||
}()
|
var hitSongLink bool
|
||||||
|
|
||||||
client := &SongLinkClient{
|
client := &SongLinkClient{
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
switch {
|
// Resolve proxy returns 500
|
||||||
case req.URL.Host == "song.link" && req.URL.Path == "/s/testspotifyid":
|
if req.URL.Host == "api.zarz.moe" && req.URL.Path == "/v1/resolve" {
|
||||||
return &http.Response{
|
return &http.Response{
|
||||||
StatusCode: 500,
|
StatusCode: 500,
|
||||||
Header: make(http.Header),
|
Header: make(http.Header),
|
||||||
Body: io.NopCloser(strings.NewReader("page failure")),
|
Body: io.NopCloser(strings.NewReader("internal error")),
|
||||||
Request: req,
|
Request: req,
|
||||||
}, nil
|
}, 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"}}}`
|
// SongLink fallback should be called
|
||||||
|
if req.URL.Host == "api.song.link" {
|
||||||
|
hitSongLink = true
|
||||||
|
body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/testspotifyid"},"deezer":{"url":"https://www.deezer.com/track/908604612"},"tidal":{"url":"https://listen.tidal.com/track/134858527"}}}`
|
||||||
return &http.Response{
|
return &http.Response{
|
||||||
StatusCode: 200,
|
StatusCode: 200,
|
||||||
Header: make(http.Header),
|
Header: make(http.Header),
|
||||||
Body: io.NopCloser(strings.NewReader(body)),
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
Request: req,
|
Request: req,
|
||||||
}, nil
|
}, nil
|
||||||
default:
|
|
||||||
t.Fatalf("unexpected request: %s", req.URL.String())
|
|
||||||
return nil, nil
|
|
||||||
}
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
|
||||||
|
return nil, nil
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
availability, err := client.CheckTrackAvailability("testspotifyid", "")
|
availability, err := client.CheckTrackAvailability("testspotifyid", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected SongLink fallback to succeed, got error: %v", err)
|
||||||
|
}
|
||||||
|
if !hitSongLink {
|
||||||
|
t.Fatal("expected fallback request to SongLink API, but it was never called")
|
||||||
|
}
|
||||||
|
if !availability.Deezer || availability.DeezerID != "908604612" {
|
||||||
|
t.Fatalf("Deezer availability via fallback = %+v, want DeezerID 908604612", availability)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckTrackAvailabilityFromSpotifyViaResolveAPIMixedSongURLShapes(t *testing.T) {
|
||||||
|
origRetryConfig := songLinkRetryConfig
|
||||||
|
defer func() { songLinkRetryConfig = origRetryConfig }()
|
||||||
|
|
||||||
|
client := &SongLinkClient{
|
||||||
|
client: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
if req.URL.Host == "api.zarz.moe" && req.URL.Path == "/v1/resolve" && req.Method == "POST" {
|
||||||
|
body := `{"success":true,"isrc":"TCAHA2367688","songUrls":{"Spotify":"https://open.spotify.com/track/5glgyj6zH0irbNGfukHacv","Deezer":"https://www.deezer.com/track/2248583177","Tidal":"https://tidal.com/browse/track/290565315","AppleMusic":"https://geo.music.apple.com/us/album/example?i=1","YouTubeMusic":null,"YouTube":"https://www.youtube.com/watch?v=wD_e59XUNdQ","AmazonMusic":"https://music.amazon.com/tracks/B0C35TG38Y/?ref=dm_ff_amazonmusic_3p","Beatport":null,"BeatSource":null,"SoundCloud":null,"Qobuz":null,"Other":[]}}`
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: 200,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
Request: req,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
|
||||||
|
return nil, nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
availability, err := client.CheckTrackAvailability("5glgyj6zH0irbNGfukHacv", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CheckTrackAvailability() error = %v", err)
|
t.Fatalf("CheckTrackAvailability() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if availability.SpotifyID != "testspotifyid" {
|
if availability.SpotifyID != "5glgyj6zH0irbNGfukHacv" {
|
||||||
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testspotifyid")
|
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "5glgyj6zH0irbNGfukHacv")
|
||||||
}
|
}
|
||||||
if !availability.Deezer || availability.DeezerID != "908604612" {
|
if !availability.Deezer || availability.DeezerID != "2248583177" {
|
||||||
t.Fatalf("Deezer availability = %+v, want DeezerID 908604612", availability)
|
t.Fatalf("Deezer availability = %+v, want DeezerID 2248583177", availability)
|
||||||
}
|
}
|
||||||
if !availability.Amazon || !availability.Tidal || !availability.Qobuz || !availability.YouTube {
|
if !availability.Tidal || availability.TidalID != "290565315" {
|
||||||
t.Fatalf("availability flags = %+v, want Amazon/Tidal/Qobuz/YouTube true", availability)
|
t.Fatalf("Tidal availability = %+v, want TidalID 290565315", availability)
|
||||||
}
|
}
|
||||||
if availability.YouTubeID != "testvideoid1" {
|
if availability.Qobuz {
|
||||||
t.Fatalf("YouTubeID = %q, want %q", availability.YouTubeID, "testvideoid1")
|
t.Fatalf("Qobuz should remain false when resolve response contains null, got %+v", availability)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckAvailabilityFromDeezerUsesSongLink(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) {
|
||||||
|
// Non-Spotify should go to SongLink, not resolve API
|
||||||
|
if req.URL.Host == "api.zarz.moe" {
|
||||||
|
t.Fatalf("non-Spotify URL should not hit resolve API, got: %s", req.URL.String())
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if req.URL.Host == "api.song.link" {
|
||||||
|
body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/testid"},"deezer":{"url":"https://www.deezer.com/track/908604612"},"tidal":{"url":"https://listen.tidal.com/track/134858527"},"qobuz":{"url":"https://open.qobuz.com/track/195125822"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=testvid"}}}`
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: 200,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
Request: req,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
|
||||||
|
return nil, nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
availability, err := client.checkAvailabilityFromDeezerSongLink("908604612")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("checkAvailabilityFromDeezerSongLink() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !availability.Deezer || availability.DeezerID != "908604612" {
|
||||||
|
t.Fatalf("Deezer = %+v, want DeezerID 908604612", availability)
|
||||||
|
}
|
||||||
|
if availability.SpotifyID != "testid" {
|
||||||
|
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const DefaultSpotFetchAPIBaseURL = "https://sp.afkarxyz.qzz.io/api"
|
|
||||||
|
|
||||||
// GetSpotifyDataWithAPI fetches Spotify metadata through SpotFetch-compatible API.
|
|
||||||
// This is used as a fallback when direct Spotify API access is blocked/limited.
|
|
||||||
func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL, apiBaseURL string) (interface{}, error) {
|
|
||||||
parsed, err := parseSpotifyURI(spotifyURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid Spotify URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
base := strings.TrimSpace(apiBaseURL)
|
|
||||||
if base == "" {
|
|
||||||
base = DefaultSpotFetchAPIBaseURL
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(base, "/"), parsed.Type, parsed.ID)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create SpotFetch API request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
|
|
||||||
client := NewHTTPClientWithTimeout(30 * time.Second)
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("SpotFetch API request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("SpotFetch API error: HTTP %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyBytes, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read SpotFetch API response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch parsed.Type {
|
|
||||||
case "track":
|
|
||||||
var trackResp TrackResponse
|
|
||||||
if err := json.Unmarshal(bodyBytes, &trackResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode track response: %w", err)
|
|
||||||
}
|
|
||||||
return trackResp, nil
|
|
||||||
case "album":
|
|
||||||
var albumResp AlbumResponsePayload
|
|
||||||
if err := json.Unmarshal(bodyBytes, &albumResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode album response: %w", err)
|
|
||||||
}
|
|
||||||
return &albumResp, nil
|
|
||||||
case "playlist":
|
|
||||||
var playlistResp PlaylistResponsePayload
|
|
||||||
if err := json.Unmarshal(bodyBytes, &playlistResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode playlist response: %w", err)
|
|
||||||
}
|
|
||||||
return playlistResp, nil
|
|
||||||
case "artist":
|
|
||||||
var artistResp ArtistResponsePayload
|
|
||||||
if err := json.Unmarshal(bodyBytes, &artistResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode artist response: %w", err)
|
|
||||||
}
|
|
||||||
return &artistResp, nil
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
+329
-52
@@ -26,8 +26,14 @@ type TidalDownloader struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
globalTidalDownloader *TidalDownloader
|
globalTidalDownloader *TidalDownloader
|
||||||
tidalDownloaderOnce sync.Once
|
tidalDownloaderOnce sync.Once
|
||||||
|
tidalGetTrackSearchPageFunc = func(t *TidalDownloader, query string, limit int) (*tidalPublicTrackSearchResponse, error) {
|
||||||
|
return t.getTrackSearchPage(query, limit)
|
||||||
|
}
|
||||||
|
tidalGetPublicTrackFunc = func(t *TidalDownloader, resourceID string) (*TidalTrack, error) {
|
||||||
|
return t.getPublicTrack(resourceID)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -758,15 +764,102 @@ func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
|
func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
|
||||||
return nil, fmt.Errorf("tidal ISRC search API disabled: no client credentials mode")
|
normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc))
|
||||||
|
if normalizedISRC == "" {
|
||||||
|
return nil, fmt.Errorf("empty tidal ISRC")
|
||||||
|
}
|
||||||
|
|
||||||
|
page, err := tidalGetTrackSearchPageFunc(t, normalizedISRC, 20)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range page.Items {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(page.Items[i].ISRC), normalizedISRC) {
|
||||||
|
return &page.Items[i], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no exact tidal ISRC match found for %s", normalizedISRC)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, albumName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
|
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, albumName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
|
||||||
return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode")
|
queryParts := make([]string, 0, 3)
|
||||||
|
if trimmed := strings.TrimSpace(trackName); trimmed != "" {
|
||||||
|
queryParts = append(queryParts, trimmed)
|
||||||
|
}
|
||||||
|
if trimmed := strings.TrimSpace(artistName); trimmed != "" {
|
||||||
|
queryParts = append(queryParts, trimmed)
|
||||||
|
}
|
||||||
|
if len(queryParts) == 0 {
|
||||||
|
return nil, fmt.Errorf("tidal metadata search requires track or artist name")
|
||||||
|
}
|
||||||
|
|
||||||
|
queries := []string{strings.Join(queryParts, " ")}
|
||||||
|
if trimmedAlbum := strings.TrimSpace(albumName); trimmedAlbum != "" {
|
||||||
|
queries = append(queries, strings.Join(append(queryParts, trimmedAlbum), " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
req := DownloadRequest{
|
||||||
|
TrackName: strings.TrimSpace(trackName),
|
||||||
|
ArtistName: strings.TrimSpace(artistName),
|
||||||
|
AlbumName: strings.TrimSpace(albumName),
|
||||||
|
ISRC: strings.ToUpper(strings.TrimSpace(spotifyISRC)),
|
||||||
|
DurationMS: expectedDuration * 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
seenQueries := make(map[string]struct{}, len(queries))
|
||||||
|
for _, query := range queries {
|
||||||
|
if _, seen := seenQueries[query]; seen {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenQueries[query] = struct{}{}
|
||||||
|
|
||||||
|
page, err := tidalGetTrackSearchPageFunc(t, query, 20)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidates []*TidalTrack
|
||||||
|
for i := range page.Items {
|
||||||
|
track := &page.Items[i]
|
||||||
|
if req.ISRC != "" && !strings.EqualFold(strings.TrimSpace(track.ISRC), req.ISRC) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resolved := resolvedTrackInfo{
|
||||||
|
Title: strings.TrimSpace(track.Title),
|
||||||
|
ArtistName: tidalTrackArtistsDisplay(track),
|
||||||
|
ISRC: strings.TrimSpace(track.ISRC),
|
||||||
|
Duration: track.Duration,
|
||||||
|
}
|
||||||
|
if trackMatchesRequest(req, resolved, "Tidal search") {
|
||||||
|
candidates = append(candidates, track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.AlbumName != "" {
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if titlesMatch(req.AlbumName, candidate.Album.Title) {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ISRC != "" {
|
||||||
|
return nil, fmt.Errorf("no tidal metadata match found for exact ISRC %s", req.ISRC)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no tidal metadata match found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*TidalTrack, error) {
|
func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*TidalTrack, error) {
|
||||||
return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode")
|
return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", "", 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetadata, error) {
|
func (t *TidalDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetadata, error) {
|
||||||
@@ -782,6 +875,119 @@ func (t *TidalDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetad
|
|||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *TidalDownloader) SearchAll(query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) {
|
||||||
|
GoLog("[Tidal] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
|
||||||
|
|
||||||
|
cleanQuery := strings.TrimSpace(query)
|
||||||
|
if cleanQuery == "" {
|
||||||
|
return nil, fmt.Errorf("empty tidal search query")
|
||||||
|
}
|
||||||
|
|
||||||
|
albumLimit := 5
|
||||||
|
|
||||||
|
if filter != "" {
|
||||||
|
switch filter {
|
||||||
|
case "track":
|
||||||
|
trackLimit = 50
|
||||||
|
artistLimit = 0
|
||||||
|
albumLimit = 0
|
||||||
|
case "artist":
|
||||||
|
trackLimit = 0
|
||||||
|
artistLimit = 20
|
||||||
|
albumLimit = 0
|
||||||
|
case "album":
|
||||||
|
trackLimit = 0
|
||||||
|
artistLimit = 0
|
||||||
|
albumLimit = 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &SearchAllResult{
|
||||||
|
Tracks: make([]TrackMetadata, 0, trackLimit),
|
||||||
|
Artists: make([]SearchArtistResult, 0, artistLimit),
|
||||||
|
Albums: make([]SearchAlbumResult, 0, albumLimit),
|
||||||
|
Playlists: make([]SearchPlaylistResult, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
if trackLimit > 0 {
|
||||||
|
page, err := t.getTrackSearchPage(cleanQuery, trackLimit)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Tidal] Track search failed: %v\n", err)
|
||||||
|
return nil, fmt.Errorf("tidal track search failed: %w", err)
|
||||||
|
}
|
||||||
|
GoLog("[Tidal] Got %d tracks from API\n", len(page.Items))
|
||||||
|
for i := range page.Items {
|
||||||
|
result.Tracks = append(result.Tracks, tidalTrackToTrackMetadata(&page.Items[i]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if artistLimit > 0 {
|
||||||
|
requestURL := tidalBuildMetadataURL("search/artists", url.Values{
|
||||||
|
"query": {cleanQuery},
|
||||||
|
"limit": {strconv.Itoa(artistLimit)},
|
||||||
|
"offset": {"0"},
|
||||||
|
})
|
||||||
|
var artistResp struct {
|
||||||
|
Items []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Picture string `json:"picture"`
|
||||||
|
Popularity int `json:"popularity"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
if err := t.getTidalMetadataJSON(requestURL, &artistResp); err == nil {
|
||||||
|
GoLog("[Tidal] Got %d artists from API\n", len(artistResp.Items))
|
||||||
|
for _, artist := range artistResp.Items {
|
||||||
|
result.Artists = append(result.Artists, SearchArtistResult{
|
||||||
|
ID: tidalPrefixedNumericID(artist.ID),
|
||||||
|
Name: strings.TrimSpace(artist.Name),
|
||||||
|
Images: tidalImageURL(artist.Picture, "750x750"),
|
||||||
|
Followers: 0,
|
||||||
|
Popularity: artist.Popularity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
GoLog("[Tidal] Artist search failed: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if albumLimit > 0 {
|
||||||
|
requestURL := tidalBuildMetadataURL("search/albums", url.Values{
|
||||||
|
"query": {cleanQuery},
|
||||||
|
"limit": {strconv.Itoa(albumLimit)},
|
||||||
|
"offset": {"0"},
|
||||||
|
})
|
||||||
|
var albumResp struct {
|
||||||
|
Items []tidalPublicAlbum `json:"items"`
|
||||||
|
}
|
||||||
|
if err := t.getTidalMetadataJSON(requestURL, &albumResp); err == nil {
|
||||||
|
GoLog("[Tidal] Got %d albums from API\n", len(albumResp.Items))
|
||||||
|
for i := range albumResp.Items {
|
||||||
|
album := &albumResp.Items[i]
|
||||||
|
albumType := strings.ToLower(strings.TrimSpace(album.Type))
|
||||||
|
if albumType == "" {
|
||||||
|
albumType = "album"
|
||||||
|
}
|
||||||
|
result.Albums = append(result.Albums, SearchAlbumResult{
|
||||||
|
ID: tidalPrefixedNumericID(album.ID),
|
||||||
|
Name: strings.TrimSpace(album.Title),
|
||||||
|
Artists: tidalAlbumArtistsDisplay(album),
|
||||||
|
Images: tidalImageURL(album.Cover, "1280x1280"),
|
||||||
|
ReleaseDate: strings.TrimSpace(album.ReleaseDate),
|
||||||
|
TotalTracks: album.NumberOfTracks,
|
||||||
|
AlbumType: albumType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
GoLog("[Tidal] Album search failed: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Tidal] SearchAll complete: %d tracks, %d artists, %d albums\n", len(result.Tracks), len(result.Artists), len(result.Albums))
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) GetTrackMetadata(resourceID string) (*TrackResponse, error) {
|
func (t *TidalDownloader) GetTrackMetadata(resourceID string) (*TrackResponse, error) {
|
||||||
track, err := t.getPublicTrack(resourceID)
|
track, err := t.getPublicTrack(resourceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -806,17 +1012,22 @@ func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
|
|||||||
}
|
}
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(itemsModule.PagedList.Items))
|
tracks := make([]AlbumTrackMetadata, 0, len(itemsModule.PagedList.Items))
|
||||||
|
totalDiscs := 0
|
||||||
for _, item := range itemsModule.PagedList.Items {
|
for _, item := range itemsModule.PagedList.Items {
|
||||||
track := item.Item
|
track := item.Item
|
||||||
if track.Album.ID == 0 {
|
track.Album.ID = headerModule.Album.ID
|
||||||
track.Album.ID = headerModule.Album.ID
|
track.Album.Title = headerModule.Album.Title
|
||||||
track.Album.Title = headerModule.Album.Title
|
track.Album.Cover = headerModule.Album.Cover
|
||||||
track.Album.Cover = headerModule.Album.Cover
|
track.Album.ReleaseDate = headerModule.Album.ReleaseDate
|
||||||
track.Album.ReleaseDate = headerModule.Album.ReleaseDate
|
track.Album.URL = headerModule.Album.URL
|
||||||
track.Album.URL = headerModule.Album.URL
|
if track.VolumeNumber > totalDiscs {
|
||||||
|
totalDiscs = track.VolumeNumber
|
||||||
}
|
}
|
||||||
tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track))
|
tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track))
|
||||||
}
|
}
|
||||||
|
for i := range tracks {
|
||||||
|
tracks[i].TotalDiscs = totalDiscs
|
||||||
|
}
|
||||||
|
|
||||||
return &AlbumResponsePayload{
|
return &AlbumResponsePayload{
|
||||||
AlbumInfo: tidalAlbumToAlbumInfo(&headerModule.Album),
|
AlbumInfo: tidalAlbumToAlbumInfo(&headerModule.Album),
|
||||||
@@ -959,7 +1170,6 @@ type tidalAPIResult struct {
|
|||||||
duration time.Duration
|
duration time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mobile networks are more unstable, so we use longer timeouts
|
|
||||||
const (
|
const (
|
||||||
tidalAPITimeoutMobile = 25 * time.Second
|
tidalAPITimeoutMobile = 25 * time.Second
|
||||||
tidalMaxRetries = 2
|
tidalMaxRetries = 2
|
||||||
@@ -1005,7 +1215,6 @@ func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout t
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 429 rate limit - wait and retry
|
|
||||||
if resp.StatusCode == 429 {
|
if resp.StatusCode == 429 {
|
||||||
io.Copy(io.Discard, resp.Body)
|
io.Copy(io.Discard, resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
@@ -1027,7 +1236,6 @@ func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout t
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try V2 response format (with manifest)
|
|
||||||
var v2Response TidalAPIResponseV2
|
var v2Response TidalAPIResponseV2
|
||||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||||
if v2Response.Data.AssetPresentation == "PREVIEW" {
|
if v2Response.Data.AssetPresentation == "PREVIEW" {
|
||||||
@@ -1041,7 +1249,6 @@ func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout t
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try V1 response format
|
|
||||||
var v1Responses []struct {
|
var v1Responses []struct {
|
||||||
OriginalTrackURL string `json:"OriginalTrackUrl"`
|
OriginalTrackURL string `json:"OriginalTrackUrl"`
|
||||||
}
|
}
|
||||||
@@ -1396,10 +1603,6 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// For DASH format, determine correct M4A path
|
|
||||||
// If outputPath already ends with .m4a, use it directly.
|
|
||||||
// If outputPath ends with .flac, convert .flac to .m4a.
|
|
||||||
// Otherwise (e.g., SAF /proc/self/fd/*), use outputPath as-is.
|
|
||||||
var m4aPath string
|
var m4aPath string
|
||||||
if strings.HasSuffix(outputPath, ".m4a") {
|
if strings.HasSuffix(outputPath, ".m4a") {
|
||||||
m4aPath = outputPath
|
m4aPath = outputPath
|
||||||
@@ -1537,8 +1740,8 @@ type TidalDownloadResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
||||||
normSpotify := strings.ToLower(strings.TrimSpace(spotifyArtist))
|
normSpotify := normalizeLooseArtistName(spotifyArtist)
|
||||||
normTidal := strings.ToLower(strings.TrimSpace(tidalArtist))
|
normTidal := normalizeLooseArtistName(tidalArtist)
|
||||||
|
|
||||||
if normSpotify == normTidal {
|
if normSpotify == normTidal {
|
||||||
return true
|
return true
|
||||||
@@ -1673,8 +1876,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emoji/symbol-only titles must be matched strictly to avoid false positives
|
|
||||||
// like mapping "🪐" to "Higher Power".
|
|
||||||
if (!hasAlphaNumericRunes(expectedTitle) || !hasAlphaNumericRunes(foundTitle)) &&
|
if (!hasAlphaNumericRunes(expectedTitle) || !hasAlphaNumericRunes(foundTitle)) &&
|
||||||
strings.TrimSpace(expectedTitle) != "" &&
|
strings.TrimSpace(expectedTitle) != "" &&
|
||||||
strings.TrimSpace(foundTitle) != "" {
|
strings.TrimSpace(foundTitle) != "" {
|
||||||
@@ -1830,6 +2031,7 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
|
|||||||
expectedDurationSec := req.DurationMS / 1000
|
expectedDurationSec := req.DurationMS / 1000
|
||||||
var trackID int64
|
var trackID int64
|
||||||
var gotTidalID bool
|
var gotTidalID bool
|
||||||
|
var resolvedViaSongLink bool
|
||||||
|
|
||||||
if req.TidalID != "" {
|
if req.TidalID != "" {
|
||||||
GoLog("[%s] Using Tidal ID from request payload: %s\n", logPrefix, req.TidalID)
|
GoLog("[%s] Using Tidal ID from request payload: %s\n", logPrefix, req.TidalID)
|
||||||
@@ -1847,6 +2049,36 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !gotTidalID && req.ISRC != "" {
|
||||||
|
GoLog("[%s] Trying direct Tidal ISRC search: %s\n", logPrefix, req.ISRC)
|
||||||
|
directTrack, directErr := downloader.SearchTrackByISRC(req.ISRC)
|
||||||
|
if directErr == nil && directTrack != nil && directTrack.ID > 0 {
|
||||||
|
trackID = directTrack.ID
|
||||||
|
gotTidalID = true
|
||||||
|
GoLog("[%s] Got Tidal ID %d from direct ISRC search\n", logPrefix, trackID)
|
||||||
|
} else if directErr != nil {
|
||||||
|
GoLog("[%s] Direct Tidal ISRC search failed: %v\n", logPrefix, directErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !gotTidalID && req.ISRC != "" && req.TrackName != "" && req.ArtistName != "" {
|
||||||
|
GoLog("[%s] Trying Tidal public metadata search with ISRC\n", logPrefix)
|
||||||
|
searchTrack, searchErr := downloader.SearchTrackByMetadataWithISRC(
|
||||||
|
req.TrackName,
|
||||||
|
req.ArtistName,
|
||||||
|
req.AlbumName,
|
||||||
|
req.ISRC,
|
||||||
|
expectedDurationSec,
|
||||||
|
)
|
||||||
|
if searchErr == nil && searchTrack != nil && searchTrack.ID > 0 {
|
||||||
|
trackID = searchTrack.ID
|
||||||
|
gotTidalID = true
|
||||||
|
GoLog("[%s] Got Tidal ID %d from public metadata search\n", logPrefix, trackID)
|
||||||
|
} else if searchErr != nil {
|
||||||
|
GoLog("[%s] Tidal public metadata search failed: %v\n", logPrefix, searchErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !gotTidalID && (req.SpotifyID != "" || req.DeezerID != "") {
|
if !gotTidalID && (req.SpotifyID != "" || req.DeezerID != "") {
|
||||||
GoLog("[%s] Trying SongLink for Tidal ID...\n", logPrefix)
|
GoLog("[%s] Trying SongLink for Tidal ID...\n", logPrefix)
|
||||||
|
|
||||||
@@ -1859,6 +2091,7 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
|
|||||||
trackID = parsedTrackID
|
trackID = parsedTrackID
|
||||||
GoLog("[%s] Got Tidal ID %d directly from SongLink\n", logPrefix, trackID)
|
GoLog("[%s] Got Tidal ID %d directly from SongLink\n", logPrefix, trackID)
|
||||||
gotTidalID = true
|
gotTidalID = true
|
||||||
|
resolvedViaSongLink = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1868,11 +2101,11 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
|
|||||||
if idErr == nil && trackID > 0 {
|
if idErr == nil && trackID > 0 {
|
||||||
GoLog("[%s] Got Tidal ID %d from URL parsing\n", logPrefix, trackID)
|
GoLog("[%s] Got Tidal ID %d from URL parsing\n", logPrefix, trackID)
|
||||||
gotTidalID = true
|
gotTidalID = true
|
||||||
|
resolvedViaSongLink = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer Deezer-based SongLink lookup when DeezerID is available.
|
|
||||||
if req.DeezerID != "" {
|
if req.DeezerID != "" {
|
||||||
GoLog("[%s] Using Deezer ID for SongLink lookup: %s\n", logPrefix, req.DeezerID)
|
GoLog("[%s] Using Deezer ID for SongLink lookup: %s\n", logPrefix, req.DeezerID)
|
||||||
songlink := NewSongLinkClient()
|
songlink := NewSongLinkClient()
|
||||||
@@ -1911,23 +2144,22 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
|
|||||||
return nil, fmt.Errorf("failed to find tidal track id from request/cache/songlink")
|
return nil, fmt.Errorf("failed to find tidal track id from request/cache/songlink")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the resolved track matches the request.
|
actualTrack, fetchErr := tidalGetPublicTrackFunc(downloader, strconv.FormatInt(trackID, 10))
|
||||||
actualTrack, fetchErr := downloader.getPublicTrack(strconv.FormatInt(trackID, 10))
|
|
||||||
if fetchErr != nil {
|
if fetchErr != nil {
|
||||||
GoLog("[%s] Warning: could not fetch Tidal track %d for verification: %v\n", logPrefix, trackID, fetchErr)
|
GoLog("[%s] Warning: could not fetch Tidal track %d for verification: %v\n", logPrefix, trackID, fetchErr)
|
||||||
// Continue without verification — better than failing entirely.
|
|
||||||
} else {
|
} else {
|
||||||
providerArtist := actualTrack.Artist.Name
|
providerArtist := actualTrack.Artist.Name
|
||||||
if providerArtist == "" && len(actualTrack.Artists) > 0 {
|
if providerArtist == "" && len(actualTrack.Artists) > 0 {
|
||||||
providerArtist = actualTrack.Artists[0].Name
|
providerArtist = actualTrack.Artists[0].Name
|
||||||
}
|
}
|
||||||
resolved := resolvedTrackInfo{
|
resolved := resolvedTrackInfo{
|
||||||
Title: actualTrack.Title,
|
Title: actualTrack.Title,
|
||||||
ArtistName: providerArtist,
|
ArtistName: providerArtist,
|
||||||
Duration: actualTrack.Duration,
|
ISRC: strings.TrimSpace(actualTrack.ISRC),
|
||||||
|
Duration: actualTrack.Duration,
|
||||||
|
SkipNameVerification: resolvedViaSongLink,
|
||||||
}
|
}
|
||||||
if !trackMatchesRequest(req, resolved, logPrefix) {
|
if !trackMatchesRequest(req, resolved, logPrefix) {
|
||||||
// Invalidate the cached ID so future requests don't reuse it.
|
|
||||||
if req.ISRC != "" {
|
if req.ISRC != "" {
|
||||||
GetTrackIDCache().SetTidal(req.ISRC, 0)
|
GetTrackIDCache().SetTidal(req.ISRC, 0)
|
||||||
}
|
}
|
||||||
@@ -1937,13 +2169,26 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
|
|||||||
GoLog("[%s] Track %d verified: '%s - %s' ✓\n", logPrefix, trackID, resolved.ArtistName, resolved.Title)
|
GoLog("[%s] Track %d verified: '%s - %s' ✓\n", logPrefix, trackID, resolved.ArtistName, resolved.Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use track_number / disc_number from the actual Tidal API data when the
|
||||||
|
// request doesn't carry them (e.g. downloads from search results / popular).
|
||||||
|
resolvedTrackNumber := req.TrackNumber
|
||||||
|
resolvedDiscNumber := req.DiscNumber
|
||||||
|
if actualTrack != nil {
|
||||||
|
if resolvedTrackNumber == 0 && actualTrack.TrackNumber > 0 {
|
||||||
|
resolvedTrackNumber = actualTrack.TrackNumber
|
||||||
|
}
|
||||||
|
if resolvedDiscNumber == 0 && actualTrack.VolumeNumber > 0 {
|
||||||
|
resolvedDiscNumber = actualTrack.VolumeNumber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
track := &TidalTrack{
|
track := &TidalTrack{
|
||||||
ID: trackID,
|
ID: trackID,
|
||||||
Title: strings.TrimSpace(req.TrackName),
|
Title: strings.TrimSpace(req.TrackName),
|
||||||
ISRC: strings.TrimSpace(req.ISRC),
|
ISRC: strings.TrimSpace(req.ISRC),
|
||||||
Duration: expectedDurationSec,
|
Duration: expectedDurationSec,
|
||||||
TrackNumber: req.TrackNumber,
|
TrackNumber: resolvedTrackNumber,
|
||||||
VolumeNumber: req.DiscNumber,
|
VolumeNumber: resolvedDiscNumber,
|
||||||
}
|
}
|
||||||
track.Artist.Name = strings.TrimSpace(req.ArtistName)
|
track.Artist.Name = strings.TrimSpace(req.ArtistName)
|
||||||
track.Album.Title = strings.TrimSpace(req.AlbumName)
|
track.Album.Title = strings.TrimSpace(req.AlbumName)
|
||||||
@@ -1971,7 +2216,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
quality := req.Quality
|
quality := req.Quality
|
||||||
if quality == "" {
|
if quality == "" || quality == "DEFAULT" {
|
||||||
quality = "LOSSLESS"
|
quality = "LOSSLESS"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1987,7 +2232,11 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
|
|
||||||
outputExt := strings.TrimSpace(req.OutputExt)
|
outputExt := strings.TrimSpace(req.OutputExt)
|
||||||
if outputExt == "" {
|
if outputExt == "" {
|
||||||
outputExt = ".flac"
|
if quality == "HIGH" {
|
||||||
|
outputExt = ".m4a"
|
||||||
|
} else {
|
||||||
|
outputExt = ".flac"
|
||||||
|
}
|
||||||
} else if !strings.HasPrefix(outputExt, ".") {
|
} else if !strings.HasPrefix(outputExt, ".") {
|
||||||
outputExt = "." + outputExt
|
outputExt = "." + outputExt
|
||||||
}
|
}
|
||||||
@@ -2001,7 +2250,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
m4aPath = outputPath
|
m4aPath = outputPath
|
||||||
} else {
|
} else {
|
||||||
if outputExt == ".m4a" {
|
if outputExt == ".m4a" || quality == "HIGH" {
|
||||||
filename = sanitizeFilename(filename) + ".m4a"
|
filename = sanitizeFilename(filename) + ".m4a"
|
||||||
outputPath = filepath.Join(req.OutputDir, filename)
|
outputPath = filepath.Join(req.OutputDir, filename)
|
||||||
m4aPath = outputPath
|
m4aPath = outputPath
|
||||||
@@ -2014,8 +2263,10 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
}
|
}
|
||||||
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
|
if quality != "HIGH" {
|
||||||
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
|
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
|
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2107,18 +2358,21 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
metadata := Metadata{
|
metadata := Metadata{
|
||||||
Title: req.TrackName,
|
Title: req.TrackName,
|
||||||
Artist: req.ArtistName,
|
Artist: req.ArtistName,
|
||||||
Album: req.AlbumName,
|
Album: req.AlbumName,
|
||||||
AlbumArtist: req.AlbumArtist,
|
AlbumArtist: req.AlbumArtist,
|
||||||
Date: releaseDate,
|
ArtistTagMode: req.ArtistTagMode,
|
||||||
TrackNumber: actualTrackNumber,
|
Date: releaseDate,
|
||||||
TotalTracks: req.TotalTracks,
|
TrackNumber: actualTrackNumber,
|
||||||
DiscNumber: actualDiscNumber,
|
TotalTracks: req.TotalTracks,
|
||||||
ISRC: track.ISRC,
|
DiscNumber: actualDiscNumber,
|
||||||
Genre: req.Genre,
|
TotalDiscs: req.TotalDiscs,
|
||||||
Label: req.Label,
|
ISRC: track.ISRC,
|
||||||
Copyright: req.Copyright,
|
Genre: req.Genre,
|
||||||
|
Label: req.Label,
|
||||||
|
Copyright: req.Copyright,
|
||||||
|
Composer: req.Composer,
|
||||||
}
|
}
|
||||||
|
|
||||||
var coverData []byte
|
var coverData []byte
|
||||||
@@ -2171,7 +2425,27 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
fmt.Println("[Tidal] No lyrics available from parallel fetch")
|
fmt.Println("[Tidal] No lyrics available from parallel fetch")
|
||||||
}
|
}
|
||||||
} else if (isSafOutput && actualExt == ".m4a") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".m4a")) {
|
} else if (isSafOutput && actualExt == ".m4a") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".m4a")) {
|
||||||
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
|
if quality == "HIGH" {
|
||||||
|
GoLog("[Tidal] HIGH quality M4A - skipping metadata embedding (file from server is already valid)\n")
|
||||||
|
|
||||||
|
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
|
lyricsMode := req.LyricsMode
|
||||||
|
if lyricsMode == "" {
|
||||||
|
lyricsMode = "embed"
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") {
|
||||||
|
GoLog("[Tidal] Saving external LRC file for M4A (mode: %s)...\n", lyricsMode)
|
||||||
|
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||||
|
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||||
|
} else {
|
||||||
|
GoLog("[Tidal] LRC file saved: %s\n", lrcPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isSafOutput {
|
if !isSafOutput {
|
||||||
@@ -2180,6 +2454,10 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
|
|
||||||
bitDepth := downloadInfo.BitDepth
|
bitDepth := downloadInfo.BitDepth
|
||||||
sampleRate := downloadInfo.SampleRate
|
sampleRate := downloadInfo.SampleRate
|
||||||
|
if quality == "HIGH" {
|
||||||
|
bitDepth = 0
|
||||||
|
sampleRate = 44100
|
||||||
|
}
|
||||||
lyricsLRC := ""
|
lyricsLRC := ""
|
||||||
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
lyricsLRC = parallelResult.LyricsLRC
|
lyricsLRC = parallelResult.LyricsLRC
|
||||||
@@ -2225,7 +2503,6 @@ func parseTidalURL(input string) (string, string, error) {
|
|||||||
|
|
||||||
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||||
|
|
||||||
// Handle /browse/track/123 format
|
|
||||||
if len(parts) > 0 && parts[0] == "browse" {
|
if len(parts) > 0 && parts[0] == "browse" {
|
||||||
parts = parts[1:]
|
parts = parts[1:]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,25 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
|
"golang.org/x/text/unicode/norm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// normalizeLooseTitle collapses separators/punctuation so titles like
|
func writeNormalizedArtistRune(b *strings.Builder, r rune) {
|
||||||
// "Doctor / Cops" and "Doctor _ Cops" can still match.
|
switch r {
|
||||||
|
case 'đ':
|
||||||
|
b.WriteString("dj")
|
||||||
|
case 'ß':
|
||||||
|
b.WriteString("ss")
|
||||||
|
case 'æ':
|
||||||
|
b.WriteString("ae")
|
||||||
|
case 'œ':
|
||||||
|
b.WriteString("oe")
|
||||||
|
default:
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeLooseTitle(title string) string {
|
func normalizeLooseTitle(title string) string {
|
||||||
trimmed := strings.TrimSpace(strings.ToLower(title))
|
trimmed := strings.TrimSpace(strings.ToLower(title))
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
@@ -22,11 +37,37 @@ func normalizeLooseTitle(title string) string {
|
|||||||
b.WriteRune(r)
|
b.WriteRune(r)
|
||||||
case unicode.IsSpace(r):
|
case unicode.IsSpace(r):
|
||||||
b.WriteByte(' ')
|
b.WriteByte(' ')
|
||||||
// Treat common separators as spaces.
|
|
||||||
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
|
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
|
||||||
b.WriteByte(' ')
|
b.WriteByte(' ')
|
||||||
default:
|
default:
|
||||||
// Drop other punctuation/symbols (including emoji) for loose matching.
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(strings.Fields(b.String()), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLooseArtistName(name string) string {
|
||||||
|
trimmed := strings.TrimSpace(strings.ToLower(name))
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
decomposed := norm.NFD.String(trimmed)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(decomposed))
|
||||||
|
|
||||||
|
for _, r := range decomposed {
|
||||||
|
switch {
|
||||||
|
case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Mc, r), unicode.Is(unicode.Me, r):
|
||||||
|
continue
|
||||||
|
case unicode.IsLetter(r), unicode.IsNumber(r):
|
||||||
|
writeNormalizedArtistRune(&b, r)
|
||||||
|
case unicode.IsSpace(r):
|
||||||
|
b.WriteByte(' ')
|
||||||
|
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
|
||||||
|
b.WriteByte(' ')
|
||||||
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,9 +83,6 @@ func hasAlphaNumericRunes(value string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeSymbolOnlyTitle keeps symbol/emoji runes while dropping letters,
|
|
||||||
// digits, spaces and punctuation. This is useful for emoji-only titles such as
|
|
||||||
// "🪐", "🌎" etc, so we can compare them strictly and avoid false matches.
|
|
||||||
func normalizeSymbolOnlyTitle(title string) string {
|
func normalizeSymbolOnlyTitle(title string) string {
|
||||||
trimmed := strings.TrimSpace(strings.ToLower(title))
|
trimmed := strings.TrimSpace(strings.ToLower(title))
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
@@ -69,30 +107,33 @@ 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 {
|
type resolvedTrackInfo struct {
|
||||||
Title string
|
Title string
|
||||||
ArtistName string
|
ArtistName string
|
||||||
Duration int // seconds
|
ISRC string
|
||||||
|
Duration int
|
||||||
|
SkipNameVerification bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// trackMatchesRequest checks whether a resolved track from a provider matches
|
|
||||||
// the original download request. Returns true if the track is a plausible match.
|
|
||||||
func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool {
|
func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool {
|
||||||
if req.ArtistName != "" && resolved.ArtistName != "" &&
|
exactISRCMatch := req.ISRC != "" &&
|
||||||
!artistsMatch(req.ArtistName, resolved.ArtistName) {
|
resolved.ISRC != "" &&
|
||||||
GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n",
|
strings.EqualFold(strings.TrimSpace(req.ISRC), strings.TrimSpace(resolved.ISRC))
|
||||||
logPrefix, req.ArtistName, resolved.ArtistName)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.TrackName != "" && resolved.Title != "" &&
|
if !exactISRCMatch && !resolved.SkipNameVerification {
|
||||||
!titlesMatch(req.TrackName, resolved.Title) {
|
if req.ArtistName != "" && resolved.ArtistName != "" &&
|
||||||
GoLog("[%s] Verification failed: title mismatch — expected '%s', got '%s'\n",
|
!artistsMatch(req.ArtistName, resolved.ArtistName) {
|
||||||
logPrefix, req.TrackName, resolved.Title)
|
GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n",
|
||||||
return false
|
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
|
expectedDurationSec := req.DurationMS / 1000
|
||||||
|
|||||||
@@ -21,6 +21,40 @@ func TestNormalizeLooseTitle_EmojiAndSymbols(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTrackMatchesRequest_SongLinkBypassesArtistAndTitle(t *testing.T) {
|
||||||
|
req := DownloadRequest{
|
||||||
|
TrackName: "Ringišpil",
|
||||||
|
ArtistName: "Djordje Balasevic",
|
||||||
|
}
|
||||||
|
resolved := resolvedTrackInfo{
|
||||||
|
Title: "Completely Different Title",
|
||||||
|
ArtistName: "Totally Different Artist",
|
||||||
|
SkipNameVerification: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !trackMatchesRequest(req, resolved, "test") {
|
||||||
|
t.Fatal("expected SongLink-resolved track to bypass artist/title verification")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrackMatchesRequest_SongLinkStillChecksDuration(t *testing.T) {
|
||||||
|
req := DownloadRequest{
|
||||||
|
TrackName: "Ringišpil",
|
||||||
|
ArtistName: "Djordje Balasevic",
|
||||||
|
DurationMS: 180000,
|
||||||
|
}
|
||||||
|
resolved := resolvedTrackInfo{
|
||||||
|
Title: "Completely Different Title",
|
||||||
|
ArtistName: "Totally Different Artist",
|
||||||
|
Duration: 240,
|
||||||
|
SkipNameVerification: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if trackMatchesRequest(req, resolved, "test") {
|
||||||
|
t.Fatal("expected SongLink-resolved track with large duration mismatch to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTitlesMatch_SeparatorVariants(t *testing.T) {
|
func TestTitlesMatch_SeparatorVariants(t *testing.T) {
|
||||||
if !titlesMatch("Doctor / Cops", "Doctor _ Cops") {
|
if !titlesMatch("Doctor / Cops", "Doctor _ Cops") {
|
||||||
t.Fatal("expected tidal titlesMatch to accept / vs _ variant")
|
t.Fatal("expected tidal titlesMatch to accept / vs _ variant")
|
||||||
|
|||||||
@@ -1,750 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
type YouTubeDownloader struct {
|
|
||||||
client *http.Client
|
|
||||||
apiURL string
|
|
||||||
mu sync.Mutex
|
|
||||||
}
|
|
||||||
|
|
||||||
const spotubeBaseURL = "https://spotubedl.com"
|
|
||||||
|
|
||||||
var (
|
|
||||||
globalYouTubeDownloader *YouTubeDownloader
|
|
||||||
youtubeDownloaderOnce sync.Once
|
|
||||||
)
|
|
||||||
|
|
||||||
type YouTubeQuality string
|
|
||||||
|
|
||||||
const (
|
|
||||||
YouTubeQualityOpus320 YouTubeQuality = "opus_320"
|
|
||||||
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
|
|
||||||
YouTubeQualityOpus128 YouTubeQuality = "opus_128"
|
|
||||||
YouTubeQualityMP3128 YouTubeQuality = "mp3_128"
|
|
||||||
YouTubeQualityMP3256 YouTubeQuality = "mp3_256"
|
|
||||||
YouTubeQualityMP3320 YouTubeQuality = "mp3_320"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
youtubeOpusSupportedBitrates = []int{128, 256, 320}
|
|
||||||
youtubeMp3SupportedBitrates = []int{128, 256, 320}
|
|
||||||
)
|
|
||||||
|
|
||||||
type CobaltRequest struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
AudioBitrate string `json:"audioBitrate,omitempty"`
|
|
||||||
AudioFormat string `json:"audioFormat,omitempty"`
|
|
||||||
DownloadMode string `json:"downloadMode,omitempty"`
|
|
||||||
FilenameStyle string `json:"filenameStyle,omitempty"`
|
|
||||||
DisableMetadata bool `json:"disableMetadata,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CobaltResponse struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
URL string `json:"url,omitempty"`
|
|
||||||
Filename string `json:"filename,omitempty"`
|
|
||||||
Error *struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
Context *struct {
|
|
||||||
Service string `json:"service,omitempty"`
|
|
||||||
Limit int `json:"limit,omitempty"`
|
|
||||||
} `json:"context,omitempty"`
|
|
||||||
} `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type YouTubeDownloadResult struct {
|
|
||||||
FilePath string
|
|
||||||
Title string
|
|
||||||
Artist string
|
|
||||||
Album string
|
|
||||||
ReleaseDate string
|
|
||||||
TrackNumber int
|
|
||||||
DiscNumber int
|
|
||||||
ISRC string
|
|
||||||
Format string // "opus" or "mp3"
|
|
||||||
Bitrate int
|
|
||||||
LyricsLRC string
|
|
||||||
CoverData []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewYouTubeDownloader() *YouTubeDownloader {
|
|
||||||
youtubeDownloaderOnce.Do(func() {
|
|
||||||
globalYouTubeDownloader = &YouTubeDownloader{
|
|
||||||
client: NewHTTPClientWithTimeout(DownloadTimeout),
|
|
||||||
apiURL: "https://api.qwkuns.me",
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return globalYouTubeDownloader
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractBitrateFromQuality(raw string, defaultBitrate int) int {
|
|
||||||
parts := strings.FieldsFunc(raw, func(r rune) bool {
|
|
||||||
return (r < '0' || r > '9')
|
|
||||||
})
|
|
||||||
for i := len(parts) - 1; i >= 0; i-- {
|
|
||||||
part := parts[i]
|
|
||||||
if part == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if parsed, err := strconv.Atoi(part); err == nil {
|
|
||||||
return parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return defaultBitrate
|
|
||||||
}
|
|
||||||
|
|
||||||
func nearestSupportedBitrate(value int, supported []int) int {
|
|
||||||
nearest := supported[0]
|
|
||||||
nearestDistance := absInt(value - nearest)
|
|
||||||
|
|
||||||
for _, option := range supported[1:] {
|
|
||||||
distance := absInt(value - option)
|
|
||||||
// On tie prefer higher quality.
|
|
||||||
if distance < nearestDistance || (distance == nearestDistance && option > nearest) {
|
|
||||||
nearest = option
|
|
||||||
nearestDistance = distance
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nearest
|
|
||||||
}
|
|
||||||
|
|
||||||
func absInt(value int) int {
|
|
||||||
if value < 0 {
|
|
||||||
return -value
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalized YouTubeQuality) {
|
|
||||||
normalizedRaw := strings.ToLower(strings.TrimSpace(raw))
|
|
||||||
|
|
||||||
if strings.HasPrefix(normalizedRaw, "opus") {
|
|
||||||
parsed := extractBitrateFromQuality(normalizedRaw, 256)
|
|
||||||
finalBitrate := nearestSupportedBitrate(parsed, youtubeOpusSupportedBitrates)
|
|
||||||
return "opus", finalBitrate, YouTubeQuality(fmt.Sprintf("opus_%d", finalBitrate))
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(normalizedRaw, "mp3") {
|
|
||||||
parsed := extractBitrateFromQuality(normalizedRaw, 320)
|
|
||||||
finalBitrate := nearestSupportedBitrate(parsed, youtubeMp3SupportedBitrates)
|
|
||||||
return "mp3", finalBitrate, YouTubeQuality(fmt.Sprintf("mp3_%d", finalBitrate))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backward compatibility for legacy symbolic values.
|
|
||||||
switch normalizedRaw {
|
|
||||||
case "opus_256", "opus256", "opus":
|
|
||||||
return "opus", 256, YouTubeQualityOpus256
|
|
||||||
case "opus_320", "opus320":
|
|
||||||
return "opus", 320, YouTubeQualityOpus320
|
|
||||||
case "opus_128", "opus128":
|
|
||||||
return "opus", 128, YouTubeQualityOpus128
|
|
||||||
case "mp3_320", "mp3320", "mp3", "":
|
|
||||||
return "mp3", 320, YouTubeQualityMP3320
|
|
||||||
case "mp3_256", "mp3256":
|
|
||||||
return "mp3", 256, YouTubeQualityMP3256
|
|
||||||
case "mp3_128", "mp3128":
|
|
||||||
return "mp3", 128, YouTubeQualityMP3128
|
|
||||||
default:
|
|
||||||
return "mp3", 320, YouTubeQualityMP3320
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) {
|
|
||||||
query := fmt.Sprintf("%s %s", artistName, trackName)
|
|
||||||
searchQuery := url.QueryEscape(query)
|
|
||||||
|
|
||||||
GoLog("[YouTube] Search query: %s\n", query)
|
|
||||||
|
|
||||||
youtubeMusicURL := fmt.Sprintf("https://music.youtube.com/search?q=%s", searchQuery)
|
|
||||||
|
|
||||||
return youtubeMusicURL, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQuality) (*CobaltResponse, error) {
|
|
||||||
y.mu.Lock()
|
|
||||||
defer y.mu.Unlock()
|
|
||||||
|
|
||||||
audioFormat, bitrate, _ := parseYouTubeQualityInput(string(quality))
|
|
||||||
audioBitrate := strconv.Itoa(bitrate)
|
|
||||||
|
|
||||||
// Try SpotubeDL first (primary)
|
|
||||||
var spotubeErr error
|
|
||||||
videoID, extractErr := ExtractYouTubeVideoID(youtubeURL)
|
|
||||||
if extractErr == nil {
|
|
||||||
GoLog("[YouTube] Requesting from SpotubeDL: videoID=%s (format: %s, bitrate: %s)\n",
|
|
||||||
videoID, audioFormat, audioBitrate)
|
|
||||||
|
|
||||||
resp, err := y.requestSpotubeDL(videoID, audioFormat, audioBitrate)
|
|
||||||
if err == nil {
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
spotubeErr = err
|
|
||||||
GoLog("[YouTube] SpotubeDL failed: %v, trying Cobalt fallback...\n", err)
|
|
||||||
} else {
|
|
||||||
GoLog("[YouTube] Could not extract video ID: %v, skipping SpotubeDL\n", extractErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: direct Cobalt API (api.qwkuns.me)
|
|
||||||
cobaltURL := toYouTubeMusicURL(youtubeURL)
|
|
||||||
GoLog("[YouTube] Requesting from Cobalt API: %s (format: %s, bitrate: %s)\n",
|
|
||||||
cobaltURL, audioFormat, audioBitrate)
|
|
||||||
|
|
||||||
resp, err := y.requestCobaltDirect(cobaltURL, audioFormat, audioBitrate)
|
|
||||||
if err != nil {
|
|
||||||
if spotubeErr != nil {
|
|
||||||
return nil, fmt.Errorf("all download methods failed: spotubedl: %v, cobalt: %v", spotubeErr, err)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("all download methods failed: spotubedl: extractErr=%v, cobalt: %v", extractErr, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) {
|
|
||||||
reqBody := CobaltRequest{
|
|
||||||
URL: videoURL,
|
|
||||||
AudioFormat: audioFormat,
|
|
||||||
AudioBitrate: audioBitrate,
|
|
||||||
DownloadMode: "audio",
|
|
||||||
FilenameStyle: "basic",
|
|
||||||
DisableMetadata: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonData, err := json.Marshal(reqBody)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest("POST", y.apiURL, strings.NewReader(string(jsonData)))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
|
|
||||||
resp, err := DoRequestWithUserAgent(y.client, req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("cobalt API request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[YouTube] Cobalt API response status: %d\n", resp.StatusCode)
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return nil, fmt.Errorf("cobalt API returned status %d: %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
var cobaltResp CobaltResponse
|
|
||||||
if err := json.Unmarshal(body, &cobaltResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cobaltResp.Status == "error" && cobaltResp.Error != nil {
|
|
||||||
return nil, fmt.Errorf("cobalt error: %s", cobaltResp.Error.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cobaltResp.Status != "tunnel" && cobaltResp.Status != "redirect" {
|
|
||||||
return nil, fmt.Errorf("unexpected cobalt status: %s", cobaltResp.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cobaltResp.URL == "" {
|
|
||||||
return nil, fmt.Errorf("no download URL in response")
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[YouTube] Got download URL from Cobalt (status: %s)\n", cobaltResp.Status)
|
|
||||||
return &cobaltResp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// requestSpotubeDL uses SpotubeDL as a Cobalt proxy (they handle auth to yt-dl.click instances).
|
|
||||||
// Engines v3/v2 are MP3-oriented outputs, so we only use them for MP3 requests.
|
|
||||||
func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate string) (*CobaltResponse, error) {
|
|
||||||
engines := []string{"v1"}
|
|
||||||
if strings.EqualFold(audioFormat, "mp3") {
|
|
||||||
engines = append(engines, "v3", "v2")
|
|
||||||
}
|
|
||||||
var lastErr error
|
|
||||||
|
|
||||||
for _, engine := range engines {
|
|
||||||
resp, err := y.requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine)
|
|
||||||
if err == nil {
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
lastErr = err
|
|
||||||
GoLog("[YouTube] SpotubeDL (%s) failed: %v\n", engine, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if lastErr == nil {
|
|
||||||
lastErr = fmt.Errorf("no SpotubeDL engine available")
|
|
||||||
}
|
|
||||||
return nil, lastErr
|
|
||||||
}
|
|
||||||
|
|
||||||
func (y *YouTubeDownloader) requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine string) (*CobaltResponse, error) {
|
|
||||||
apiURL := fmt.Sprintf("%s/api/download/%s?engine=%s&format=%s&quality=%s",
|
|
||||||
spotubeBaseURL, videoID, url.QueryEscape(engine), url.QueryEscape(audioFormat), url.QueryEscape(audioBitrate))
|
|
||||||
|
|
||||||
GoLog("[YouTube] Requesting from SpotubeDL (%s): %s\n", engine, apiURL)
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", apiURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
|
|
||||||
resp, err := DoRequestWithUserAgent(y.client, req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("spotubedl request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[YouTube] SpotubeDL (%s) response status: %d\n", engine, resp.StatusCode)
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return nil, fmt.Errorf("spotubedl(%s) returned status %d: %s", engine, resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
var result struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Filename string `json:"filename"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &result); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse spotubedl response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadURL := strings.TrimSpace(result.URL)
|
|
||||||
if downloadURL == "" {
|
|
||||||
if result.Error != "" {
|
|
||||||
return nil, fmt.Errorf("spotubedl(%s) error: %s", engine, result.Error)
|
|
||||||
}
|
|
||||||
if result.Message != "" {
|
|
||||||
return nil, fmt.Errorf("spotubedl(%s) message: %s", engine, result.Message)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("no download URL from spotubedl(%s)", engine)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(downloadURL, "/") {
|
|
||||||
downloadURL = spotubeBaseURL + downloadURL
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasPrefix(downloadURL, "http://") && !strings.HasPrefix(downloadURL, "https://") {
|
|
||||||
return nil, fmt.Errorf("invalid download URL from spotubedl(%s): %s", engine, downloadURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
filename := strings.TrimSpace(result.Filename)
|
|
||||||
if filename == "" {
|
|
||||||
if parsedURL, parseErr := url.Parse(downloadURL); parseErr == nil {
|
|
||||||
if queryFilename := strings.TrimSpace(parsedURL.Query().Get("filename")); queryFilename != "" {
|
|
||||||
if decodedFilename, decodeErr := url.QueryUnescape(queryFilename); decodeErr == nil {
|
|
||||||
filename = decodedFilename
|
|
||||||
} else {
|
|
||||||
filename = queryFilename
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[YouTube] Got download URL from SpotubeDL (%s)\n", engine)
|
|
||||||
return &CobaltResponse{
|
|
||||||
Status: "tunnel",
|
|
||||||
URL: downloadURL,
|
|
||||||
Filename: filename,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (y *YouTubeDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if itemID != "" {
|
|
||||||
StartItemProgress(itemID)
|
|
||||||
defer CompleteItemProgress(itemID)
|
|
||||||
ctx = initDownloadCancel(itemID)
|
|
||||||
defer clearDownloadCancel(itemID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isDownloadCancelled(itemID) {
|
|
||||||
return ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := DoRequestWithUserAgent(y.client, req)
|
|
||||||
if err != nil {
|
|
||||||
if isDownloadCancelled(itemID) {
|
|
||||||
return ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
return fmt.Errorf("download request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedSize := resp.ContentLength
|
|
||||||
if expectedSize > 0 && itemID != "" {
|
|
||||||
SetItemBytesTotal(itemID, expectedSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
out, err := openOutputForWrite(outputPath, outputFD)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create output file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
|
||||||
|
|
||||||
var written int64
|
|
||||||
if itemID != "" {
|
|
||||||
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
|
||||||
written, err = io.Copy(progressWriter, resp.Body)
|
|
||||||
} else {
|
|
||||||
written, err = io.Copy(bufWriter, resp.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
flushErr := bufWriter.Flush()
|
|
||||||
closeErr := out.Close()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
if isDownloadCancelled(itemID) {
|
|
||||||
return ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
return fmt.Errorf("download interrupted: %w", err)
|
|
||||||
}
|
|
||||||
if flushErr != nil {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
|
||||||
}
|
|
||||||
if closeErr != nil {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if expectedSize > 0 && written != expectedSize {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[YouTube] Download completed: %d bytes written\n", written)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildYouTubeSearchURL(trackName, artistName string) string {
|
|
||||||
query := fmt.Sprintf("%s %s official audio", artistName, trackName)
|
|
||||||
return fmt.Sprintf("https://music.youtube.com/search?q=%s", url.QueryEscape(query))
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildYouTubeWatchURL(videoID string) string {
|
|
||||||
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isYouTubeVideoID(s string) bool {
|
|
||||||
if len(s) != 11 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, c := range s {
|
|
||||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func IsYouTubeURL(urlStr string) bool {
|
|
||||||
lower := strings.ToLower(urlStr)
|
|
||||||
return strings.Contains(lower, "youtube.com") ||
|
|
||||||
strings.Contains(lower, "youtu.be") ||
|
|
||||||
strings.Contains(lower, "music.youtube.com")
|
|
||||||
}
|
|
||||||
|
|
||||||
// toYouTubeMusicURL converts any YouTube URL to music.youtube.com format.
|
|
||||||
// YouTube Music URLs bypass the login requirement that affects regular YouTube videos on Cobalt.
|
|
||||||
func toYouTubeMusicURL(rawURL string) string {
|
|
||||||
videoID, err := ExtractYouTubeVideoID(rawURL)
|
|
||||||
if err != nil {
|
|
||||||
return rawURL
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ExtractYouTubeVideoID(urlStr string) (string, error) {
|
|
||||||
if strings.Contains(urlStr, "youtu.be/") {
|
|
||||||
parts := strings.Split(urlStr, "youtu.be/")
|
|
||||||
if len(parts) >= 2 {
|
|
||||||
videoID := strings.Split(parts[1], "?")[0]
|
|
||||||
videoID = strings.Split(videoID, "&")[0]
|
|
||||||
return strings.TrimSpace(videoID), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed, err := url.Parse(urlStr)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("invalid URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if v := parsed.Query().Get("v"); v != "" {
|
|
||||||
return v, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(parsed.Path, "/embed/") {
|
|
||||||
parts := strings.Split(parsed.Path, "/embed/")
|
|
||||||
if len(parts) >= 2 {
|
|
||||||
return strings.Split(parts[1], "/")[0], nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(parsed.Path, "/v/") {
|
|
||||||
parts := strings.Split(parsed.Path, "/v/")
|
|
||||||
if len(parts) >= 2 {
|
|
||||||
return strings.Split(parts[1], "/")[0], nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("could not extract video ID from URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
// searchYouTubeMusicViaExtension uses the YT Music extension's customSearch
|
|
||||||
// to find a track by artist + title. It filters for tracks only (not videos,
|
|
||||||
// albums, or playlists) and returns the YouTube Music watch URL for the first
|
|
||||||
// matching track, or "" if nothing was found.
|
|
||||||
func searchYouTubeMusicViaExtension(artistName, trackName string) string {
|
|
||||||
extManager := GetExtensionManager()
|
|
||||||
searchProviders := extManager.GetSearchProviders()
|
|
||||||
|
|
||||||
// Find the ytmusic-spotiflac extension
|
|
||||||
var ytProvider *ExtensionProviderWrapper
|
|
||||||
for _, p := range searchProviders {
|
|
||||||
if p.extension.ID == "ytmusic-spotiflac" {
|
|
||||||
ytProvider = p
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ytProvider == nil {
|
|
||||||
GoLog("[YouTube] YT Music extension not found or not enabled, skipping fallback\n")
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
query := strings.TrimSpace(artistName + " " + trackName)
|
|
||||||
if query == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[YouTube] Searching YT Music extension for: %s\n", query)
|
|
||||||
results, err := ytProvider.CustomSearch(query, map[string]interface{}{
|
|
||||||
"filter": "tracks",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
GoLog("[YouTube] YT Music extension search failed: %v\n", err)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the first track result (item_type == "track" with a valid video ID)
|
|
||||||
for _, track := range results {
|
|
||||||
if track.ItemType != "" && track.ItemType != "track" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
videoID := strings.TrimSpace(track.ID)
|
|
||||||
if videoID == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if isYouTubeVideoID(videoID) {
|
|
||||||
return BuildYouTubeWatchURL(videoID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[YouTube] YT Music extension returned no matching tracks for: %s\n", query)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
|
|
||||||
downloader := NewYouTubeDownloader()
|
|
||||||
|
|
||||||
format, bitrate, quality := parseYouTubeQualityInput(req.Quality)
|
|
||||||
|
|
||||||
// URL lookup priority: YouTube video ID > YT Music extension > SongLink (Spotify/Deezer/ISRC)
|
|
||||||
var youtubeURL string
|
|
||||||
var lookupErr error
|
|
||||||
|
|
||||||
// SpotifyID might actually be a YouTube video ID (from YT Music extension)
|
|
||||||
if req.SpotifyID != "" && isYouTubeVideoID(req.SpotifyID) {
|
|
||||||
youtubeURL = BuildYouTubeWatchURL(req.SpotifyID)
|
|
||||||
GoLog("[YouTube] SpotifyID appears to be YouTube video ID, using directly: %s\n", youtubeURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try YT Music extension search first (if installed) - more accurate, tracks only
|
|
||||||
if youtubeURL == "" && (req.TrackName != "" || req.ArtistName != "") {
|
|
||||||
youtubeURL = searchYouTubeMusicViaExtension(req.ArtistName, req.TrackName)
|
|
||||||
if youtubeURL != "" {
|
|
||||||
GoLog("[YouTube] Found YouTube URL via YT Music extension: %s\n", youtubeURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Try Spotify ID via SongLink
|
|
||||||
if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) {
|
|
||||||
GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID)
|
|
||||||
songlink := NewSongLinkClient()
|
|
||||||
youtubeURL, lookupErr = songlink.GetYouTubeURLFromSpotify(req.SpotifyID)
|
|
||||||
if lookupErr != nil {
|
|
||||||
GoLog("[YouTube] SongLink Spotify lookup failed: %v\n", lookupErr)
|
|
||||||
} else {
|
|
||||||
GoLog("[YouTube] Found YouTube URL via SongLink (Spotify): %s\n", youtubeURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Try Deezer ID via SongLink
|
|
||||||
if youtubeURL == "" && req.DeezerID != "" {
|
|
||||||
GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID)
|
|
||||||
songlink := NewSongLinkClient()
|
|
||||||
youtubeURL, lookupErr = songlink.GetYouTubeURLFromDeezer(req.DeezerID)
|
|
||||||
if lookupErr != nil {
|
|
||||||
GoLog("[YouTube] SongLink Deezer lookup failed: %v\n", lookupErr)
|
|
||||||
} else {
|
|
||||||
GoLog("[YouTube] Found YouTube URL via SongLink (Deezer): %s\n", youtubeURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Try ISRC via SongLink
|
|
||||||
if youtubeURL == "" && req.ISRC != "" {
|
|
||||||
GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC)
|
|
||||||
songlink := NewSongLinkClient()
|
|
||||||
availability, isrcErr := songlink.CheckTrackAvailability("", req.ISRC)
|
|
||||||
if isrcErr == nil && availability.YouTube && availability.YouTubeURL != "" {
|
|
||||||
youtubeURL = availability.YouTubeURL
|
|
||||||
GoLog("[YouTube] Found YouTube URL via SongLink (ISRC): %s\n", youtubeURL)
|
|
||||||
} else if isrcErr != nil {
|
|
||||||
GoLog("[YouTube] SongLink ISRC lookup failed: %v\n", isrcErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cobalt requires direct video URLs, not search URLs
|
|
||||||
if youtubeURL == "" {
|
|
||||||
return YouTubeDownloadResult{}, fmt.Errorf("could not find YouTube URL for track: %s - %s (no Spotify/Deezer ID available or track not on YouTube)", req.ArtistName, req.TrackName)
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[YouTube] Requesting download from Cobalt for: %s\n", youtubeURL)
|
|
||||||
|
|
||||||
cobaltResp, err := downloader.GetDownloadURL(youtubeURL, quality)
|
|
||||||
if err != nil {
|
|
||||||
return YouTubeDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ext := ".mp3"
|
|
||||||
if format == "opus" {
|
|
||||||
ext = ".opus"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Some SpotubeDL engines may return a different output container than requested.
|
|
||||||
// Respect the provider-reported filename to avoid saving MP3 bytes with .opus extension.
|
|
||||||
if cobaltResp != nil && cobaltResp.Filename != "" {
|
|
||||||
lowerName := strings.ToLower(strings.TrimSpace(cobaltResp.Filename))
|
|
||||||
switch {
|
|
||||||
case strings.HasSuffix(lowerName, ".mp3"):
|
|
||||||
ext = ".mp3"
|
|
||||||
format = "mp3"
|
|
||||||
case strings.HasSuffix(lowerName, ".opus"), strings.HasSuffix(lowerName, ".ogg"):
|
|
||||||
ext = ".opus"
|
|
||||||
format = "opus"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
|
|
||||||
"title": req.TrackName,
|
|
||||||
"artist": req.ArtistName,
|
|
||||||
"album": req.AlbumName,
|
|
||||||
"track": req.TrackNumber,
|
|
||||||
"year": extractYear(req.ReleaseDate),
|
|
||||||
"date": req.ReleaseDate,
|
|
||||||
"disc": req.DiscNumber,
|
|
||||||
})
|
|
||||||
filename = sanitizeFilename(filename) + ext
|
|
||||||
|
|
||||||
var outputPath string
|
|
||||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
|
||||||
if isSafOutput {
|
|
||||||
outputPath = strings.TrimSpace(req.OutputPath)
|
|
||||||
if outputPath == "" && isFDOutput(req.OutputFD) {
|
|
||||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
outputPath = req.OutputDir + "/" + filename
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[YouTube] Downloading to: %s\n", outputPath)
|
|
||||||
|
|
||||||
var parallelResult *ParallelDownloadResult
|
|
||||||
if req.EmbedLyrics || req.CoverURL != "" {
|
|
||||||
GoLog("[YouTube] Starting parallel fetch for cover and lyrics...\n")
|
|
||||||
parallelResult = FetchCoverAndLyricsParallel(
|
|
||||||
req.CoverURL,
|
|
||||||
req.EmbedMaxQualityCover,
|
|
||||||
req.SpotifyID,
|
|
||||||
req.TrackName,
|
|
||||||
req.ArtistName,
|
|
||||||
req.EmbedLyrics,
|
|
||||||
int64(req.DurationMS),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := downloader.DownloadFile(cobaltResp.URL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
|
||||||
return YouTubeDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
lyricsLRC := ""
|
|
||||||
var coverData []byte
|
|
||||||
if parallelResult != nil {
|
|
||||||
if parallelResult.LyricsLRC != "" {
|
|
||||||
lyricsLRC = parallelResult.LyricsLRC
|
|
||||||
GoLog("[YouTube] Got lyrics from lrclib (%d lines)\n", len(parallelResult.LyricsData.Lines))
|
|
||||||
}
|
|
||||||
if parallelResult.CoverData != nil {
|
|
||||||
coverData = parallelResult.CoverData
|
|
||||||
GoLog("[YouTube] Got cover art (%d bytes)\n", len(coverData))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return YouTubeDownloadResult{
|
|
||||||
FilePath: outputPath,
|
|
||||||
Title: req.TrackName,
|
|
||||||
Artist: req.ArtistName,
|
|
||||||
Album: req.AlbumName,
|
|
||||||
ReleaseDate: req.ReleaseDate,
|
|
||||||
TrackNumber: req.TrackNumber,
|
|
||||||
DiscNumber: req.DiscNumber,
|
|
||||||
ISRC: req.ISRC,
|
|
||||||
Format: format,
|
|
||||||
Bitrate: bitrate,
|
|
||||||
LyricsLRC: lyricsLRC,
|
|
||||||
CoverData: coverData,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestParseYouTubeQualityInput_OpusNormalizesToSupportedBitrates(t *testing.T) {
|
|
||||||
format, bitrate, normalized := parseYouTubeQualityInput("opus_160")
|
|
||||||
if format != "opus" {
|
|
||||||
t.Fatalf("expected opus format, got %s", format)
|
|
||||||
}
|
|
||||||
if bitrate != 128 {
|
|
||||||
t.Fatalf("expected 128 bitrate, got %d", bitrate)
|
|
||||||
}
|
|
||||||
if normalized != YouTubeQualityOpus128 {
|
|
||||||
t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus128, normalized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseYouTubeQualityInput_Mp3NormalizesToSupportedBitrates(t *testing.T) {
|
|
||||||
format, bitrate, normalized := parseYouTubeQualityInput("mp3_192")
|
|
||||||
if format != "mp3" {
|
|
||||||
t.Fatalf("expected mp3 format, got %s", format)
|
|
||||||
}
|
|
||||||
if bitrate != 256 {
|
|
||||||
t.Fatalf("expected 256 bitrate, got %d", bitrate)
|
|
||||||
}
|
|
||||||
if normalized != YouTubeQualityMP3256 {
|
|
||||||
t.Fatalf("expected %s normalized, got %s", YouTubeQualityMP3256, normalized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
|
|
||||||
_, opusBitrate, _ := parseYouTubeQualityInput("opus_999")
|
|
||||||
if opusBitrate != 320 {
|
|
||||||
t.Fatalf("expected opus normalization to 320, got %d", opusBitrate)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1")
|
|
||||||
if mp3Bitrate != 128 {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+33
@@ -27,6 +27,37 @@ require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelpe
|
|||||||
|
|
||||||
flutter_ios_podfile_setup
|
flutter_ios_podfile_setup
|
||||||
|
|
||||||
|
def patch_device_info_plus_vision_selector
|
||||||
|
plugin_file = File.join(
|
||||||
|
__dir__,
|
||||||
|
'.symlinks',
|
||||||
|
'plugins',
|
||||||
|
'device_info_plus',
|
||||||
|
'ios',
|
||||||
|
'device_info_plus',
|
||||||
|
'Sources',
|
||||||
|
'device_info_plus',
|
||||||
|
'FPPDeviceInfoPlusPlugin.m'
|
||||||
|
)
|
||||||
|
return unless File.exist?(plugin_file)
|
||||||
|
|
||||||
|
source = File.read(plugin_file)
|
||||||
|
return if source.include?('FPPDeviceInfoPlusVisionCompat')
|
||||||
|
|
||||||
|
marker = "#import <sys/utsname.h>\n"
|
||||||
|
declaration = <<~OBJC
|
||||||
|
|
||||||
|
// Older Xcode SDKs do not declare this selector yet, but device_info_plus
|
||||||
|
// only calls it behind an availability check.
|
||||||
|
@interface NSProcessInfo (FPPDeviceInfoPlusVisionCompat)
|
||||||
|
- (BOOL)isiOSAppOnVision;
|
||||||
|
@end
|
||||||
|
OBJC
|
||||||
|
|
||||||
|
patched = source.sub(marker, "#{marker}#{declaration}\n")
|
||||||
|
File.write(plugin_file, patched) if patched != source
|
||||||
|
end
|
||||||
|
|
||||||
target 'Runner' do
|
target 'Runner' do
|
||||||
use_frameworks!
|
use_frameworks!
|
||||||
use_modular_headers!
|
use_modular_headers!
|
||||||
@@ -42,6 +73,8 @@ target 'RunnerTests' do
|
|||||||
end
|
end
|
||||||
|
|
||||||
post_install do |installer|
|
post_install do |installer|
|
||||||
|
patch_device_info_plus_vision_selector
|
||||||
|
|
||||||
installer.pods_project.targets.each do |target|
|
installer.pods_project.targets.each do |target|
|
||||||
flutter_additional_ios_build_settings(target)
|
flutter_additional_ios_build_settings(target)
|
||||||
target.build_configurations.each do |config|
|
target.build_configurations.each do |config|
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ import Gobackend // Import Go framework
|
|||||||
}
|
}
|
||||||
self.lastDownloadProgressPayload = payload
|
self.lastDownloadProgressPayload = payload
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
self?.downloadProgressEventSink?(payload)
|
self?.downloadProgressEventSink?(self?.parseJsonPayload(payload))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
downloadProgressTimer = timer
|
downloadProgressTimer = timer
|
||||||
@@ -119,7 +119,7 @@ import Gobackend // Import Go framework
|
|||||||
}
|
}
|
||||||
self.lastLibraryScanProgressPayload = payload
|
self.lastLibraryScanProgressPayload = payload
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
self?.libraryScanProgressEventSink?(payload)
|
self?.libraryScanProgressEventSink?(self?.parseJsonPayload(payload))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
libraryScanProgressTimer = timer
|
libraryScanProgressTimer = timer
|
||||||
@@ -133,6 +133,17 @@ import Gobackend // Import Go framework
|
|||||||
libraryScanProgressEventSink = nil
|
libraryScanProgressEventSink = nil
|
||||||
lastLibraryScanProgressPayload = nil
|
lastLibraryScanProgressPayload = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func parseJsonPayload(_ payload: String) -> Any {
|
||||||
|
guard let data = payload.data(using: .utf8) else {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
return try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
|
||||||
|
} catch {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func handleMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
private func handleMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
@@ -153,13 +164,6 @@ import Gobackend // Import Go framework
|
|||||||
var error: NSError?
|
var error: NSError?
|
||||||
|
|
||||||
switch call.method {
|
switch call.method {
|
||||||
case "parseSpotifyUrl":
|
|
||||||
let args = call.arguments as! [String: Any]
|
|
||||||
let url = args["url"] as! String
|
|
||||||
let response = GobackendParseSpotifyURL(url, &error)
|
|
||||||
if let error = error { throw error }
|
|
||||||
return response
|
|
||||||
|
|
||||||
case "checkAvailability":
|
case "checkAvailability":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let spotifyId = args["spotify_id"] as! String
|
let spotifyId = args["spotify_id"] as! String
|
||||||
@@ -176,11 +180,11 @@ import Gobackend // Import Go framework
|
|||||||
|
|
||||||
case "getDownloadProgress":
|
case "getDownloadProgress":
|
||||||
let response = GobackendGetDownloadProgress()
|
let response = GobackendGetDownloadProgress()
|
||||||
return response
|
return parseJsonPayload(response as String? ?? "{}")
|
||||||
|
|
||||||
case "getAllDownloadProgress":
|
case "getAllDownloadProgress":
|
||||||
let response = GobackendGetAllDownloadProgress()
|
let response = GobackendGetAllDownloadProgress()
|
||||||
return response
|
return parseJsonPayload(response as String? ?? "{}")
|
||||||
|
|
||||||
case "initItemProgress":
|
case "initItemProgress":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
@@ -303,6 +307,15 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "rewriteSplitArtistTags":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let filePath = args["file_path"] as! String
|
||||||
|
let artist = args["artist"] as! String
|
||||||
|
let albumArtist = args["album_artist"] as! String
|
||||||
|
let response = GobackendRewriteSplitArtistTagsExport(filePath, artist, albumArtist, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
case "cleanupConnections":
|
case "cleanupConnections":
|
||||||
GobackendCleanupConnections()
|
GobackendCleanupConnections()
|
||||||
return nil
|
return nil
|
||||||
@@ -331,7 +344,8 @@ import Gobackend // Import Go framework
|
|||||||
let spotifyId = args["spotify_id"] as! String
|
let spotifyId = args["spotify_id"] as! String
|
||||||
let durationMs = args["duration_ms"] as? Int64 ?? 0
|
let durationMs = args["duration_ms"] as? Int64 ?? 0
|
||||||
let outputPath = args["output_path"] as! String
|
let outputPath = args["output_path"] as! String
|
||||||
GobackendFetchAndSaveLyrics(trackName, artistName, spotifyId, durationMs, outputPath, &error)
|
let audioFilePath = args["audio_file_path"] as? String ?? ""
|
||||||
|
GobackendFetchAndSaveLyrics(trackName, artistName, spotifyId, durationMs, outputPath, audioFilePath, &error)
|
||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return "{\"success\":true}"
|
return "{\"success\":true}"
|
||||||
|
|
||||||
@@ -367,6 +381,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
|
||||||
@@ -449,13 +483,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
case "getSpotifyMetadataWithFallback":
|
|
||||||
let args = call.arguments as! [String: Any]
|
|
||||||
let url = args["url"] as! String
|
|
||||||
let response = GobackendGetSpotifyMetadataWithDeezerFallback(url, &error)
|
|
||||||
if let error = error { throw error }
|
|
||||||
return response
|
|
||||||
|
|
||||||
case "checkAvailabilityFromDeezerID":
|
case "checkAvailabilityFromDeezerID":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let deezerTrackId = args["deezer_track_id"] as! String
|
let deezerTrackId = args["deezer_track_id"] as! String
|
||||||
@@ -580,6 +607,13 @@ import Gobackend // Import Go framework
|
|||||||
let response = GobackendGetProviderPriorityJSON(&error)
|
let response = GobackendGetProviderPriorityJSON(&error)
|
||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "setDownloadFallbackExtensionIds":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionIdsJson = args["extension_ids"] as? String ?? ""
|
||||||
|
GobackendSetExtensionFallbackProviderIDsJSON(extensionIdsJson, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return nil
|
||||||
|
|
||||||
case "setMetadataProviderPriority":
|
case "setMetadataProviderPriority":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
@@ -917,7 +951,7 @@ import Gobackend // Import Go framework
|
|||||||
|
|
||||||
case "getLibraryScanProgress":
|
case "getLibraryScanProgress":
|
||||||
let response = GobackendGetLibraryScanProgressJSON()
|
let response = GobackendGetLibraryScanProgressJSON()
|
||||||
return response
|
return parseJsonPayload(response as String? ?? "{}")
|
||||||
|
|
||||||
case "cancelLibraryScan":
|
case "cancelLibraryScan":
|
||||||
GobackendCancelLibraryScanJSON()
|
GobackendCancelLibraryScanJSON()
|
||||||
|
|||||||
@@ -3,24 +3,24 @@ 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.8.7';
|
static const String version = '4.2.2';
|
||||||
static const String buildNumber = '113';
|
static const String buildNumber = '123';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
/// Shows "Internal" in debug builds, actual version in release.
|
/// Shows "Internal" in debug builds, actual version in release.
|
||||||
static String get displayVersion => kDebugMode ? 'Internal' : version;
|
static String get displayVersion => kDebugMode ? 'Internal' : version;
|
||||||
|
|
||||||
|
static const String appName = 'SpotiFLAC Mobile';
|
||||||
static const String appName = 'SpotiFLAC';
|
|
||||||
static const String copyright = '© 2026 SpotiFLAC';
|
static const String copyright = '© 2026 SpotiFLAC';
|
||||||
|
|
||||||
static const String mobileAuthor = 'zarzet';
|
static const String mobileAuthor = 'zarzet';
|
||||||
static const String originalAuthor = 'afkarxyz';
|
static const String originalAuthor = 'afkarxyz';
|
||||||
|
|
||||||
static const String githubRepo = 'zarzet/SpotiFLAC-Mobile';
|
static const String githubRepo = 'zarzet/SpotiFLAC-Mobile';
|
||||||
static const String githubUrl = 'https://github.com/$githubRepo';
|
static const String githubUrl = 'https://github.com/$githubRepo';
|
||||||
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
|
static const String originalGithubUrl =
|
||||||
|
'https://github.com/afkarxyz/SpotiFLAC';
|
||||||
|
|
||||||
static const String kofiUrl = 'https://ko-fi.com/zarzet';
|
static const String kofiUrl = 'https://ko-fi.com/zarzet';
|
||||||
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
|
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
|
||||||
}
|
}
|
||||||
|
|||||||
+846
-32
File diff suppressed because it is too large
Load Diff
@@ -76,6 +76,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadFilenameFormat => 'Dateinamenformat';
|
String get downloadFilenameFormat => 'Dateinamenformat';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSingleFilenameFormat => 'Single Filename Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSingleFilenameFormatDescription =>
|
||||||
|
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadFolderOrganization => 'Ordnerstruktur';
|
String get downloadFolderOrganization => 'Ordnerstruktur';
|
||||||
|
|
||||||
@@ -158,6 +165,38 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'Cover in höchster Auflösung herunterladen';
|
'Cover in höchster Auflösung herunterladen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGain => 'ReplayGain';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGainSubtitleOn =>
|
||||||
|
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGainSubtitleOff =>
|
||||||
|
'Disabled: no loudness normalization tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeDescription =>
|
||||||
|
'Choose how multiple artists are written into embedded tags.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoinedSubtitle =>
|
||||||
|
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||||
|
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsConcurrentDownloads => 'Parallele Downloads';
|
String get optionsConcurrentDownloads => 'Parallele Downloads';
|
||||||
|
|
||||||
@@ -365,7 +404,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 +480,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';
|
||||||
@@ -705,15 +744,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) {
|
||||||
@@ -750,7 +789,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) {
|
||||||
@@ -767,11 +806,41 @@ 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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitle => 'Sort Results';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDefault => 'Default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleZA => 'Title (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationLong => 'Duration (Longest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipPlay => 'Abspielen';
|
String get tooltipPlay => 'Abspielen';
|
||||||
|
|
||||||
@@ -789,11 +858,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';
|
||||||
@@ -810,7 +879,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 =>
|
||||||
@@ -871,6 +940,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get providerPriorityInfo =>
|
String get providerPriorityInfo =>
|
||||||
'Wenn kein Titel bei dem ersten Anbieter nicht verfügbar ist, wird die App automatisch den nächsten versuchen.';
|
'Wenn kein Titel bei dem ersten Anbieter nicht verfügbar ist, wird die App automatisch den nächsten versuchen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsDescription =>
|
||||||
|
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsHint =>
|
||||||
|
'Only enabled extensions with download-provider capability are listed here.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get providerBuiltIn => 'Integriert';
|
String get providerBuiltIn => 'Integriert';
|
||||||
|
|
||||||
@@ -1150,6 +1230,12 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get trackLyricsNotAvailable =>
|
String get trackLyricsNotAvailable =>
|
||||||
'Lyrics sind für diesen Titel nicht verfügbar';
|
'Lyrics sind für diesen Titel nicht verfügbar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLyricsNotInFile => 'No lyrics found in this file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackFetchOnlineLyrics => 'Fetch from Online';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackLyricsTimeout =>
|
String get trackLyricsTimeout =>
|
||||||
'Anfrage Timeout. Versuche es später erneut.';
|
'Anfrage Timeout. Versuche es später erneut.';
|
||||||
@@ -1251,7 +1337,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1363,6 +1449,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get extensionsDownloadPrioritySubtitle =>
|
String get extensionsDownloadPrioritySubtitle =>
|
||||||
'Download-Service-Reihenfolge festlegen';
|
'Download-Service-Reihenfolge festlegen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackSubtitle =>
|
||||||
|
'Choose which installed download extensions can be used as fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsNoDownloadProvider =>
|
String get extensionsNoDownloadProvider =>
|
||||||
'Keine Erweiterungen mit Download-Provider';
|
'Keine Erweiterungen mit Download-Provider';
|
||||||
@@ -1413,37 +1506,60 @@ 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';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube bietet nur verlustbehaftete Audioqualität. Deswegen ist es kein Teil des verlustfreien Fallbacks.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
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 =>
|
||||||
@@ -1451,7 +1567,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';
|
||||||
@@ -1473,7 +1589,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 =>
|
||||||
@@ -1496,14 +1613,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';
|
||||||
@@ -1515,14 +1632,21 @@ 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
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Ausgewählte löschen';
|
String get downloadedAlbumDeleteSelected => 'Ausgewählte löschen';
|
||||||
@@ -1561,7 +1685,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) {
|
||||||
@@ -1607,7 +1731,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
|
||||||
@@ -1623,14 +1747,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
|
||||||
@@ -1643,7 +1767,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
|
||||||
@@ -1655,7 +1779,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';
|
||||||
@@ -1664,14 +1788,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 =>
|
||||||
@@ -1685,13 +1809,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';
|
||||||
@@ -1704,7 +1830,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';
|
||||||
@@ -1713,7 +1839,7 @@ 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 =>
|
||||||
@@ -1788,6 +1914,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryFilesUnit(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'files',
|
||||||
|
one: 'file',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryLastScanned(String time) {
|
String libraryLastScanned(String time) {
|
||||||
return 'Zuletzt gescannt: $time';
|
return 'Zuletzt gescannt: $time';
|
||||||
@@ -1799,6 +1936,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryScanning => 'Scannen...';
|
String get libraryScanning => 'Scannen...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanFinalizing => 'Finalizing library...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryScanProgress(String progress, int total) {
|
String libraryScanProgress(String progress, int total) {
|
||||||
return '$progress% von $total Dateien';
|
return '$progress% von $total Dateien';
|
||||||
@@ -1867,6 +2007,24 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterFormat => 'Format';
|
String get libraryFilterFormat => 'Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadata => 'Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterSort => 'Sortieren';
|
String get libraryFilterSort => 'Sortieren';
|
||||||
|
|
||||||
@@ -1876,6 +2034,18 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterSortOldest => 'Älteste';
|
String get libraryFilterSortOldest => 'Älteste';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get timeJustNow => 'Gerade eben';
|
String get timeJustNow => 'Gerade eben';
|
||||||
|
|
||||||
@@ -1914,7 +2084,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 =>
|
||||||
@@ -1981,7 +2151,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 =>
|
||||||
@@ -2039,14 +2209,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 =>
|
||||||
@@ -2162,16 +2332,40 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get trackReEnrichOnlineSubtitle =>
|
String get trackReEnrichOnlineSubtitle =>
|
||||||
'Metadaten online suchen und in Datei einbinden';
|
'Metadaten online suchen und in Datei einbinden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldsTitle => 'Fields to update';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldCover => 'Cover Art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSelectAll => 'Select All';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackEditMetadata => 'Metadaten bearbeiten';
|
String get trackEditMetadata => 'Metadaten bearbeiten';
|
||||||
|
|
||||||
@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) {
|
||||||
@@ -2269,10 +2463,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) {
|
||||||
@@ -2281,40 +2475,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';
|
||||||
@@ -2539,11 +2734,11 @@ 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
|
@override
|
||||||
String get lyricsProvidersTitle => 'Lyrics Providers';
|
String get lyricsProvidersTitle => 'Lyrics Providers';
|
||||||
@@ -2577,10 +2772,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get lyricsProvidersDiscardContent =>
|
String get lyricsProvidersDiscardContent =>
|
||||||
'You have unsaved changes that will be lost.';
|
'You have unsaved changes that will be lost.';
|
||||||
|
|
||||||
@override
|
|
||||||
String get lyricsProviderSpotifyApiDesc =>
|
|
||||||
'Spotify-sourced synced lyrics via community API';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||||
|
|
||||||
@@ -2712,6 +2903,22 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
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
|
@override
|
||||||
String get downloadSongLinkRegion => 'SongLink Region';
|
String get downloadSongLinkRegion => 'SongLink Region';
|
||||||
|
|
||||||
@@ -2942,4 +3149,294 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
String get editMetadataSelectEmpty => 'Empty only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueDownloadingCount(int count) {
|
||||||
|
return 'Downloading ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueDownloadedHeader => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFilteringIndicator => 'Filtering...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueTrackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueAlbumCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count albums',
|
||||||
|
one: '1 album',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbums => 'No album downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbumsSubtitle =>
|
||||||
|
'Download multiple tracks from an album to see them here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySingles => 'No single downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySinglesSubtitle =>
|
||||||
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistory => 'No download history';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDescription =>
|
||||||
|
'Verify lossless quality with spectrum analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisChannels => 'Channels';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDuration => 'Duration';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisNyquist => 'Nyquist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisFileSize => 'Size';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisPeak => 'Peak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisRms => 'RMS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSamples => 'Samples';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,13 +21,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get navSettings => 'Settings';
|
String get navSettings => 'Settings';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navStore => 'Store';
|
String get navStore => 'Repo';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeTitle => 'Home';
|
String get homeTitle => 'Home';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSubtitle => 'Paste a Spotify link or search by name';
|
String get homeSubtitle => 'Paste a supported URL or search by name';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
|
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
|
||||||
@@ -75,6 +75,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadFilenameFormat => 'Filename Format';
|
String get downloadFilenameFormat => 'Filename Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSingleFilenameFormat => 'Single Filename Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSingleFilenameFormatDescription =>
|
||||||
|
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadFolderOrganization => 'Folder Organization';
|
String get downloadFolderOrganization => 'Folder Organization';
|
||||||
|
|
||||||
@@ -154,6 +161,38 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'Download highest resolution cover art';
|
'Download highest resolution cover art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGain => 'ReplayGain';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGainSubtitleOn =>
|
||||||
|
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGainSubtitleOff =>
|
||||||
|
'Disabled: no loudness normalization tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeDescription =>
|
||||||
|
'Choose how multiple artists are written into embedded tags.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoinedSubtitle =>
|
||||||
|
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||||
|
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
||||||
|
|
||||||
@@ -170,10 +209,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
'Parallel downloads may trigger rate limiting';
|
'Parallel downloads may trigger rate limiting';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsExtensionStore => 'Extension Store';
|
String get optionsExtensionStore => 'Extension Repo';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation';
|
String get optionsExtensionStoreSubtitle => 'Show Repo tab in navigation';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsCheckUpdates => 'Check for Updates';
|
String get optionsCheckUpdates => 'Check for Updates';
|
||||||
@@ -250,7 +289,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get extensionsUninstall => 'Uninstall';
|
String get extensionsUninstall => 'Uninstall';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeTitle => 'Extension Store';
|
String get storeTitle => 'Extension Repo';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeSearch => 'Search extensions...';
|
String get storeSearch => 'Search extensions...';
|
||||||
@@ -759,6 +798,36 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Playlists';
|
String get searchPlaylists => 'Playlists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitle => 'Sort Results';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDefault => 'Default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleZA => 'Title (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationLong => 'Duration (Longest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipPlay => 'Play';
|
String get tooltipPlay => 'Play';
|
||||||
|
|
||||||
@@ -857,6 +926,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get providerPriorityInfo =>
|
String get providerPriorityInfo =>
|
||||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsDescription =>
|
||||||
|
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsHint =>
|
||||||
|
'Only enabled extensions with download-provider capability are listed here.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get providerBuiltIn => 'Built-in';
|
String get providerBuiltIn => 'Built-in';
|
||||||
|
|
||||||
@@ -1131,6 +1211,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLyricsNotInFile => 'No lyrics found in this file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackFetchOnlineLyrics => 'Fetch from Online';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackLyricsTimeout => 'Request timed out. Try again later.';
|
String get trackLyricsTimeout => 'Request timed out. Try again later.';
|
||||||
|
|
||||||
@@ -1231,7 +1317,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1240,7 +1326,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get storeEmptyNoResults => 'No extensions found';
|
String get storeEmptyNoResults => 'No extensions found';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
|
String get extensionDefaultProvider => 'Default (Deezer)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionDefaultProviderSubtitle => 'Use built-in search';
|
String get extensionDefaultProviderSubtitle => 'Use built-in search';
|
||||||
@@ -1340,6 +1426,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackSubtitle =>
|
||||||
|
'Choose which installed download extensions can be used as fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsNoDownloadProvider =>
|
String get extensionsNoDownloadProvider =>
|
||||||
'No extensions with download provider';
|
'No extensions with download provider';
|
||||||
@@ -1389,20 +1482,42 @@ 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';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1500,6 +1615,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -1764,6 +1886,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryFilesUnit(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'files',
|
||||||
|
one: 'file',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryLastScanned(String time) {
|
String libraryLastScanned(String time) {
|
||||||
return 'Last scanned: $time';
|
return 'Last scanned: $time';
|
||||||
@@ -1775,6 +1908,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryScanning => 'Scanning...';
|
String get libraryScanning => 'Scanning...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanFinalizing => 'Finalizing library...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryScanProgress(String progress, int total) {
|
String libraryScanProgress(String progress, int total) {
|
||||||
return '$progress% of $total files';
|
return '$progress% of $total files';
|
||||||
@@ -1843,6 +1979,24 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterFormat => 'Format';
|
String get libraryFilterFormat => 'Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadata => 'Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterSort => 'Sort';
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
@@ -1852,6 +2006,18 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterSortOldest => 'Oldest';
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get timeJustNow => 'Just now';
|
String get timeJustNow => 'Just now';
|
||||||
|
|
||||||
@@ -1938,7 +2104,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip1 =>
|
String get tutorialExtensionsTip1 =>
|
||||||
'Browse the Store tab to discover useful extensions';
|
'Browse the Repo tab to discover useful extensions';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip2 =>
|
String get tutorialExtensionsTip2 =>
|
||||||
@@ -2136,6 +2302,30 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get trackReEnrichOnlineSubtitle =>
|
String get trackReEnrichOnlineSubtitle =>
|
||||||
'Search metadata online and embed into file';
|
'Search metadata online and embed into file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldsTitle => 'Fields to update';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldCover => 'Cover Art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSelectAll => 'Select All';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackEditMetadata => 'Edit Metadata';
|
String get trackEditMetadata => 'Edit Metadata';
|
||||||
|
|
||||||
@@ -2550,10 +2740,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get lyricsProvidersDiscardContent =>
|
String get lyricsProvidersDiscardContent =>
|
||||||
'You have unsaved changes that will be lost.';
|
'You have unsaved changes that will be lost.';
|
||||||
|
|
||||||
@override
|
|
||||||
String get lyricsProviderSpotifyApiDesc =>
|
|
||||||
'Spotify-sourced synced lyrics via community API';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||||
|
|
||||||
@@ -2685,6 +2871,22 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
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
|
@override
|
||||||
String get downloadSongLinkRegion => 'SongLink Region';
|
String get downloadSongLinkRegion => 'SongLink Region';
|
||||||
|
|
||||||
@@ -2915,4 +3117,294 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
String get editMetadataSelectEmpty => 'Empty only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueDownloadingCount(int count) {
|
||||||
|
return 'Downloading ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueDownloadedHeader => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFilteringIndicator => 'Filtering...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueTrackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueAlbumCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count albums',
|
||||||
|
one: '1 album',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbums => 'No album downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbumsSubtitle =>
|
||||||
|
'Download multiple tracks from an album to see them here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySingles => 'No single downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySinglesSubtitle =>
|
||||||
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistory => 'No download history';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDescription =>
|
||||||
|
'Verify lossless quality with spectrum analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisChannels => 'Channels';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDuration => 'Duration';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisNyquist => 'Nyquist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisFileSize => 'Size';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisPeak => 'Peak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisRms => 'RMS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSamples => 'Samples';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,13 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadFilenameFormat => 'Filename Format';
|
String get downloadFilenameFormat => 'Filename Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSingleFilenameFormat => 'Single Filename Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSingleFilenameFormatDescription =>
|
||||||
|
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadFolderOrganization => 'Folder Organization';
|
String get downloadFolderOrganization => 'Folder Organization';
|
||||||
|
|
||||||
@@ -154,6 +161,38 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'Download highest resolution cover art';
|
'Download highest resolution cover art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGain => 'ReplayGain';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGainSubtitleOn =>
|
||||||
|
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGainSubtitleOff =>
|
||||||
|
'Disabled: no loudness normalization tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeDescription =>
|
||||||
|
'Choose how multiple artists are written into embedded tags.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoinedSubtitle =>
|
||||||
|
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||||
|
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
||||||
|
|
||||||
@@ -759,6 +798,36 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Playlists';
|
String get searchPlaylists => 'Playlists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitle => 'Sort Results';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDefault => 'Default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleZA => 'Title (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationLong => 'Duration (Longest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipPlay => 'Play';
|
String get tooltipPlay => 'Play';
|
||||||
|
|
||||||
@@ -857,6 +926,17 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get providerPriorityInfo =>
|
String get providerPriorityInfo =>
|
||||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsDescription =>
|
||||||
|
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsHint =>
|
||||||
|
'Only enabled extensions with download-provider capability are listed here.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get providerBuiltIn => 'Built-in';
|
String get providerBuiltIn => 'Built-in';
|
||||||
|
|
||||||
@@ -1131,6 +1211,12 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLyricsNotInFile => 'No lyrics found in this file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackFetchOnlineLyrics => 'Fetch from Online';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackLyricsTimeout => 'Request timed out. Try again later.';
|
String get trackLyricsTimeout => 'Request timed out. Try again later.';
|
||||||
|
|
||||||
@@ -1231,7 +1317,7 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1340,6 +1426,13 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackSubtitle =>
|
||||||
|
'Choose which installed download extensions can be used as fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsNoDownloadProvider =>
|
String get extensionsNoDownloadProvider =>
|
||||||
'No extensions with download provider';
|
'No extensions with download provider';
|
||||||
@@ -1389,20 +1482,42 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossy320 => 'Lossy 320kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyFormat => 'Lossy Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossy320FormatDesc =>
|
||||||
|
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus256Subtitle =>
|
||||||
|
'Best quality Opus, ~8MB per track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1500,6 +1615,13 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -1764,6 +1886,17 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryFilesUnit(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'files',
|
||||||
|
one: 'file',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryLastScanned(String time) {
|
String libraryLastScanned(String time) {
|
||||||
return 'Last scanned: $time';
|
return 'Last scanned: $time';
|
||||||
@@ -1775,6 +1908,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryScanning => 'Scanning...';
|
String get libraryScanning => 'Scanning...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanFinalizing => 'Finalizing library...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryScanProgress(String progress, int total) {
|
String libraryScanProgress(String progress, int total) {
|
||||||
return '$progress% of $total files';
|
return '$progress% of $total files';
|
||||||
@@ -1843,6 +1979,24 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterFormat => 'Format';
|
String get libraryFilterFormat => 'Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadata => 'Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterSort => 'Sort';
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
@@ -1852,6 +2006,18 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterSortOldest => 'Oldest';
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get timeJustNow => 'Just now';
|
String get timeJustNow => 'Just now';
|
||||||
|
|
||||||
@@ -1938,7 +2104,7 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip1 =>
|
String get tutorialExtensionsTip1 =>
|
||||||
'Browse the Store tab to discover useful extensions';
|
'Browse the Repo tab to discover useful extensions';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip2 =>
|
String get tutorialExtensionsTip2 =>
|
||||||
@@ -2136,6 +2302,30 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get trackReEnrichOnlineSubtitle =>
|
String get trackReEnrichOnlineSubtitle =>
|
||||||
'Search metadata online and embed into file';
|
'Search metadata online and embed into file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldsTitle => 'Fields to update';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldCover => 'Cover Art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSelectAll => 'Select All';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackEditMetadata => 'Edit Metadata';
|
String get trackEditMetadata => 'Edit Metadata';
|
||||||
|
|
||||||
@@ -2550,10 +2740,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get lyricsProvidersDiscardContent =>
|
String get lyricsProvidersDiscardContent =>
|
||||||
'You have unsaved changes that will be lost.';
|
'You have unsaved changes that will be lost.';
|
||||||
|
|
||||||
@override
|
|
||||||
String get lyricsProviderSpotifyApiDesc =>
|
|
||||||
'Spotify-sourced synced lyrics via community API';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||||
|
|
||||||
@@ -2685,6 +2871,22 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
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
|
@override
|
||||||
String get downloadSongLinkRegion => 'SongLink Region';
|
String get downloadSongLinkRegion => 'SongLink Region';
|
||||||
|
|
||||||
@@ -2915,6 +3117,296 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
String get editMetadataSelectEmpty => 'Empty only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueDownloadingCount(int count) {
|
||||||
|
return 'Downloading ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueDownloadedHeader => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFilteringIndicator => 'Filtering...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueTrackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueAlbumCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count albums',
|
||||||
|
one: '1 album',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbums => 'No album downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbumsSubtitle =>
|
||||||
|
'Download multiple tracks from an album to see them here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySingles => 'No single downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySinglesSubtitle =>
|
||||||
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistory => 'No download history';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDescription =>
|
||||||
|
'Verify lossless quality with spectrum analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisChannels => 'Channels';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDuration => 'Duration';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisNyquist => 'Nyquist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisFileSize => 'Size';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisPeak => 'Peak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisRms => 'RMS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSamples => 'Samples';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
||||||
@@ -3278,7 +3770,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutAppDescription =>
|
String get aboutAppDescription =>
|
||||||
'Descarga pistas de Spotify con calidad sin pérdida de Tidal y Qobuz.';
|
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get artistAlbums => 'Álbumes';
|
String get artistAlbums => 'Álbumes';
|
||||||
@@ -3613,6 +4105,17 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
|
|||||||
@override
|
@override
|
||||||
String get errorNoTracksFound => 'No se encontraron pistas';
|
String get errorNoTracksFound => 'No se encontraron pistas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlNotRecognized => 'Link not recognized';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlNotRecognizedMessage =>
|
||||||
|
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlFetchFailed =>
|
||||||
|
'Failed to load content from this link. Please try again.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String errorMissingExtensionSource(String item) {
|
String errorMissingExtensionSource(String item) {
|
||||||
return 'No se puede cargar $item: falta una fuente de extensión';
|
return 'No se puede cargar $item: falta una fuente de extensión';
|
||||||
@@ -3676,9 +4179,23 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
|
|||||||
@override
|
@override
|
||||||
String get filenameFormat => 'Formato del nombre del archivo';
|
String get filenameFormat => 'Formato del nombre del archivo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTagsDescription =>
|
||||||
|
'Enable formatted tags for track padding and date patterns';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganizationNone => 'Ninguna organización';
|
String get folderOrganizationNone => 'Ninguna organización';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get folderOrganizationByPlaylist => 'By Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get folderOrganizationByPlaylistSubtitle =>
|
||||||
|
'Separate folder for each playlist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganizationByArtist => 'Por Artista';
|
String get folderOrganizationByArtist => 'Por Artista';
|
||||||
|
|
||||||
@@ -4261,10 +4778,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'La calidad real depende de la disponibilidad de la pista del servicio';
|
'La calidad real depende de la disponibilidad de la pista del servicio';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Preguntar antes de descargar';
|
String get downloadAskBeforeDownload => 'Preguntar antes de descargar';
|
||||||
|
|
||||||
@@ -4597,6 +5110,17 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
|
|||||||
String get libraryAboutDescription =>
|
String get libraryAboutDescription =>
|
||||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryTracksUnit(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryLastScanned(String time) {
|
String libraryLastScanned(String time) {
|
||||||
return 'Last scanned: $time';
|
return 'Last scanned: $time';
|
||||||
@@ -4723,7 +5247,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip2 =>
|
String get tutorialWelcomeTip2 =>
|
||||||
'Obtén audio en calidad FLAC de Tidal, Qobuz o Deezer';
|
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip3 =>
|
String get tutorialWelcomeTip3 =>
|
||||||
@@ -5040,6 +5564,258 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
|
|||||||
@override
|
@override
|
||||||
String get trackConvertFailed => 'Conversion failed';
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitTitle => 'Split CUE Sheet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitAlbum(String album) {
|
||||||
|
return 'Album: $album';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitArtist(String artist) {
|
||||||
|
return 'Artist: $artist';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitTrackCount(int count) {
|
||||||
|
return '$count tracks';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitConfirmTitle => 'Split CUE Album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitConfirmMessage(String album, int count) {
|
||||||
|
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitSplitting(int current, int total) {
|
||||||
|
return 'Splitting CUE sheet... ($current/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitSuccess(int count) {
|
||||||
|
return 'Split into $count tracks successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitFailed => 'CUE split failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitButton => 'Split into Tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionCreate => 'Create';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionFoldersTitle => 'My folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionWishlist => 'Wishlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionLoved => 'Loved';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionPlaylists => 'Playlists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionPlaylist => 'Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionAddToPlaylist => 'Add to playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionCreatePlaylist => 'Create playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionNoPlaylistsYet => 'No playlists yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionNoPlaylistsSubtitle =>
|
||||||
|
'Create a playlist to start categorizing tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String collectionPlaylistTracks(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String collectionAddedToPlaylist(String playlistName) {
|
||||||
|
return 'Added to \"$playlistName\"';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String collectionAlreadyInPlaylist(String playlistName) {
|
||||||
|
return 'Already in \"$playlistName\"';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionPlaylistCreated => 'Playlist created';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionPlaylistNameHint => 'Playlist name';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionPlaylistNameRequired => 'Playlist name is required';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionRenamePlaylist => 'Rename playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionDeletePlaylist => 'Delete playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String collectionDeletePlaylistMessage(String playlistName) {
|
||||||
|
return 'Delete \"$playlistName\" and all tracks inside it?';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionPlaylistDeleted => 'Playlist deleted';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionPlaylistRenamed => 'Playlist renamed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionWishlistEmptySubtitle =>
|
||||||
|
'Tap + on tracks to save what you want to download later';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionLovedEmptyTitle => 'Loved folder is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionLovedEmptySubtitle =>
|
||||||
|
'Tap love on tracks to keep your favorites';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionPlaylistEmptySubtitle =>
|
||||||
|
'Long-press + on any track to add it here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionRemoveFromPlaylist => 'Remove from playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionRemoveFromFolder => 'Remove from folder';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String collectionRemoved(String trackName) {
|
||||||
|
return '\"$trackName\" removed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String collectionAddedToLoved(String trackName) {
|
||||||
|
return '\"$trackName\" added to Loved';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String collectionRemovedFromLoved(String trackName) {
|
||||||
|
return '\"$trackName\" removed from Loved';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String collectionAddedToWishlist(String trackName) {
|
||||||
|
return '\"$trackName\" added to Wishlist';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String collectionRemovedFromWishlist(String trackName) {
|
||||||
|
return '\"$trackName\" removed from Wishlist';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackOptionAddToLoved => 'Add to Loved';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackOptionRemoveFromLoved => 'Remove from Loved';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackOptionAddToWishlist => 'Add to Wishlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionPlaylistChangeCover => 'Change cover image';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionPlaylistRemoveCover => 'Remove cover image';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionShareCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Share $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionShareNoFiles => 'No shareable files found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionConvertCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessage(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
|
return 'Converting $current of $total...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||||
|
return 'Converted $success of $total tracks to $format';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String downloadedAlbumDownloadedCount(int count) {
|
String downloadedAlbumDownloadedCount(int count) {
|
||||||
return '$count descargado';
|
return '$count descargado';
|
||||||
|
|||||||
@@ -75,6 +75,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadFilenameFormat => 'Nom du fichier';
|
String get downloadFilenameFormat => 'Nom du fichier';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSingleFilenameFormat => 'Single Filename Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSingleFilenameFormatDescription =>
|
||||||
|
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadFolderOrganization => 'Organisation du dossier';
|
String get downloadFolderOrganization => 'Organisation du dossier';
|
||||||
|
|
||||||
@@ -156,6 +163,38 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'Download highest resolution cover art';
|
'Download highest resolution cover art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGain => 'ReplayGain';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGainSubtitleOn =>
|
||||||
|
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGainSubtitleOff =>
|
||||||
|
'Disabled: no loudness normalization tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeDescription =>
|
||||||
|
'Choose how multiple artists are written into embedded tags.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoinedSubtitle =>
|
||||||
|
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||||
|
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
||||||
|
|
||||||
@@ -358,7 +397,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';
|
||||||
@@ -761,6 +800,36 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Playlists';
|
String get searchPlaylists => 'Playlists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitle => 'Sort Results';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDefault => 'Default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleZA => 'Title (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationLong => 'Duration (Longest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipPlay => 'Play';
|
String get tooltipPlay => 'Play';
|
||||||
|
|
||||||
@@ -859,6 +928,17 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get providerPriorityInfo =>
|
String get providerPriorityInfo =>
|
||||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsDescription =>
|
||||||
|
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsHint =>
|
||||||
|
'Only enabled extensions with download-provider capability are listed here.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get providerBuiltIn => 'Built-in';
|
String get providerBuiltIn => 'Built-in';
|
||||||
|
|
||||||
@@ -1133,6 +1213,12 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLyricsNotInFile => 'No lyrics found in this file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackFetchOnlineLyrics => 'Fetch from Online';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackLyricsTimeout => 'Request timed out. Try again later.';
|
String get trackLyricsTimeout => 'Request timed out. Try again later.';
|
||||||
|
|
||||||
@@ -1233,7 +1319,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1342,6 +1428,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackSubtitle =>
|
||||||
|
'Choose which installed download extensions can be used as fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsNoDownloadProvider =>
|
String get extensionsNoDownloadProvider =>
|
||||||
'No extensions with download provider';
|
'No extensions with download provider';
|
||||||
@@ -1391,20 +1484,42 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossy320 => 'Lossy 320kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyFormat => 'Lossy Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossy320FormatDesc =>
|
||||||
|
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus256Subtitle =>
|
||||||
|
'Best quality Opus, ~8MB per track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1502,6 +1617,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -1766,6 +1888,17 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryFilesUnit(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'files',
|
||||||
|
one: 'file',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryLastScanned(String time) {
|
String libraryLastScanned(String time) {
|
||||||
return 'Last scanned: $time';
|
return 'Last scanned: $time';
|
||||||
@@ -1777,6 +1910,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryScanning => 'Scanning...';
|
String get libraryScanning => 'Scanning...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanFinalizing => 'Finalizing library...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryScanProgress(String progress, int total) {
|
String libraryScanProgress(String progress, int total) {
|
||||||
return '$progress% of $total files';
|
return '$progress% of $total files';
|
||||||
@@ -1845,6 +1981,24 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterFormat => 'Format';
|
String get libraryFilterFormat => 'Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadata => 'Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterSort => 'Sort';
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
@@ -1854,6 +2008,18 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterSortOldest => 'Oldest';
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get timeJustNow => 'Just now';
|
String get timeJustNow => 'Just now';
|
||||||
|
|
||||||
@@ -1892,7 +2058,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 =>
|
||||||
@@ -2138,6 +2304,30 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get trackReEnrichOnlineSubtitle =>
|
String get trackReEnrichOnlineSubtitle =>
|
||||||
'Search metadata online and embed into file';
|
'Search metadata online and embed into file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldsTitle => 'Fields to update';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldCover => 'Cover Art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSelectAll => 'Select All';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackEditMetadata => 'Edit Metadata';
|
String get trackEditMetadata => 'Edit Metadata';
|
||||||
|
|
||||||
@@ -2551,10 +2741,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get lyricsProvidersDiscardContent =>
|
String get lyricsProvidersDiscardContent =>
|
||||||
'You have unsaved changes that will be lost.';
|
'You have unsaved changes that will be lost.';
|
||||||
|
|
||||||
@override
|
|
||||||
String get lyricsProviderSpotifyApiDesc =>
|
|
||||||
'Spotify-sourced synced lyrics via community API';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||||
|
|
||||||
@@ -2686,6 +2872,22 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
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
|
@override
|
||||||
String get downloadSongLinkRegion => 'SongLink Region';
|
String get downloadSongLinkRegion => 'SongLink Region';
|
||||||
|
|
||||||
@@ -2916,4 +3118,294 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
String get editMetadataSelectEmpty => 'Empty only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueDownloadingCount(int count) {
|
||||||
|
return 'Downloading ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueDownloadedHeader => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFilteringIndicator => 'Filtering...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueTrackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueAlbumCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count albums',
|
||||||
|
one: '1 album',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbums => 'No album downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbumsSubtitle =>
|
||||||
|
'Download multiple tracks from an album to see them here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySingles => 'No single downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySinglesSubtitle =>
|
||||||
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistory => 'No download history';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDescription =>
|
||||||
|
'Verify lossless quality with spectrum analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisChannels => 'Channels';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDuration => 'Duration';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisNyquist => 'Nyquist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisFileSize => 'Size';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisPeak => 'Peak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisRms => 'RMS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSamples => 'Samples';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadFilenameFormat => 'Filename Format';
|
String get downloadFilenameFormat => 'Filename Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSingleFilenameFormat => 'Single Filename Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSingleFilenameFormatDescription =>
|
||||||
|
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadFolderOrganization => 'Folder Organization';
|
String get downloadFolderOrganization => 'Folder Organization';
|
||||||
|
|
||||||
@@ -154,6 +161,38 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'Download highest resolution cover art';
|
'Download highest resolution cover art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGain => 'ReplayGain';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGainSubtitleOn =>
|
||||||
|
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGainSubtitleOff =>
|
||||||
|
'Disabled: no loudness normalization tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeDescription =>
|
||||||
|
'Choose how multiple artists are written into embedded tags.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoinedSubtitle =>
|
||||||
|
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||||
|
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
||||||
|
|
||||||
@@ -356,7 +395,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';
|
||||||
@@ -759,6 +798,36 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Playlists';
|
String get searchPlaylists => 'Playlists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitle => 'Sort Results';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDefault => 'Default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleZA => 'Title (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationLong => 'Duration (Longest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipPlay => 'Play';
|
String get tooltipPlay => 'Play';
|
||||||
|
|
||||||
@@ -857,6 +926,17 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get providerPriorityInfo =>
|
String get providerPriorityInfo =>
|
||||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsDescription =>
|
||||||
|
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsHint =>
|
||||||
|
'Only enabled extensions with download-provider capability are listed here.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get providerBuiltIn => 'Built-in';
|
String get providerBuiltIn => 'Built-in';
|
||||||
|
|
||||||
@@ -1131,6 +1211,12 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLyricsNotInFile => 'No lyrics found in this file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackFetchOnlineLyrics => 'Fetch from Online';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackLyricsTimeout => 'Request timed out. Try again later.';
|
String get trackLyricsTimeout => 'Request timed out. Try again later.';
|
||||||
|
|
||||||
@@ -1231,7 +1317,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1340,6 +1426,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackSubtitle =>
|
||||||
|
'Choose which installed download extensions can be used as fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsNoDownloadProvider =>
|
String get extensionsNoDownloadProvider =>
|
||||||
'No extensions with download provider';
|
'No extensions with download provider';
|
||||||
@@ -1389,20 +1482,42 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossy320 => 'Lossy 320kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyFormat => 'Lossy Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossy320FormatDesc =>
|
||||||
|
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus256Subtitle =>
|
||||||
|
'Best quality Opus, ~8MB per track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1500,6 +1615,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -1764,6 +1886,17 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryFilesUnit(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'files',
|
||||||
|
one: 'file',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryLastScanned(String time) {
|
String libraryLastScanned(String time) {
|
||||||
return 'Last scanned: $time';
|
return 'Last scanned: $time';
|
||||||
@@ -1775,6 +1908,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryScanning => 'Scanning...';
|
String get libraryScanning => 'Scanning...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanFinalizing => 'Finalizing library...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryScanProgress(String progress, int total) {
|
String libraryScanProgress(String progress, int total) {
|
||||||
return '$progress% of $total files';
|
return '$progress% of $total files';
|
||||||
@@ -1843,6 +1979,24 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterFormat => 'Format';
|
String get libraryFilterFormat => 'Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadata => 'Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterSort => 'Sort';
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
@@ -1852,6 +2006,18 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterSortOldest => 'Oldest';
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get timeJustNow => 'Just now';
|
String get timeJustNow => 'Just now';
|
||||||
|
|
||||||
@@ -1890,7 +2056,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 =>
|
||||||
@@ -2136,6 +2302,30 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get trackReEnrichOnlineSubtitle =>
|
String get trackReEnrichOnlineSubtitle =>
|
||||||
'Search metadata online and embed into file';
|
'Search metadata online and embed into file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldsTitle => 'Fields to update';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldCover => 'Cover Art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSelectAll => 'Select All';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackEditMetadata => 'Edit Metadata';
|
String get trackEditMetadata => 'Edit Metadata';
|
||||||
|
|
||||||
@@ -2549,10 +2739,6 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get lyricsProvidersDiscardContent =>
|
String get lyricsProvidersDiscardContent =>
|
||||||
'You have unsaved changes that will be lost.';
|
'You have unsaved changes that will be lost.';
|
||||||
|
|
||||||
@override
|
|
||||||
String get lyricsProviderSpotifyApiDesc =>
|
|
||||||
'Spotify-sourced synced lyrics via community API';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||||
|
|
||||||
@@ -2684,6 +2870,22 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
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
|
@override
|
||||||
String get downloadSongLinkRegion => 'SongLink Region';
|
String get downloadSongLinkRegion => 'SongLink Region';
|
||||||
|
|
||||||
@@ -2914,4 +3116,294 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
String get editMetadataSelectEmpty => 'Empty only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueDownloadingCount(int count) {
|
||||||
|
return 'Downloading ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueDownloadedHeader => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFilteringIndicator => 'Filtering...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueTrackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueAlbumCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count albums',
|
||||||
|
one: '1 album',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbums => 'No album downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbumsSubtitle =>
|
||||||
|
'Download multiple tracks from an album to see them here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySingles => 'No single downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySinglesSubtitle =>
|
||||||
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistory => 'No download history';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDescription =>
|
||||||
|
'Verify lossless quality with spectrum analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisChannels => 'Channels';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDuration => 'Duration';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisNyquist => 'Nyquist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisFileSize => 'Size';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisPeak => 'Peak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisRms => 'RMS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSamples => 'Samples';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,13 +21,14 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get navSettings => 'Pengaturan';
|
String get navSettings => 'Pengaturan';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navStore => 'Toko';
|
String get navStore => 'Repo';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeTitle => 'Beranda';
|
String get homeTitle => 'Beranda';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSubtitle => 'Tempel link Spotify atau cari berdasarkan nama';
|
String get homeSubtitle =>
|
||||||
|
'Tempel URL yang didukung atau cari berdasarkan nama';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSupports => 'Mendukung: URL Track, Album, Playlist, Artis';
|
String get homeSupports => 'Mendukung: URL Track, Album, Playlist, Artis';
|
||||||
@@ -75,6 +76,13 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadFilenameFormat => 'Format Nama File';
|
String get downloadFilenameFormat => 'Format Nama File';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSingleFilenameFormat => 'Single Filename Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSingleFilenameFormatDescription =>
|
||||||
|
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadFolderOrganization => 'Organisasi Folder';
|
String get downloadFolderOrganization => 'Organisasi Folder';
|
||||||
|
|
||||||
@@ -157,6 +165,38 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'Unduh cover art resolusi tertinggi';
|
'Unduh cover art resolusi tertinggi';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGain => 'ReplayGain';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGainSubtitleOn =>
|
||||||
|
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGainSubtitleOff =>
|
||||||
|
'Disabled: no loudness normalization tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeDescription =>
|
||||||
|
'Choose how multiple artists are written into embedded tags.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoinedSubtitle =>
|
||||||
|
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||||
|
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsConcurrentDownloads => 'Unduhan Bersamaan';
|
String get optionsConcurrentDownloads => 'Unduhan Bersamaan';
|
||||||
|
|
||||||
@@ -173,10 +213,10 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
'Unduhan paralel dapat memicu pembatasan rate';
|
'Unduhan paralel dapat memicu pembatasan rate';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsExtensionStore => 'Toko Ekstensi';
|
String get optionsExtensionStore => 'Repo Ekstensi';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsExtensionStoreSubtitle => 'Tampilkan tab Toko di navigasi';
|
String get optionsExtensionStoreSubtitle => 'Tampilkan tab Repo di navigasi';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsCheckUpdates => 'Periksa Pembaruan';
|
String get optionsCheckUpdates => 'Periksa Pembaruan';
|
||||||
@@ -252,7 +292,7 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get extensionsUninstall => 'Copot';
|
String get extensionsUninstall => 'Copot';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeTitle => 'Toko Ekstensi';
|
String get storeTitle => 'Repo Ekstensi';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeSearch => 'Cari ekstensi...';
|
String get storeSearch => 'Cari ekstensi...';
|
||||||
@@ -359,7 +399,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';
|
||||||
@@ -762,6 +802,36 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Playlist';
|
String get searchPlaylists => 'Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitle => 'Sort Results';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDefault => 'Default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleZA => 'Title (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationLong => 'Duration (Longest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipPlay => 'Putar';
|
String get tooltipPlay => 'Putar';
|
||||||
|
|
||||||
@@ -769,21 +839,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';
|
||||||
@@ -860,6 +930,17 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get providerPriorityInfo =>
|
String get providerPriorityInfo =>
|
||||||
'Jika lagu tidak tersedia di provider pertama, aplikasi akan otomatis mencoba yang berikutnya.';
|
'Jika lagu tidak tersedia di provider pertama, aplikasi akan otomatis mencoba yang berikutnya.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsTitle => 'Fallback Ekstensi';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsDescription =>
|
||||||
|
'Pilih ekstensi unduhan terpasang mana yang boleh dipakai saat fallback otomatis. Provider bawaan tetap mengikuti urutan prioritas di atas.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsHint =>
|
||||||
|
'Hanya ekstensi aktif dengan kemampuan download provider yang ditampilkan di sini.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get providerBuiltIn => 'Bawaan';
|
String get providerBuiltIn => 'Bawaan';
|
||||||
|
|
||||||
@@ -939,13 +1020,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';
|
||||||
@@ -954,7 +1035,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';
|
||||||
@@ -984,33 +1065,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';
|
||||||
@@ -1122,10 +1204,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';
|
||||||
@@ -1136,6 +1218,12 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackLyricsNotAvailable => 'Lirik tidak tersedia untuk lagu ini';
|
String get trackLyricsNotAvailable => 'Lirik tidak tersedia untuk lagu ini';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLyricsNotInFile => 'No lyrics found in this file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackFetchOnlineLyrics => 'Fetch from Online';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackLyricsTimeout => 'Permintaan timeout. Coba lagi nanti.';
|
String get trackLyricsTimeout => 'Permintaan timeout. Coba lagi nanti.';
|
||||||
|
|
||||||
@@ -1143,13 +1231,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';
|
||||||
@@ -1236,7 +1324,7 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Gagal memuat repo';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1245,7 +1333,7 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get storeEmptyNoResults => 'No extensions found';
|
String get storeEmptyNoResults => 'No extensions found';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
|
String get extensionDefaultProvider => 'Bawaan (Deezer/Spotify)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionDefaultProviderSubtitle => 'Gunakan pencarian bawaan';
|
String get extensionDefaultProviderSubtitle => 'Gunakan pencarian bawaan';
|
||||||
@@ -1257,7 +1345,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';
|
||||||
@@ -1346,6 +1434,13 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get extensionsDownloadPrioritySubtitle =>
|
String get extensionsDownloadPrioritySubtitle =>
|
||||||
'Atur urutan layanan unduhan';
|
'Atur urutan layanan unduhan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackSubtitle =>
|
||||||
|
'Pilih ekstensi unduhan terpasang yang boleh dipakai saat fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsNoDownloadProvider =>
|
String get extensionsNoDownloadProvider =>
|
||||||
'Tidak ada ekstensi dengan provider unduhan';
|
'Tidak ada ekstensi dengan provider unduhan';
|
||||||
@@ -1396,20 +1491,42 @@ 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
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
|
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
|
||||||
|
|
||||||
@@ -1423,18 +1540,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';
|
||||||
@@ -1456,24 +1574,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';
|
||||||
@@ -1501,11 +1619,18 @@ 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
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih';
|
String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih';
|
||||||
@@ -1561,21 +1686,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';
|
||||||
@@ -1771,6 +1896,17 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryFilesUnit(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'files',
|
||||||
|
one: 'file',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryLastScanned(String time) {
|
String libraryLastScanned(String time) {
|
||||||
return 'Last scanned: $time';
|
return 'Last scanned: $time';
|
||||||
@@ -1782,6 +1918,9 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryScanning => 'Scanning...';
|
String get libraryScanning => 'Scanning...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanFinalizing => 'Finalizing library...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryScanProgress(String progress, int total) {
|
String libraryScanProgress(String progress, int total) {
|
||||||
return '$progress% of $total files';
|
return '$progress% of $total files';
|
||||||
@@ -1850,6 +1989,24 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterFormat => 'Format';
|
String get libraryFilterFormat => 'Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadata => 'Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterSort => 'Sort';
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
@@ -1859,6 +2016,18 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterSortOldest => 'Oldest';
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get timeJustNow => 'Just now';
|
String get timeJustNow => 'Just now';
|
||||||
|
|
||||||
@@ -1885,44 +2054,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 =>
|
||||||
@@ -1945,7 +2114,7 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip1 =>
|
String get tutorialExtensionsTip1 =>
|
||||||
'Browse the Store tab to discover useful extensions';
|
'Buka tab Repo untuk menemukan ekstensi yang berguna';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip2 =>
|
String get tutorialExtensionsTip2 =>
|
||||||
@@ -2143,6 +2312,30 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get trackReEnrichOnlineSubtitle =>
|
String get trackReEnrichOnlineSubtitle =>
|
||||||
'Search metadata online and embed into file';
|
'Search metadata online and embed into file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldsTitle => 'Fields to update';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldCover => 'Cover Art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSelectAll => 'Select All';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackEditMetadata => 'Edit Metadata';
|
String get trackEditMetadata => 'Edit Metadata';
|
||||||
|
|
||||||
@@ -2557,10 +2750,6 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get lyricsProvidersDiscardContent =>
|
String get lyricsProvidersDiscardContent =>
|
||||||
'You have unsaved changes that will be lost.';
|
'You have unsaved changes that will be lost.';
|
||||||
|
|
||||||
@override
|
|
||||||
String get lyricsProviderSpotifyApiDesc =>
|
|
||||||
'Spotify-sourced synced lyrics via community API';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||||
|
|
||||||
@@ -2692,6 +2881,22 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
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
|
@override
|
||||||
String get downloadSongLinkRegion => 'SongLink Region';
|
String get downloadSongLinkRegion => 'SongLink Region';
|
||||||
|
|
||||||
@@ -2922,4 +3127,294 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
String get editMetadataSelectEmpty => 'Empty only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueDownloadingCount(int count) {
|
||||||
|
return 'Downloading ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueDownloadedHeader => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFilteringIndicator => 'Filtering...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueTrackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueAlbumCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count albums',
|
||||||
|
one: '1 album',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbums => 'No album downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbumsSubtitle =>
|
||||||
|
'Download multiple tracks from an album to see them here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySingles => 'No single downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySinglesSubtitle =>
|
||||||
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistory => 'No download history';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDescription =>
|
||||||
|
'Verify lossless quality with spectrum analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisChannels => 'Channels';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDuration => 'Duration';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisNyquist => 'Nyquist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisFileSize => 'Size';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisPeak => 'Peak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisRms => 'RMS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSamples => 'Samples';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadFilenameFormat => 'ファイル名の形式';
|
String get downloadFilenameFormat => 'ファイル名の形式';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSingleFilenameFormat => 'Single Filename Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSingleFilenameFormatDescription =>
|
||||||
|
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadFolderOrganization => 'フォルダ構成';
|
String get downloadFolderOrganization => 'フォルダ構成';
|
||||||
|
|
||||||
@@ -152,6 +159,38 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get optionsMaxQualityCoverSubtitle => '最高解像度のカバーアートをダウンロード';
|
String get optionsMaxQualityCoverSubtitle => '最高解像度のカバーアートをダウンロード';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGain => 'ReplayGain';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGainSubtitleOn =>
|
||||||
|
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGainSubtitleOff =>
|
||||||
|
'Disabled: no loudness normalization tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeDescription =>
|
||||||
|
'Choose how multiple artists are written into embedded tags.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoinedSubtitle =>
|
||||||
|
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||||
|
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsConcurrentDownloads => '同時ダウンロード';
|
String get optionsConcurrentDownloads => '同時ダウンロード';
|
||||||
|
|
||||||
@@ -352,7 +391,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 => 'アルバム';
|
||||||
@@ -754,6 +793,36 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'プレイリスト';
|
String get searchPlaylists => 'プレイリスト';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitle => 'Sort Results';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDefault => 'Default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleZA => 'Title (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationLong => 'Duration (Longest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipPlay => '再生';
|
String get tooltipPlay => '再生';
|
||||||
|
|
||||||
@@ -761,7 +830,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 =>
|
||||||
@@ -851,6 +920,17 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get providerPriorityInfo =>
|
String get providerPriorityInfo =>
|
||||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsDescription =>
|
||||||
|
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsHint =>
|
||||||
|
'Only enabled extensions with download-provider capability are listed here.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get providerBuiltIn => '内蔵';
|
String get providerBuiltIn => '内蔵';
|
||||||
|
|
||||||
@@ -1125,6 +1205,12 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackLyricsNotAvailable => 'このトラックの歌詞は利用できません';
|
String get trackLyricsNotAvailable => 'このトラックの歌詞は利用できません';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLyricsNotInFile => 'No lyrics found in this file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackFetchOnlineLyrics => 'Fetch from Online';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackLyricsTimeout => 'リクエストがタイムアウトしました。後ほどお試しください。';
|
String get trackLyricsTimeout => 'リクエストがタイムアウトしました。後ほどお試しください。';
|
||||||
|
|
||||||
@@ -1138,7 +1224,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 => 'クリップボードにコピーしました';
|
||||||
@@ -1225,7 +1311,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1334,6 +1420,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get extensionsDownloadPrioritySubtitle => 'ダウンロードサービスの順序を設定';
|
String get extensionsDownloadPrioritySubtitle => 'ダウンロードサービスの順序を設定';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackSubtitle =>
|
||||||
|
'Choose which installed download extensions can be used as fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsNoDownloadProvider => 'ダウンロードプロバイダーの拡張はありません';
|
String get extensionsNoDownloadProvider => 'ダウンロードプロバイダーの拡張はありません';
|
||||||
|
|
||||||
@@ -1379,19 +1472,41 @@ 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 => '実際の品質はサービスからのトラックの可用性に依存します';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus のビットレート';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 のビットレート';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
|
String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
|
||||||
|
|
||||||
@@ -1487,6 +1602,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => '選択済みを削除';
|
String get downloadedAlbumDeleteSelected => '選択済みを削除';
|
||||||
|
|
||||||
@@ -1751,6 +1873,17 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryFilesUnit(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'files',
|
||||||
|
one: 'file',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryLastScanned(String time) {
|
String libraryLastScanned(String time) {
|
||||||
return '最終スキャン: $time';
|
return '最終スキャン: $time';
|
||||||
@@ -1762,6 +1895,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryScanning => 'スキャン中...';
|
String get libraryScanning => 'スキャン中...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanFinalizing => 'Finalizing library...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryScanProgress(String progress, int total) {
|
String libraryScanProgress(String progress, int total) {
|
||||||
return '$progress% of $total files';
|
return '$progress% of $total files';
|
||||||
@@ -1830,6 +1966,24 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterFormat => '形式';
|
String get libraryFilterFormat => '形式';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadata => 'Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterSort => 'Sort';
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
@@ -1839,6 +1993,18 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterSortOldest => 'Oldest';
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get timeJustNow => 'Just now';
|
String get timeJustNow => 'Just now';
|
||||||
|
|
||||||
@@ -1877,7 +2043,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 =>
|
||||||
@@ -2123,6 +2289,30 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get trackReEnrichOnlineSubtitle =>
|
String get trackReEnrichOnlineSubtitle =>
|
||||||
'Search metadata online and embed into file';
|
'Search metadata online and embed into file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldsTitle => 'Fields to update';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldCover => 'Cover Art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSelectAll => 'Select All';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackEditMetadata => 'メタデータを編集';
|
String get trackEditMetadata => 'メタデータを編集';
|
||||||
|
|
||||||
@@ -2229,7 +2419,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';
|
||||||
@@ -2379,7 +2569,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) {
|
||||||
@@ -2413,26 +2603,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
|
||||||
@@ -2453,7 +2643,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(
|
||||||
@@ -2536,10 +2726,6 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get lyricsProvidersDiscardContent =>
|
String get lyricsProvidersDiscardContent =>
|
||||||
'You have unsaved changes that will be lost.';
|
'You have unsaved changes that will be lost.';
|
||||||
|
|
||||||
@override
|
|
||||||
String get lyricsProviderSpotifyApiDesc =>
|
|
||||||
'Spotify-sourced synced lyrics via community API';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||||
|
|
||||||
@@ -2671,6 +2857,22 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
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
|
@override
|
||||||
String get downloadSongLinkRegion => 'SongLink Region';
|
String get downloadSongLinkRegion => 'SongLink Region';
|
||||||
|
|
||||||
@@ -2901,4 +3103,294 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
String get editMetadataSelectEmpty => 'Empty only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueDownloadingCount(int count) {
|
||||||
|
return 'Downloading ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueDownloadedHeader => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFilteringIndicator => 'Filtering...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueTrackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueAlbumCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count albums',
|
||||||
|
one: '1 album',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbums => 'No album downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbumsSubtitle =>
|
||||||
|
'Download multiple tracks from an album to see them here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySingles => 'No single downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySinglesSubtitle =>
|
||||||
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistory => 'No download history';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDescription =>
|
||||||
|
'Verify lossless quality with spectrum analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisChannels => 'Channels';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDuration => 'Duration';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisNyquist => 'Nyquist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisFileSize => 'Size';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisPeak => 'Peak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisRms => 'RMS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSamples => 'Samples';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadFilenameFormat => '파일 이름 형식';
|
String get downloadFilenameFormat => '파일 이름 형식';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSingleFilenameFormat => 'Single Filename Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSingleFilenameFormatDescription =>
|
||||||
|
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadFolderOrganization => '폴더 분류 형식';
|
String get downloadFolderOrganization => '폴더 분류 형식';
|
||||||
|
|
||||||
@@ -148,6 +155,38 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get optionsMaxQualityCoverSubtitle => '최고 품질의 커버 이미지를 다운로드';
|
String get optionsMaxQualityCoverSubtitle => '최고 품질의 커버 이미지를 다운로드';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGain => 'ReplayGain';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGainSubtitleOn =>
|
||||||
|
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGainSubtitleOff =>
|
||||||
|
'Disabled: no loudness normalization tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeDescription =>
|
||||||
|
'Choose how multiple artists are written into embedded tags.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoinedSubtitle =>
|
||||||
|
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||||
|
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsConcurrentDownloads => '동시 다운로드';
|
String get optionsConcurrentDownloads => '동시 다운로드';
|
||||||
|
|
||||||
@@ -344,7 +383,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 => '앨범';
|
||||||
@@ -741,6 +780,36 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => '재생목록들';
|
String get searchPlaylists => '재생목록들';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitle => 'Sort Results';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDefault => 'Default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleZA => 'Title (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationLong => 'Duration (Longest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipPlay => '재생';
|
String get tooltipPlay => '재생';
|
||||||
|
|
||||||
@@ -839,6 +908,17 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get providerPriorityInfo =>
|
String get providerPriorityInfo =>
|
||||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsDescription =>
|
||||||
|
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsHint =>
|
||||||
|
'Only enabled extensions with download-provider capability are listed here.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get providerBuiltIn => 'Built-in';
|
String get providerBuiltIn => 'Built-in';
|
||||||
|
|
||||||
@@ -1111,6 +1191,12 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLyricsNotInFile => 'No lyrics found in this file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackFetchOnlineLyrics => 'Fetch from Online';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackLyricsTimeout => 'Request timed out. Try again later.';
|
String get trackLyricsTimeout => 'Request timed out. Try again later.';
|
||||||
|
|
||||||
@@ -1211,7 +1297,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1320,6 +1406,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackSubtitle =>
|
||||||
|
'Choose which installed download extensions can be used as fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsNoDownloadProvider =>
|
String get extensionsNoDownloadProvider =>
|
||||||
'No extensions with download provider';
|
'No extensions with download provider';
|
||||||
@@ -1369,20 +1462,42 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossy320 => 'Lossy 320kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyFormat => 'Lossy Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossy320FormatDesc =>
|
||||||
|
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus256Subtitle =>
|
||||||
|
'Best quality Opus, ~8MB per track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1480,6 +1595,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -1744,6 +1866,17 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryFilesUnit(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'files',
|
||||||
|
one: 'file',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryLastScanned(String time) {
|
String libraryLastScanned(String time) {
|
||||||
return 'Last scanned: $time';
|
return 'Last scanned: $time';
|
||||||
@@ -1755,6 +1888,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryScanning => 'Scanning...';
|
String get libraryScanning => 'Scanning...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanFinalizing => 'Finalizing library...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryScanProgress(String progress, int total) {
|
String libraryScanProgress(String progress, int total) {
|
||||||
return '$progress% of $total files';
|
return '$progress% of $total files';
|
||||||
@@ -1823,6 +1959,24 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterFormat => 'Format';
|
String get libraryFilterFormat => 'Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadata => 'Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterSort => 'Sort';
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
@@ -1832,6 +1986,18 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterSortOldest => 'Oldest';
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get timeJustNow => 'Just now';
|
String get timeJustNow => 'Just now';
|
||||||
|
|
||||||
@@ -1870,7 +2036,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 =>
|
||||||
@@ -2116,6 +2282,30 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get trackReEnrichOnlineSubtitle =>
|
String get trackReEnrichOnlineSubtitle =>
|
||||||
'Search metadata online and embed into file';
|
'Search metadata online and embed into file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldsTitle => 'Fields to update';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldCover => 'Cover Art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSelectAll => 'Select All';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackEditMetadata => 'Edit Metadata';
|
String get trackEditMetadata => 'Edit Metadata';
|
||||||
|
|
||||||
@@ -2529,10 +2719,6 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get lyricsProvidersDiscardContent =>
|
String get lyricsProvidersDiscardContent =>
|
||||||
'You have unsaved changes that will be lost.';
|
'You have unsaved changes that will be lost.';
|
||||||
|
|
||||||
@override
|
|
||||||
String get lyricsProviderSpotifyApiDesc =>
|
|
||||||
'Spotify-sourced synced lyrics via community API';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||||
|
|
||||||
@@ -2664,6 +2850,22 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
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
|
@override
|
||||||
String get downloadSongLinkRegion => 'SongLink Region';
|
String get downloadSongLinkRegion => 'SongLink Region';
|
||||||
|
|
||||||
@@ -2894,4 +3096,294 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
String get editMetadataSelectEmpty => 'Empty only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueDownloadingCount(int count) {
|
||||||
|
return 'Downloading ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueDownloadedHeader => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFilteringIndicator => 'Filtering...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueTrackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueAlbumCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count albums',
|
||||||
|
one: '1 album',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbums => 'No album downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbumsSubtitle =>
|
||||||
|
'Download multiple tracks from an album to see them here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySingles => 'No single downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySinglesSubtitle =>
|
||||||
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistory => 'No download history';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDescription =>
|
||||||
|
'Verify lossless quality with spectrum analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisChannels => 'Channels';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDuration => 'Duration';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisNyquist => 'Nyquist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisFileSize => 'Size';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisPeak => 'Peak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisRms => 'RMS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSamples => 'Samples';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadFilenameFormat => 'Filename Format';
|
String get downloadFilenameFormat => 'Filename Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSingleFilenameFormat => 'Single Filename Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSingleFilenameFormatDescription =>
|
||||||
|
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadFolderOrganization => 'Folder Organization';
|
String get downloadFolderOrganization => 'Folder Organization';
|
||||||
|
|
||||||
@@ -154,20 +161,52 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'Download highest resolution cover art';
|
'Download highest resolution cover art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGain => 'ReplayGain';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGainSubtitleOn =>
|
||||||
|
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGainSubtitleOff =>
|
||||||
|
'Disabled: no loudness normalization tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeDescription =>
|
||||||
|
'Choose how multiple artists are written into embedded tags.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoinedSubtitle =>
|
||||||
|
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||||
|
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
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 +310,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 +395,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';
|
||||||
@@ -759,6 +798,36 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Playlists';
|
String get searchPlaylists => 'Playlists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitle => 'Sort Results';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDefault => 'Default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleZA => 'Title (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationLong => 'Duration (Longest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipPlay => 'Play';
|
String get tooltipPlay => 'Play';
|
||||||
|
|
||||||
@@ -857,6 +926,17 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get providerPriorityInfo =>
|
String get providerPriorityInfo =>
|
||||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsDescription =>
|
||||||
|
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsHint =>
|
||||||
|
'Only enabled extensions with download-provider capability are listed here.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get providerBuiltIn => 'Built-in';
|
String get providerBuiltIn => 'Built-in';
|
||||||
|
|
||||||
@@ -1131,6 +1211,12 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLyricsNotInFile => 'No lyrics found in this file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackFetchOnlineLyrics => 'Fetch from Online';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackLyricsTimeout => 'Request timed out. Try again later.';
|
String get trackLyricsTimeout => 'Request timed out. Try again later.';
|
||||||
|
|
||||||
@@ -1231,7 +1317,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1340,6 +1426,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackSubtitle =>
|
||||||
|
'Choose which installed download extensions can be used as fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsNoDownloadProvider =>
|
String get extensionsNoDownloadProvider =>
|
||||||
'No extensions with download provider';
|
'No extensions with download provider';
|
||||||
@@ -1389,20 +1482,42 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossy320 => 'Lossy 320kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyFormat => 'Lossy Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossy320FormatDesc =>
|
||||||
|
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus256Subtitle =>
|
||||||
|
'Best quality Opus, ~8MB per track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1500,6 +1615,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -1764,6 +1886,17 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryFilesUnit(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'files',
|
||||||
|
one: 'file',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryLastScanned(String time) {
|
String libraryLastScanned(String time) {
|
||||||
return 'Last scanned: $time';
|
return 'Last scanned: $time';
|
||||||
@@ -1775,6 +1908,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryScanning => 'Scanning...';
|
String get libraryScanning => 'Scanning...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanFinalizing => 'Finalizing library...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryScanProgress(String progress, int total) {
|
String libraryScanProgress(String progress, int total) {
|
||||||
return '$progress% of $total files';
|
return '$progress% of $total files';
|
||||||
@@ -1843,6 +1979,24 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterFormat => 'Format';
|
String get libraryFilterFormat => 'Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadata => 'Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterSort => 'Sort';
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
@@ -1852,6 +2006,18 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterSortOldest => 'Oldest';
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get timeJustNow => 'Just now';
|
String get timeJustNow => 'Just now';
|
||||||
|
|
||||||
@@ -1890,7 +2056,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 =>
|
||||||
@@ -2136,6 +2302,30 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get trackReEnrichOnlineSubtitle =>
|
String get trackReEnrichOnlineSubtitle =>
|
||||||
'Search metadata online and embed into file';
|
'Search metadata online and embed into file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldsTitle => 'Fields to update';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldCover => 'Cover Art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSelectAll => 'Select All';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackEditMetadata => 'Edit Metadata';
|
String get trackEditMetadata => 'Edit Metadata';
|
||||||
|
|
||||||
@@ -2549,10 +2739,6 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get lyricsProvidersDiscardContent =>
|
String get lyricsProvidersDiscardContent =>
|
||||||
'You have unsaved changes that will be lost.';
|
'You have unsaved changes that will be lost.';
|
||||||
|
|
||||||
@override
|
|
||||||
String get lyricsProviderSpotifyApiDesc =>
|
|
||||||
'Spotify-sourced synced lyrics via community API';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||||
|
|
||||||
@@ -2684,6 +2870,22 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
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
|
@override
|
||||||
String get downloadSongLinkRegion => 'SongLink Region';
|
String get downloadSongLinkRegion => 'SongLink Region';
|
||||||
|
|
||||||
@@ -2914,4 +3116,294 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
String get editMetadataSelectEmpty => 'Empty only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueDownloadingCount(int count) {
|
||||||
|
return 'Downloading ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueDownloadedHeader => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFilteringIndicator => 'Filtering...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueTrackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueAlbumCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count albums',
|
||||||
|
one: '1 album',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbums => 'No album downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbumsSubtitle =>
|
||||||
|
'Download multiple tracks from an album to see them here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySingles => 'No single downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySinglesSubtitle =>
|
||||||
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistory => 'No download history';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDescription =>
|
||||||
|
'Verify lossless quality with spectrum analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisChannels => 'Channels';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDuration => 'Duration';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisNyquist => 'Nyquist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisFileSize => 'Size';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisPeak => 'Peak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisRms => 'RMS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSamples => 'Samples';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,13 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadFilenameFormat => 'Filename Format';
|
String get downloadFilenameFormat => 'Filename Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSingleFilenameFormat => 'Single Filename Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSingleFilenameFormatDescription =>
|
||||||
|
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadFolderOrganization => 'Folder Organization';
|
String get downloadFolderOrganization => 'Folder Organization';
|
||||||
|
|
||||||
@@ -154,6 +161,38 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'Download highest resolution cover art';
|
'Download highest resolution cover art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGain => 'ReplayGain';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGainSubtitleOn =>
|
||||||
|
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGainSubtitleOff =>
|
||||||
|
'Disabled: no loudness normalization tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeDescription =>
|
||||||
|
'Choose how multiple artists are written into embedded tags.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoinedSubtitle =>
|
||||||
|
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||||
|
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
||||||
|
|
||||||
@@ -759,6 +798,36 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Playlists';
|
String get searchPlaylists => 'Playlists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitle => 'Sort Results';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDefault => 'Default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleZA => 'Title (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationLong => 'Duration (Longest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipPlay => 'Play';
|
String get tooltipPlay => 'Play';
|
||||||
|
|
||||||
@@ -857,6 +926,17 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String get providerPriorityInfo =>
|
String get providerPriorityInfo =>
|
||||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsDescription =>
|
||||||
|
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsHint =>
|
||||||
|
'Only enabled extensions with download-provider capability are listed here.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get providerBuiltIn => 'Built-in';
|
String get providerBuiltIn => 'Built-in';
|
||||||
|
|
||||||
@@ -1131,6 +1211,12 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLyricsNotInFile => 'No lyrics found in this file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackFetchOnlineLyrics => 'Fetch from Online';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackLyricsTimeout => 'Request timed out. Try again later.';
|
String get trackLyricsTimeout => 'Request timed out. Try again later.';
|
||||||
|
|
||||||
@@ -1231,7 +1317,7 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1340,6 +1426,13 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackSubtitle =>
|
||||||
|
'Choose which installed download extensions can be used as fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsNoDownloadProvider =>
|
String get extensionsNoDownloadProvider =>
|
||||||
'No extensions with download provider';
|
'No extensions with download provider';
|
||||||
@@ -1389,20 +1482,42 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossy320 => 'Lossy 320kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyFormat => 'Lossy Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossy320FormatDesc =>
|
||||||
|
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus256Subtitle =>
|
||||||
|
'Best quality Opus, ~8MB per track';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1500,6 +1615,13 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -1764,6 +1886,17 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryFilesUnit(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'files',
|
||||||
|
one: 'file',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryLastScanned(String time) {
|
String libraryLastScanned(String time) {
|
||||||
return 'Last scanned: $time';
|
return 'Last scanned: $time';
|
||||||
@@ -1775,6 +1908,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryScanning => 'Scanning...';
|
String get libraryScanning => 'Scanning...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanFinalizing => 'Finalizing library...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryScanProgress(String progress, int total) {
|
String libraryScanProgress(String progress, int total) {
|
||||||
return '$progress% of $total files';
|
return '$progress% of $total files';
|
||||||
@@ -1843,6 +1979,24 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterFormat => 'Format';
|
String get libraryFilterFormat => 'Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadata => 'Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterSort => 'Sort';
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
@@ -1852,6 +2006,18 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterSortOldest => 'Oldest';
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get timeJustNow => 'Just now';
|
String get timeJustNow => 'Just now';
|
||||||
|
|
||||||
@@ -1938,7 +2104,7 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip1 =>
|
String get tutorialExtensionsTip1 =>
|
||||||
'Browse the Store tab to discover useful extensions';
|
'Browse the Repo tab to discover useful extensions';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip2 =>
|
String get tutorialExtensionsTip2 =>
|
||||||
@@ -2136,6 +2302,30 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String get trackReEnrichOnlineSubtitle =>
|
String get trackReEnrichOnlineSubtitle =>
|
||||||
'Search metadata online and embed into file';
|
'Search metadata online and embed into file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldsTitle => 'Fields to update';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldCover => 'Cover Art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSelectAll => 'Select All';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackEditMetadata => 'Edit Metadata';
|
String get trackEditMetadata => 'Edit Metadata';
|
||||||
|
|
||||||
@@ -2550,10 +2740,6 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String get lyricsProvidersDiscardContent =>
|
String get lyricsProvidersDiscardContent =>
|
||||||
'You have unsaved changes that will be lost.';
|
'You have unsaved changes that will be lost.';
|
||||||
|
|
||||||
@override
|
|
||||||
String get lyricsProviderSpotifyApiDesc =>
|
|
||||||
'Spotify-sourced synced lyrics via community API';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||||
|
|
||||||
@@ -2685,6 +2871,22 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
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
|
@override
|
||||||
String get downloadSongLinkRegion => 'SongLink Region';
|
String get downloadSongLinkRegion => 'SongLink Region';
|
||||||
|
|
||||||
@@ -2915,6 +3117,296 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
String get editMetadataSelectEmpty => 'Empty only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueDownloadingCount(int count) {
|
||||||
|
return 'Downloading ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueDownloadedHeader => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFilteringIndicator => 'Filtering...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueTrackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueAlbumCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count albums',
|
||||||
|
one: '1 album',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbums => 'No album downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbumsSubtitle =>
|
||||||
|
'Download multiple tracks from an album to see them here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySingles => 'No single downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySinglesSubtitle =>
|
||||||
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistory => 'No download history';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDescription =>
|
||||||
|
'Verify lossless quality with spectrum analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisChannels => 'Channels';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDuration => 'Duration';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisNyquist => 'Nyquist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisFileSize => 'Size';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisPeak => 'Peak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisRms => 'RMS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSamples => 'Samples';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
||||||
@@ -3278,7 +3770,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutAppDescription =>
|
String get aboutAppDescription =>
|
||||||
'Baixe faixas do Spotify em qualidade sem perdas do Tidal e Qobuz.';
|
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get artistAlbums => 'Álbuns';
|
String get artistAlbums => 'Álbuns';
|
||||||
@@ -3612,6 +4104,17 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
|||||||
@override
|
@override
|
||||||
String get errorNoTracksFound => 'Nenhuma faixa encontrada';
|
String get errorNoTracksFound => 'Nenhuma faixa encontrada';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlNotRecognized => 'Link not recognized';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlNotRecognizedMessage =>
|
||||||
|
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorUrlFetchFailed =>
|
||||||
|
'Failed to load content from this link. Please try again.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String errorMissingExtensionSource(String item) {
|
String errorMissingExtensionSource(String item) {
|
||||||
return 'Não é possível carregar $item: faltando a fonte da extensão';
|
return 'Não é possível carregar $item: faltando a fonte da extensão';
|
||||||
@@ -3675,9 +4178,23 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
|||||||
@override
|
@override
|
||||||
String get filenameFormat => 'Formato do Nome do Arquivo';
|
String get filenameFormat => 'Formato do Nome do Arquivo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTagsDescription =>
|
||||||
|
'Enable formatted tags for track padding and date patterns';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganizationNone => 'Nenhuma organização';
|
String get folderOrganizationNone => 'Nenhuma organização';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get folderOrganizationByPlaylist => 'By Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get folderOrganizationByPlaylistSubtitle =>
|
||||||
|
'Separate folder for each playlist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganizationByArtist => 'Por Artista';
|
String get folderOrganizationByArtist => 'Por Artista';
|
||||||
|
|
||||||
@@ -4258,10 +4775,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'A qualidade real depende da faixa que estiver disponível no serviço';
|
'A qualidade real depende da faixa que estiver disponível no serviço';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Perguntar qualidade antes de baixar';
|
String get downloadAskBeforeDownload => 'Perguntar qualidade antes de baixar';
|
||||||
|
|
||||||
@@ -4594,6 +5107,17 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
|||||||
String get libraryAboutDescription =>
|
String get libraryAboutDescription =>
|
||||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryTracksUnit(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryLastScanned(String time) {
|
String libraryLastScanned(String time) {
|
||||||
return 'Last scanned: $time';
|
return 'Last scanned: $time';
|
||||||
@@ -4720,7 +5244,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip2 =>
|
String get tutorialWelcomeTip2 =>
|
||||||
'Obtenha áudio em qualidade FLAC do Tidal, Qobuz ou Deezer';
|
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip3 =>
|
String get tutorialWelcomeTip3 =>
|
||||||
@@ -5037,6 +5561,258 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
|||||||
@override
|
@override
|
||||||
String get trackConvertFailed => 'Conversion failed';
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitTitle => 'Split CUE Sheet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitAlbum(String album) {
|
||||||
|
return 'Album: $album';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitArtist(String artist) {
|
||||||
|
return 'Artist: $artist';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitTrackCount(int count) {
|
||||||
|
return '$count tracks';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitConfirmTitle => 'Split CUE Album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitConfirmMessage(String album, int count) {
|
||||||
|
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitSplitting(int current, int total) {
|
||||||
|
return 'Splitting CUE sheet... ($current/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cueSplitSuccess(int count) {
|
||||||
|
return 'Split into $count tracks successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitFailed => 'CUE split failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cueSplitButton => 'Split into Tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionCreate => 'Create';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionFoldersTitle => 'My folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionWishlist => 'Wishlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionLoved => 'Loved';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionPlaylists => 'Playlists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionPlaylist => 'Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionAddToPlaylist => 'Add to playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionCreatePlaylist => 'Create playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionNoPlaylistsYet => 'No playlists yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionNoPlaylistsSubtitle =>
|
||||||
|
'Create a playlist to start categorizing tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String collectionPlaylistTracks(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String collectionAddedToPlaylist(String playlistName) {
|
||||||
|
return 'Added to \"$playlistName\"';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String collectionAlreadyInPlaylist(String playlistName) {
|
||||||
|
return 'Already in \"$playlistName\"';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionPlaylistCreated => 'Playlist created';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionPlaylistNameHint => 'Playlist name';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionPlaylistNameRequired => 'Playlist name is required';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionRenamePlaylist => 'Rename playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionDeletePlaylist => 'Delete playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String collectionDeletePlaylistMessage(String playlistName) {
|
||||||
|
return 'Delete \"$playlistName\" and all tracks inside it?';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionPlaylistDeleted => 'Playlist deleted';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionPlaylistRenamed => 'Playlist renamed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionWishlistEmptySubtitle =>
|
||||||
|
'Tap + on tracks to save what you want to download later';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionLovedEmptyTitle => 'Loved folder is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionLovedEmptySubtitle =>
|
||||||
|
'Tap love on tracks to keep your favorites';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionPlaylistEmptySubtitle =>
|
||||||
|
'Long-press + on any track to add it here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionRemoveFromPlaylist => 'Remove from playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionRemoveFromFolder => 'Remove from folder';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String collectionRemoved(String trackName) {
|
||||||
|
return '\"$trackName\" removed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String collectionAddedToLoved(String trackName) {
|
||||||
|
return '\"$trackName\" added to Loved';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String collectionRemovedFromLoved(String trackName) {
|
||||||
|
return '\"$trackName\" removed from Loved';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String collectionAddedToWishlist(String trackName) {
|
||||||
|
return '\"$trackName\" added to Wishlist';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String collectionRemovedFromWishlist(String trackName) {
|
||||||
|
return '\"$trackName\" removed from Wishlist';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackOptionAddToLoved => 'Add to Loved';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackOptionRemoveFromLoved => 'Remove from Loved';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackOptionAddToWishlist => 'Add to Wishlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionPlaylistChangeCover => 'Change cover image';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get collectionPlaylistRemoveCover => 'Remove cover image';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionShareCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Share $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionShareNoFiles => 'No shareable files found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionConvertCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessage(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
|
return 'Converting $current of $total...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||||
|
return 'Converted $success of $total tracks to $format';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String downloadedAlbumDownloadedCount(int count) {
|
String downloadedAlbumDownloadedCount(int count) {
|
||||||
return '$count baixado(s)';
|
return '$count baixado(s)';
|
||||||
|
|||||||
@@ -76,6 +76,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadFilenameFormat => 'Формат имени файла';
|
String get downloadFilenameFormat => 'Формат имени файла';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSingleFilenameFormat => 'Single Filename Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadSingleFilenameFormatDescription =>
|
||||||
|
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadFolderOrganization => 'Организация папок';
|
String get downloadFolderOrganization => 'Организация папок';
|
||||||
|
|
||||||
@@ -159,6 +166,38 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'Скачивать обложку в макс. разрешении';
|
'Скачивать обложку в макс. разрешении';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGain => 'ReplayGain';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGainSubtitleOn =>
|
||||||
|
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsReplayGainSubtitleOff =>
|
||||||
|
'Disabled: no loudness normalization tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeDescription =>
|
||||||
|
'Choose how multiple artists are written into embedded tags.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeJoinedSubtitle =>
|
||||||
|
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||||
|
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsConcurrentDownloads => 'Одновременные загрузки';
|
String get optionsConcurrentDownloads => 'Одновременные загрузки';
|
||||||
|
|
||||||
@@ -363,7 +402,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 => 'Альбомы';
|
||||||
@@ -706,15 +745,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) {
|
||||||
@@ -773,6 +812,36 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Плейлисты';
|
String get searchPlaylists => 'Плейлисты';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitle => 'Sort Results';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDefault => 'Default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleZA => 'Title (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationLong => 'Duration (Longest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipPlay => 'Воспроизвести';
|
String get tooltipPlay => 'Воспроизвести';
|
||||||
|
|
||||||
@@ -790,11 +859,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 => 'По исполнителю';
|
||||||
@@ -871,6 +940,17 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get providerPriorityInfo =>
|
String get providerPriorityInfo =>
|
||||||
'Если трек не доступен у первого провайдера, приложение автоматически попробует следующий.';
|
'Если трек не доступен у первого провайдера, приложение автоматически попробует следующий.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsDescription =>
|
||||||
|
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerPriorityFallbackExtensionsHint =>
|
||||||
|
'Only enabled extensions with download-provider capability are listed here.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get providerBuiltIn => 'Встроенные';
|
String get providerBuiltIn => 'Встроенные';
|
||||||
|
|
||||||
@@ -1151,6 +1231,12 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get trackLyricsNotAvailable =>
|
String get trackLyricsNotAvailable =>
|
||||||
'Текст песни недоступен для этого трека';
|
'Текст песни недоступен для этого трека';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLyricsNotInFile => 'No lyrics found in this file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackFetchOnlineLyrics => 'Fetch from Online';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackLyricsTimeout =>
|
String get trackLyricsTimeout =>
|
||||||
'Время ожидания запроса истекло. Повторите попытку позже.';
|
'Время ожидания запроса истекло. Повторите попытку позже.';
|
||||||
@@ -1252,7 +1338,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1364,6 +1450,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get extensionsDownloadPrioritySubtitle =>
|
String get extensionsDownloadPrioritySubtitle =>
|
||||||
'Установка порядок сервисов скачивания';
|
'Установка порядок сервисов скачивания';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsFallbackSubtitle =>
|
||||||
|
'Choose which installed download extensions can be used as fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsNoDownloadProvider =>
|
String get extensionsNoDownloadProvider =>
|
||||||
'Нет расширений с провайдером загрузки';
|
'Нет расширений с провайдером загрузки';
|
||||||
@@ -1414,20 +1507,42 @@ 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 =>
|
||||||
'Фактическое качество зависит от доступности треков в сервисе';
|
'Фактическое качество зависит от доступности треков в сервисе';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube обеспечивает только звук с потерями(Lossy).';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'Битрейт YouTube Opus';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'Битрейт YouTube MP3';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием';
|
String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием';
|
||||||
|
|
||||||
@@ -1450,7 +1565,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 =>
|
||||||
@@ -1529,6 +1644,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Исполнитель/Альбом и Исполнитель/Сингл/';
|
'Исполнитель/Альбом и Исполнитель/Сингл/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Удалить выбранные';
|
String get downloadedAlbumDeleteSelected => 'Удалить выбранные';
|
||||||
|
|
||||||
@@ -1802,6 +1924,17 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryFilesUnit(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'files',
|
||||||
|
one: 'file',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryLastScanned(String time) {
|
String libraryLastScanned(String time) {
|
||||||
return 'Последнее сканирование: $time';
|
return 'Последнее сканирование: $time';
|
||||||
@@ -1813,6 +1946,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryScanning => 'Сканирование...';
|
String get libraryScanning => 'Сканирование...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanFinalizing => 'Finalizing library...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryScanProgress(String progress, int total) {
|
String libraryScanProgress(String progress, int total) {
|
||||||
return '$progress% из $total файлов';
|
return '$progress% из $total файлов';
|
||||||
@@ -1889,6 +2025,24 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterFormat => 'Формат';
|
String get libraryFilterFormat => 'Формат';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadata => 'Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryFilterSort => 'Сортировка';
|
String get libraryFilterSort => 'Сортировка';
|
||||||
|
|
||||||
@@ -1898,6 +2052,18 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterSortOldest => 'Старые';
|
String get libraryFilterSortOldest => 'Старые';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get timeJustNow => 'Только что';
|
String get timeJustNow => 'Только что';
|
||||||
|
|
||||||
@@ -1940,7 +2106,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 =>
|
||||||
@@ -2036,7 +2202,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
|
||||||
@@ -2061,7 +2227,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 => 'Папка кэша приложения';
|
||||||
@@ -2107,7 +2273,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 => 'Нет кэшированных данных';
|
||||||
@@ -2155,7 +2321,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) {
|
||||||
@@ -2188,6 +2354,30 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get trackReEnrichOnlineSubtitle =>
|
String get trackReEnrichOnlineSubtitle =>
|
||||||
'Поиск в сети метаданных и встраивание в файл';
|
'Поиск в сети метаданных и встраивание в файл';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldsTitle => 'Fields to update';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldCover => 'Cover Art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldBasicTags => 'Album, Album Artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldTrackInfo => 'Track & Disc Number';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldReleaseInfo => 'Date & ISRC';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFieldExtra => 'Genre, Label, Copyright';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSelectAll => 'Select All';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackEditMetadata => 'Редактировать метаданные';
|
String get trackEditMetadata => 'Редактировать метаданные';
|
||||||
|
|
||||||
@@ -2295,52 +2485,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 => 'Создать';
|
||||||
@@ -2506,7 +2696,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) {
|
||||||
@@ -2539,7 +2730,7 @@ 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
|
@override
|
||||||
@@ -2608,10 +2799,6 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get lyricsProvidersDiscardContent =>
|
String get lyricsProvidersDiscardContent =>
|
||||||
'You have unsaved changes that will be lost.';
|
'You have unsaved changes that will be lost.';
|
||||||
|
|
||||||
@override
|
|
||||||
String get lyricsProviderSpotifyApiDesc =>
|
|
||||||
'Spotify-sourced synced lyrics via community API';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||||
|
|
||||||
@@ -2743,6 +2930,22 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
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
|
@override
|
||||||
String get downloadSongLinkRegion => 'SongLink Region';
|
String get downloadSongLinkRegion => 'SongLink Region';
|
||||||
|
|
||||||
@@ -2973,4 +3176,294 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
String get editMetadataSelectEmpty => 'Empty only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueDownloadingCount(int count) {
|
||||||
|
return 'Downloading ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueDownloadedHeader => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFilteringIndicator => 'Filtering...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueTrackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueAlbumCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count albums',
|
||||||
|
one: '1 album',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbums => 'No album downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbumsSubtitle =>
|
||||||
|
'Download multiple tracks from an album to see them here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySingles => 'No single downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySinglesSubtitle =>
|
||||||
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistory => 'No download history';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDescription =>
|
||||||
|
'Verify lossless quality with spectrum analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisChannels => 'Channels';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDuration => 'Duration';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisNyquist => 'Nyquist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisFileSize => 'Size';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisPeak => 'Peak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisRms => 'RMS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSamples => 'Samples';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
+643
-157
File diff suppressed because it is too large
Load Diff
+5789
-5198
File diff suppressed because it is too large
Load Diff
+143
-51
@@ -450,7 +450,7 @@
|
|||||||
"@aboutSpotiSaverDesc": {
|
"@aboutSpotiSaverDesc": {
|
||||||
"description": "Credit for SpotiSaver API"
|
"description": "Credit for SpotiSaver API"
|
||||||
},
|
},
|
||||||
"aboutAppDescription": "Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.",
|
"aboutAppDescription": "Lade Spotify-Titel in verlustfreier Qualität von Tidal und Qobuz herunter.",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -555,7 +555,7 @@
|
|||||||
"@setupDownloadLocationTitle": {
|
"@setupDownloadLocationTitle": {
|
||||||
"description": "Download location dialog title"
|
"description": "Download location dialog title"
|
||||||
},
|
},
|
||||||
"setupDownloadLocationIosMessage": "Auf iOS werden Downloads im Dokumentenverzeichnis der App gespeichert. Du kannst sie über die Datei-App aufrufen.",
|
"setupDownloadLocationIosMessage": "Auf iOS werden Downloads im Dokumentenordner der App gespeichert. Du kannst sie über die Datei-App aufrufen.",
|
||||||
"@setupDownloadLocationIosMessage": {
|
"@setupDownloadLocationIosMessage": {
|
||||||
"description": "iOS-specific folder info"
|
"description": "iOS-specific folder info"
|
||||||
},
|
},
|
||||||
@@ -897,6 +897,18 @@
|
|||||||
"@errorNoTracksFound": {
|
"@errorNoTracksFound": {
|
||||||
"description": "Error - search returned no results"
|
"description": "Error - search returned no results"
|
||||||
},
|
},
|
||||||
|
"errorUrlNotRecognized": "Link wurde nicht erkannt",
|
||||||
|
"@errorUrlNotRecognized": {
|
||||||
|
"description": "Error title - URL not handled by any extension or service"
|
||||||
|
},
|
||||||
|
"errorUrlNotRecognizedMessage": "Dieser Link ist inkompatibel. Prüfe die URL und stelle sicher, dass eine kompatible Erweiterung installiert ist.",
|
||||||
|
"@errorUrlNotRecognizedMessage": {
|
||||||
|
"description": "Error message - URL not recognized explanation"
|
||||||
|
},
|
||||||
|
"errorUrlFetchFailed": "Laden fehlgeschlagen. Bitte erneut versuchen.",
|
||||||
|
"@errorUrlFetchFailed": {
|
||||||
|
"description": "Error message - generic URL fetch failure"
|
||||||
|
},
|
||||||
"errorMissingExtensionSource": "Kann {item} nicht lade wegen fehlender Erweiterungsquelle",
|
"errorMissingExtensionSource": "Kann {item} nicht lade wegen fehlender Erweiterungsquelle",
|
||||||
"@errorMissingExtensionSource": {
|
"@errorMissingExtensionSource": {
|
||||||
"description": "Error - extension source not available",
|
"description": "Error - extension source not available",
|
||||||
@@ -947,7 +959,7 @@
|
|||||||
"@selectionAllSelected": {
|
"@selectionAllSelected": {
|
||||||
"description": "Status - all items selected"
|
"description": "Status - all items selected"
|
||||||
},
|
},
|
||||||
"selectionSelectToDelete": "Titel zum Löschen auswählen",
|
"selectionSelectToDelete": "Titel zum Löschen wählen",
|
||||||
"@selectionSelectToDelete": {
|
"@selectionSelectToDelete": {
|
||||||
"description": "Placeholder when nothing selected"
|
"description": "Placeholder when nothing selected"
|
||||||
},
|
},
|
||||||
@@ -975,7 +987,7 @@
|
|||||||
"@searchArtists": {
|
"@searchArtists": {
|
||||||
"description": "Search result category - artists"
|
"description": "Search result category - artists"
|
||||||
},
|
},
|
||||||
"searchAlbums": "Albums",
|
"searchAlbums": "Alben",
|
||||||
"@searchAlbums": {
|
"@searchAlbums": {
|
||||||
"description": "Search result category - albums"
|
"description": "Search result category - albums"
|
||||||
},
|
},
|
||||||
@@ -1003,6 +1015,14 @@
|
|||||||
"@folderOrganizationNone": {
|
"@folderOrganizationNone": {
|
||||||
"description": "Folder option - flat structure"
|
"description": "Folder option - flat structure"
|
||||||
},
|
},
|
||||||
|
"folderOrganizationByPlaylist": "Nach Playlist",
|
||||||
|
"@folderOrganizationByPlaylist": {
|
||||||
|
"description": "Folder option - playlist folders"
|
||||||
|
},
|
||||||
|
"folderOrganizationByPlaylistSubtitle": "Ordner für jede Playlist trennen",
|
||||||
|
"@folderOrganizationByPlaylistSubtitle": {
|
||||||
|
"description": "Subtitle for playlist folder option"
|
||||||
|
},
|
||||||
"folderOrganizationByArtist": "Nach Künstler",
|
"folderOrganizationByArtist": "Nach Künstler",
|
||||||
"@folderOrganizationByArtist": {
|
"@folderOrganizationByArtist": {
|
||||||
"description": "Folder option - artist folders"
|
"description": "Folder option - artist folders"
|
||||||
@@ -1019,7 +1039,7 @@
|
|||||||
"@folderOrganizationDescription": {
|
"@folderOrganizationDescription": {
|
||||||
"description": "Folder organization sheet description"
|
"description": "Folder organization sheet description"
|
||||||
},
|
},
|
||||||
"folderOrganizationNoneSubtitle": "Alle Dateien im Download-Verzeichnis",
|
"folderOrganizationNoneSubtitle": "Alle Dateien im Download-Ordner",
|
||||||
"@folderOrganizationNoneSubtitle": {
|
"@folderOrganizationNoneSubtitle": {
|
||||||
"description": "Subtitle for no organization option"
|
"description": "Subtitle for no organization option"
|
||||||
},
|
},
|
||||||
@@ -1097,7 +1117,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "Integriert",
|
"providerBuiltIn": "Integriert",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Erweiterung",
|
"providerExtension": "Erweiterung",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1753,23 +1773,11 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube bietet nur verlustbehaftete Audioqualität. Deswegen ist es kein Teil des verlustfreien Fallbacks.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Qualität vor Download fragen",
|
"downloadAskBeforeDownload": "Qualität vor Download fragen",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
},
|
},
|
||||||
"downloadDirectory": "Downloadverzeichnis",
|
"downloadDirectory": "Download-Ordner",
|
||||||
"@downloadDirectory": {
|
"@downloadDirectory": {
|
||||||
"description": "Setting - download folder"
|
"description": "Setting - download folder"
|
||||||
},
|
},
|
||||||
@@ -1777,15 +1785,15 @@
|
|||||||
"@downloadSeparateSinglesFolder": {
|
"@downloadSeparateSinglesFolder": {
|
||||||
"description": "Setting - separate folder for singles"
|
"description": "Setting - separate folder for singles"
|
||||||
},
|
},
|
||||||
"downloadAlbumFolderStructure": "Album Folder Structure",
|
"downloadAlbumFolderStructure": "Album-Ordnerstruktur",
|
||||||
"@downloadAlbumFolderStructure": {
|
"@downloadAlbumFolderStructure": {
|
||||||
"description": "Setting - album folder organization"
|
"description": "Setting - album folder organization"
|
||||||
},
|
},
|
||||||
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
"downloadUseAlbumArtistForFolders": "Album-Künstler für Ordner verwenden",
|
||||||
"@downloadUseAlbumArtistForFolders": {
|
"@downloadUseAlbumArtistForFolders": {
|
||||||
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
||||||
},
|
},
|
||||||
"downloadUsePrimaryArtistOnly": "Primary artist only for folders",
|
"downloadUsePrimaryArtistOnly": "Primärer Künstler nur für Ordner",
|
||||||
"@downloadUsePrimaryArtistOnly": {
|
"@downloadUsePrimaryArtistOnly": {
|
||||||
"description": "Setting - strip featured artists from folder name"
|
"description": "Setting - strip featured artists from folder name"
|
||||||
},
|
},
|
||||||
@@ -1793,7 +1801,7 @@
|
|||||||
"@downloadUsePrimaryArtistOnlyEnabled": {
|
"@downloadUsePrimaryArtistOnlyEnabled": {
|
||||||
"description": "Subtitle when primary artist only is enabled"
|
"description": "Subtitle when primary artist only is enabled"
|
||||||
},
|
},
|
||||||
"downloadUsePrimaryArtistOnlyDisabled": "Full artist string used for folder name",
|
"downloadUsePrimaryArtistOnlyDisabled": "Vollständiger Künstler für Ordnername",
|
||||||
"@downloadUsePrimaryArtistOnlyDisabled": {
|
"@downloadUsePrimaryArtistOnlyDisabled": {
|
||||||
"description": "Subtitle when primary artist only is disabled"
|
"description": "Subtitle when primary artist only is disabled"
|
||||||
},
|
},
|
||||||
@@ -1821,7 +1829,7 @@
|
|||||||
"@queueClearAllMessage": {
|
"@queueClearAllMessage": {
|
||||||
"description": "Clear queue confirmation"
|
"description": "Clear queue confirmation"
|
||||||
},
|
},
|
||||||
"settingsAutoExportFailed": "Auto-export failed downloads",
|
"settingsAutoExportFailed": "Auto-Export fehlgeschlagener Downloads",
|
||||||
"@settingsAutoExportFailed": {
|
"@settingsAutoExportFailed": {
|
||||||
"description": "Setting toggle for auto-export"
|
"description": "Setting toggle for auto-export"
|
||||||
},
|
},
|
||||||
@@ -1849,15 +1857,15 @@
|
|||||||
"@albumFolderArtistAlbum": {
|
"@albumFolderArtistAlbum": {
|
||||||
"description": "Album folder option"
|
"description": "Album folder option"
|
||||||
},
|
},
|
||||||
"albumFolderArtistAlbumSubtitle": "Albums/Artist Name/Album Name/",
|
"albumFolderArtistAlbumSubtitle": "Alben/Künster Name/Album Name/",
|
||||||
"@albumFolderArtistAlbumSubtitle": {
|
"@albumFolderArtistAlbumSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
"albumFolderArtistYearAlbum": "Artist / [Year] Album",
|
"albumFolderArtistYearAlbum": "Künstler / [Year] Album",
|
||||||
"@albumFolderArtistYearAlbum": {
|
"@albumFolderArtistYearAlbum": {
|
||||||
"description": "Album folder option with year"
|
"description": "Album folder option with year"
|
||||||
},
|
},
|
||||||
"albumFolderArtistYearAlbumSubtitle": "Albums/Künster Name/[2005] Album Name/",
|
"albumFolderArtistYearAlbumSubtitle": "Alben/Künster Name/[2005] Album Name/",
|
||||||
"@albumFolderArtistYearAlbumSubtitle": {
|
"@albumFolderArtistYearAlbumSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
@@ -1873,15 +1881,15 @@
|
|||||||
"@albumFolderYearAlbum": {
|
"@albumFolderYearAlbum": {
|
||||||
"description": "Album folder option with year"
|
"description": "Album folder option with year"
|
||||||
},
|
},
|
||||||
"albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/",
|
"albumFolderYearAlbumSubtitle": "Alben/[2005] Album Name/",
|
||||||
"@albumFolderYearAlbumSubtitle": {
|
"@albumFolderYearAlbumSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
|
"albumFolderArtistAlbumSingles": "Künstler / Album + Singles",
|
||||||
"@albumFolderArtistAlbumSingles": {
|
"@albumFolderArtistAlbumSingles": {
|
||||||
"description": "Album folder option with singles inside artist"
|
"description": "Album folder option with singles inside artist"
|
||||||
},
|
},
|
||||||
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
|
"albumFolderArtistAlbumSinglesSubtitle": "Künstler/Album/ und Künstler/Singles/",
|
||||||
"@albumFolderArtistAlbumSinglesSubtitle": {
|
"@albumFolderArtistAlbumSinglesSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
@@ -1924,7 +1932,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"downloadedAlbumSelectToDelete": "Select tracks to delete",
|
"downloadedAlbumSelectToDelete": "Titel zum Löschen wählen",
|
||||||
"@downloadedAlbumSelectToDelete": {
|
"@downloadedAlbumSelectToDelete": {
|
||||||
"description": "Placeholder when nothing selected"
|
"description": "Placeholder when nothing selected"
|
||||||
},
|
},
|
||||||
@@ -1996,7 +2004,7 @@
|
|||||||
"@discographyAlbumsOnly": {
|
"@discographyAlbumsOnly": {
|
||||||
"description": "Option - download only albums"
|
"description": "Option - download only albums"
|
||||||
},
|
},
|
||||||
"discographyAlbumsOnlySubtitle": "{count} Titel von {albumCount} Albums",
|
"discographyAlbumsOnlySubtitle": "{count} Titel aus {albumCount} Alben",
|
||||||
"@discographyAlbumsOnlySubtitle": {
|
"@discographyAlbumsOnlySubtitle": {
|
||||||
"description": "Subtitle showing album tracks count",
|
"description": "Subtitle showing album tracks count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2028,7 +2036,7 @@
|
|||||||
"@discographySelectAlbums": {
|
"@discographySelectAlbums": {
|
||||||
"description": "Option - manually select albums to download"
|
"description": "Option - manually select albums to download"
|
||||||
},
|
},
|
||||||
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
|
"discographySelectAlbumsSubtitle": "Wähle bestimmte Alben oder Singles",
|
||||||
"@discographySelectAlbumsSubtitle": {
|
"@discographySelectAlbumsSubtitle": {
|
||||||
"description": "Subtitle for select albums option"
|
"description": "Subtitle for select albums option"
|
||||||
},
|
},
|
||||||
@@ -2036,7 +2044,7 @@
|
|||||||
"@discographyFetchingTracks": {
|
"@discographyFetchingTracks": {
|
||||||
"description": "Progress - fetching album tracks"
|
"description": "Progress - fetching album tracks"
|
||||||
},
|
},
|
||||||
"discographyFetchingAlbum": "Fetching {current} of {total}...",
|
"discographyFetchingAlbum": "Lade {current} von {total}...",
|
||||||
"@discographyFetchingAlbum": {
|
"@discographyFetchingAlbum": {
|
||||||
"description": "Progress - fetching specific album",
|
"description": "Progress - fetching specific album",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2061,7 +2069,7 @@
|
|||||||
"@discographyDownloadSelected": {
|
"@discographyDownloadSelected": {
|
||||||
"description": "Button - download selected albums"
|
"description": "Button - download selected albums"
|
||||||
},
|
},
|
||||||
"discographyAddedToQueue": "Added {count} tracks to queue",
|
"discographyAddedToQueue": "{count} Titel zur Warteschlange hinzugefügt",
|
||||||
"@discographyAddedToQueue": {
|
"@discographyAddedToQueue": {
|
||||||
"description": "Snackbar - tracks added from discography",
|
"description": "Snackbar - tracks added from discography",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2086,7 +2094,7 @@
|
|||||||
"@discographyNoAlbums": {
|
"@discographyNoAlbums": {
|
||||||
"description": "Error - no albums found for artist"
|
"description": "Error - no albums found for artist"
|
||||||
},
|
},
|
||||||
"discographyFailedToFetch": "Failed to fetch some albums",
|
"discographyFailedToFetch": "Fehler beim Abrufen einiger Alben",
|
||||||
"@discographyFailedToFetch": {
|
"@discographyFailedToFetch": {
|
||||||
"description": "Error - some albums failed to load"
|
"description": "Error - some albums failed to load"
|
||||||
},
|
},
|
||||||
@@ -2098,15 +2106,15 @@
|
|||||||
"@allFilesAccess": {
|
"@allFilesAccess": {
|
||||||
"description": "Toggle for MANAGE_EXTERNAL_STORAGE permission"
|
"description": "Toggle for MANAGE_EXTERNAL_STORAGE permission"
|
||||||
},
|
},
|
||||||
"allFilesAccessEnabledSubtitle": "Can write to any folder",
|
"allFilesAccessEnabledSubtitle": "Darf in jeden Ordner schreiben",
|
||||||
"@allFilesAccessEnabledSubtitle": {
|
"@allFilesAccessEnabledSubtitle": {
|
||||||
"description": "Subtitle when all files access is enabled"
|
"description": "Subtitle when all files access is enabled"
|
||||||
},
|
},
|
||||||
"allFilesAccessDisabledSubtitle": "Limited to media folders only",
|
"allFilesAccessDisabledSubtitle": "Nur auf Medienordner begrenzt",
|
||||||
"@allFilesAccessDisabledSubtitle": {
|
"@allFilesAccessDisabledSubtitle": {
|
||||||
"description": "Subtitle when all files access is disabled"
|
"description": "Subtitle when all files access is disabled"
|
||||||
},
|
},
|
||||||
"allFilesAccessDescription": "Aktiviere die Option, wenn beim Speichern in benutzerdefinierten Ordnern Schreibfehler auftreten. Weil Android 13+ standardmäßig den Zugriff auf bestimmte Verzeichnisse einschränkt.",
|
"allFilesAccessDescription": "Option bei Schreibfehlern bitte aktivieren (erforderlich ab Android 13).",
|
||||||
"@allFilesAccessDescription": {
|
"@allFilesAccessDescription": {
|
||||||
"description": "Description explaining when to enable all files access"
|
"description": "Description explaining when to enable all files access"
|
||||||
},
|
},
|
||||||
@@ -2122,7 +2130,7 @@
|
|||||||
"@settingsLocalLibrary": {
|
"@settingsLocalLibrary": {
|
||||||
"description": "Settings menu item - local library"
|
"description": "Settings menu item - local library"
|
||||||
},
|
},
|
||||||
"settingsLocalLibrarySubtitle": "Scan music & detect duplicates",
|
"settingsLocalLibrarySubtitle": "Musik scannen & Duplikate erkennen",
|
||||||
"@settingsLocalLibrarySubtitle": {
|
"@settingsLocalLibrarySubtitle": {
|
||||||
"description": "Subtitle for local library settings"
|
"description": "Subtitle for local library settings"
|
||||||
},
|
},
|
||||||
@@ -2130,7 +2138,7 @@
|
|||||||
"@settingsCache": {
|
"@settingsCache": {
|
||||||
"description": "Settings menu item - cache management"
|
"description": "Settings menu item - cache management"
|
||||||
},
|
},
|
||||||
"settingsCacheSubtitle": "View size and clear cached data",
|
"settingsCacheSubtitle": "Größe anzeigen und Daten im Cache leeren",
|
||||||
"@settingsCacheSubtitle": {
|
"@settingsCacheSubtitle": {
|
||||||
"description": "Subtitle for cache management menu"
|
"description": "Subtitle for cache management menu"
|
||||||
},
|
},
|
||||||
@@ -2146,7 +2154,7 @@
|
|||||||
"@libraryEnableLocalLibrary": {
|
"@libraryEnableLocalLibrary": {
|
||||||
"description": "Toggle to enable library scanning"
|
"description": "Toggle to enable library scanning"
|
||||||
},
|
},
|
||||||
"libraryEnableLocalLibrarySubtitle": "Scan and track your existing music",
|
"libraryEnableLocalLibrarySubtitle": "Scan und verfolge deine bestehende Musik",
|
||||||
"@libraryEnableLocalLibrarySubtitle": {
|
"@libraryEnableLocalLibrarySubtitle": {
|
||||||
"description": "Subtitle for enable toggle"
|
"description": "Subtitle for enable toggle"
|
||||||
},
|
},
|
||||||
@@ -2158,7 +2166,7 @@
|
|||||||
"@libraryFolderHint": {
|
"@libraryFolderHint": {
|
||||||
"description": "Placeholder when no folder selected"
|
"description": "Placeholder when no folder selected"
|
||||||
},
|
},
|
||||||
"libraryShowDuplicateIndicator": "Show Duplicate Indicator",
|
"libraryShowDuplicateIndicator": "Duplikat Indikator anzeigen",
|
||||||
"@libraryShowDuplicateIndicator": {
|
"@libraryShowDuplicateIndicator": {
|
||||||
"description": "Toggle for duplicate indicator in search"
|
"description": "Toggle for duplicate indicator in search"
|
||||||
},
|
},
|
||||||
@@ -2383,7 +2391,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"description": "Tutorial welcome tip 1"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip2": "Hole dir FLAC Audio von Tidal, Qobuz oder Amazon Musik",
|
"tutorialWelcomeTip2": "Hole dir FLAC Audio von Tidal, Qobuz oder Deezer",
|
||||||
"@tutorialWelcomeTip2": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2455,7 +2463,7 @@
|
|||||||
"@tutorialSettingsDesc": {
|
"@tutorialSettingsDesc": {
|
||||||
"description": "Tutorial settings page description"
|
"description": "Tutorial settings page description"
|
||||||
},
|
},
|
||||||
"tutorialSettingsTip1": "Downloadverzeichnis und Ordnerorganisation ändern",
|
"tutorialSettingsTip1": "Download-Ordner und Ordner-Organisation ändern",
|
||||||
"@tutorialSettingsTip1": {
|
"@tutorialSettingsTip1": {
|
||||||
"description": "Tutorial settings tip 1"
|
"description": "Tutorial settings tip 1"
|
||||||
},
|
},
|
||||||
@@ -2529,7 +2537,7 @@
|
|||||||
"@cacheSectionMaintenance": {
|
"@cacheSectionMaintenance": {
|
||||||
"description": "Section header for cleanup actions"
|
"description": "Section header for cleanup actions"
|
||||||
},
|
},
|
||||||
"cacheAppDirectory": "App-Cache Verzeichnis",
|
"cacheAppDirectory": "App-Cache Ordner",
|
||||||
"@cacheAppDirectory": {
|
"@cacheAppDirectory": {
|
||||||
"description": "Cache item title for app cache directory"
|
"description": "Cache item title for app cache directory"
|
||||||
},
|
},
|
||||||
@@ -2537,7 +2545,7 @@
|
|||||||
"@cacheAppDirectoryDesc": {
|
"@cacheAppDirectoryDesc": {
|
||||||
"description": "Description of what app cache directory contains"
|
"description": "Description of what app cache directory contains"
|
||||||
},
|
},
|
||||||
"cacheTempDirectory": "Temporäres Verzeichnis",
|
"cacheTempDirectory": "Temporärer Ordner",
|
||||||
"@cacheTempDirectory": {
|
"@cacheTempDirectory": {
|
||||||
"description": "Cache item title for temporary files directory"
|
"description": "Cache item title for temporary files directory"
|
||||||
},
|
},
|
||||||
@@ -2705,7 +2713,7 @@
|
|||||||
"@trackEditMetadata": {
|
"@trackEditMetadata": {
|
||||||
"description": "Menu action - edit embedded metadata"
|
"description": "Menu action - edit embedded metadata"
|
||||||
},
|
},
|
||||||
"trackCoverSaved": "Cover art saved to {fileName}",
|
"trackCoverSaved": "Cover in {fileName} gespeichert",
|
||||||
"@trackCoverSaved": {
|
"@trackCoverSaved": {
|
||||||
"description": "Snackbar after cover art saved",
|
"description": "Snackbar after cover art saved",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2714,7 +2722,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"trackCoverNoSource": "No cover art source available",
|
"trackCoverNoSource": "Keine Cover Quelle vorhanden",
|
||||||
"@trackCoverNoSource": {
|
"@trackCoverNoSource": {
|
||||||
"description": "Snackbar when no cover art URL or embedded cover"
|
"description": "Snackbar when no cover art URL or embedded cover"
|
||||||
},
|
},
|
||||||
@@ -2808,6 +2816,90 @@
|
|||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"description": "Snackbar when conversion fails"
|
||||||
},
|
},
|
||||||
|
"cueSplitTitle": "CUE-Sheet aufteilen",
|
||||||
|
"@cueSplitTitle": {
|
||||||
|
"description": "Title for CUE split bottom sheet"
|
||||||
|
},
|
||||||
|
"cueSplitSubtitle": "CUE+FLAC in einzelne Titel aufteilen",
|
||||||
|
"@cueSplitSubtitle": {
|
||||||
|
"description": "Subtitle for CUE split menu item"
|
||||||
|
},
|
||||||
|
"cueSplitAlbum": "Album: {album}",
|
||||||
|
"@cueSplitAlbum": {
|
||||||
|
"description": "Album name in CUE split sheet",
|
||||||
|
"placeholders": {
|
||||||
|
"album": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitArtist": "Künstler: {artist}",
|
||||||
|
"@cueSplitArtist": {
|
||||||
|
"description": "Artist name in CUE split sheet",
|
||||||
|
"placeholders": {
|
||||||
|
"artist": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitTrackCount": "{count} Titel",
|
||||||
|
"@cueSplitTrackCount": {
|
||||||
|
"description": "Number of tracks in CUE sheet",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitConfirmTitle": "CUE-Album aufteilen",
|
||||||
|
"@cueSplitConfirmTitle": {
|
||||||
|
"description": "CUE split confirmation dialog title"
|
||||||
|
},
|
||||||
|
"cueSplitConfirmMessage": "Soll „{album}“ in {count} einzelne FLAC-Dateien aufgeteilt werden?\n\nDie Dateien werden im selben Ordner gespeichert.",
|
||||||
|
"@cueSplitConfirmMessage": {
|
||||||
|
"description": "CUE split confirmation dialog message",
|
||||||
|
"placeholders": {
|
||||||
|
"album": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitSplitting": "CUE-Sheet wird geteilt... ({current}/{total})",
|
||||||
|
"@cueSplitSplitting": {
|
||||||
|
"description": "Snackbar while splitting CUE",
|
||||||
|
"placeholders": {
|
||||||
|
"current": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitSuccess": "{count} Titel erfolgreich aufgeteilt",
|
||||||
|
"@cueSplitSuccess": {
|
||||||
|
"description": "Snackbar after successful CUE split",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitFailed": "CUE-Aufteilung fehlgeschlagen",
|
||||||
|
"@cueSplitFailed": {
|
||||||
|
"description": "Snackbar when CUE split fails"
|
||||||
|
},
|
||||||
|
"cueSplitNoAudioFile": "Audiodatei für dieses CUE-Sheet nicht gefunden",
|
||||||
|
"@cueSplitNoAudioFile": {
|
||||||
|
"description": "Error when CUE audio file is missing"
|
||||||
|
},
|
||||||
|
"cueSplitButton": "In Titel aufteilen",
|
||||||
|
"@cueSplitButton": {
|
||||||
|
"description": "Button text to start CUE splitting"
|
||||||
|
},
|
||||||
"actionCreate": "Erstellen",
|
"actionCreate": "Erstellen",
|
||||||
"@actionCreate": {
|
"@actionCreate": {
|
||||||
"description": "Generic action button - create"
|
"description": "Generic action button - create"
|
||||||
@@ -3094,11 +3186,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"downloadUseAlbumArtistForFoldersAlbumSubtitle": "Künstlerordner verwenden den Album-Interpreten, wenn verfügbar",
|
"downloadUseAlbumArtistForFoldersAlbumSubtitle": "Interpret-Ordner verwenden Album-Interpret, sofern vorhanden",
|
||||||
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": {
|
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": {
|
||||||
"description": "Subtitle when Album Artist is used for folder naming"
|
"description": "Subtitle when Album Artist is used for folder naming"
|
||||||
},
|
},
|
||||||
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only",
|
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Künstler-Ordner nur für Titel-Künstler",
|
||||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||||
"description": "Subtitle when Track Artist is used for folder naming"
|
"description": "Subtitle when Track Artist is used for folder naming"
|
||||||
}
|
}
|
||||||
|
|||||||
+705
-24
@@ -17,7 +17,7 @@
|
|||||||
"@navSettings": {
|
"@navSettings": {
|
||||||
"description": "Bottom navigation - Settings tab"
|
"description": "Bottom navigation - Settings tab"
|
||||||
},
|
},
|
||||||
"navStore": "Store",
|
"navStore": "Repo",
|
||||||
"@navStore": {
|
"@navStore": {
|
||||||
"description": "Bottom navigation - Extension store tab"
|
"description": "Bottom navigation - Extension store tab"
|
||||||
},
|
},
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"@homeTitle": {
|
"@homeTitle": {
|
||||||
"description": "Home screen title"
|
"description": "Home screen title"
|
||||||
},
|
},
|
||||||
"homeSubtitle": "Paste a Spotify link or search by name",
|
"homeSubtitle": "Paste a supported URL or search by name",
|
||||||
"@homeSubtitle": {
|
"@homeSubtitle": {
|
||||||
"description": "Subtitle shown below search box"
|
"description": "Subtitle shown below search box"
|
||||||
},
|
},
|
||||||
@@ -89,6 +89,14 @@
|
|||||||
"@downloadFilenameFormat": {
|
"@downloadFilenameFormat": {
|
||||||
"description": "Setting for output filename pattern"
|
"description": "Setting for output filename pattern"
|
||||||
},
|
},
|
||||||
|
"downloadSingleFilenameFormat": "Single Filename Format",
|
||||||
|
"@downloadSingleFilenameFormat": {
|
||||||
|
"description": "Setting for output filename pattern for singles/EPs"
|
||||||
|
},
|
||||||
|
"downloadSingleFilenameFormatDescription": "Filename pattern for singles and EPs. Uses the same tags as the album format.",
|
||||||
|
"@downloadSingleFilenameFormatDescription": {
|
||||||
|
"description": "Subtitle description for single filename format setting"
|
||||||
|
},
|
||||||
"downloadFolderOrganization": "Folder Organization",
|
"downloadFolderOrganization": "Folder Organization",
|
||||||
"@downloadFolderOrganization": {
|
"@downloadFolderOrganization": {
|
||||||
"description": "Setting for folder structure"
|
"description": "Setting for folder structure"
|
||||||
@@ -190,6 +198,42 @@
|
|||||||
"@optionsMaxQualityCoverSubtitle": {
|
"@optionsMaxQualityCoverSubtitle": {
|
||||||
"description": "Subtitle for max quality cover"
|
"description": "Subtitle for max quality cover"
|
||||||
},
|
},
|
||||||
|
"optionsReplayGain": "ReplayGain",
|
||||||
|
"@optionsReplayGain": {
|
||||||
|
"description": "Title for ReplayGain setting toggle"
|
||||||
|
},
|
||||||
|
"optionsReplayGainSubtitleOn": "Scan loudness and embed ReplayGain tags (EBU R128)",
|
||||||
|
"@optionsReplayGainSubtitleOn": {
|
||||||
|
"description": "Subtitle when ReplayGain is enabled"
|
||||||
|
},
|
||||||
|
"optionsReplayGainSubtitleOff": "Disabled: no loudness normalization tags",
|
||||||
|
"@optionsReplayGainSubtitleOff": {
|
||||||
|
"description": "Subtitle when ReplayGain is disabled"
|
||||||
|
},
|
||||||
|
"optionsArtistTagMode": "Artist Tag Mode",
|
||||||
|
"@optionsArtistTagMode": {
|
||||||
|
"description": "Setting title for how artist metadata is written into files"
|
||||||
|
},
|
||||||
|
"optionsArtistTagModeDescription": "Choose how multiple artists are written into embedded tags.",
|
||||||
|
"@optionsArtistTagModeDescription": {
|
||||||
|
"description": "Bottom-sheet description for artist tag mode setting"
|
||||||
|
},
|
||||||
|
"optionsArtistTagModeJoined": "Single joined value",
|
||||||
|
"@optionsArtistTagModeJoined": {
|
||||||
|
"description": "Artist tag mode option that joins multiple artists into one value"
|
||||||
|
},
|
||||||
|
"optionsArtistTagModeJoinedSubtitle": "Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.",
|
||||||
|
"@optionsArtistTagModeJoinedSubtitle": {
|
||||||
|
"description": "Subtitle for joined artist tag mode"
|
||||||
|
},
|
||||||
|
"optionsArtistTagModeSplitVorbis": "Split tags for FLAC/Opus",
|
||||||
|
"@optionsArtistTagModeSplitVorbis": {
|
||||||
|
"description": "Artist tag mode option that writes repeated ARTIST tags for Vorbis formats"
|
||||||
|
},
|
||||||
|
"optionsArtistTagModeSplitVorbisSubtitle": "Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.",
|
||||||
|
"@optionsArtistTagModeSplitVorbisSubtitle": {
|
||||||
|
"description": "Subtitle for split Vorbis artist tag mode"
|
||||||
|
},
|
||||||
"optionsConcurrentDownloads": "Concurrent Downloads",
|
"optionsConcurrentDownloads": "Concurrent Downloads",
|
||||||
"@optionsConcurrentDownloads": {
|
"@optionsConcurrentDownloads": {
|
||||||
"description": "Number of parallel downloads"
|
"description": "Number of parallel downloads"
|
||||||
@@ -211,11 +255,11 @@
|
|||||||
"@optionsConcurrentWarning": {
|
"@optionsConcurrentWarning": {
|
||||||
"description": "Warning about rate limits"
|
"description": "Warning about rate limits"
|
||||||
},
|
},
|
||||||
"optionsExtensionStore": "Extension Store",
|
"optionsExtensionStore": "Extension Repo",
|
||||||
"@optionsExtensionStore": {
|
"@optionsExtensionStore": {
|
||||||
"description": "Show/hide store tab"
|
"description": "Show/hide store tab"
|
||||||
},
|
},
|
||||||
"optionsExtensionStoreSubtitle": "Show Store tab in navigation",
|
"optionsExtensionStoreSubtitle": "Show Repo tab in navigation",
|
||||||
"@optionsExtensionStoreSubtitle": {
|
"@optionsExtensionStoreSubtitle": {
|
||||||
"description": "Subtitle for extension store toggle"
|
"description": "Subtitle for extension store toggle"
|
||||||
},
|
},
|
||||||
@@ -318,7 +362,7 @@
|
|||||||
"@extensionsUninstall": {
|
"@extensionsUninstall": {
|
||||||
"description": "Uninstall extension button"
|
"description": "Uninstall extension button"
|
||||||
},
|
},
|
||||||
"storeTitle": "Extension Store",
|
"storeTitle": "Extension Repo",
|
||||||
"@storeTitle": {
|
"@storeTitle": {
|
||||||
"description": "Store screen title"
|
"description": "Store screen title"
|
||||||
},
|
},
|
||||||
@@ -999,6 +1043,46 @@
|
|||||||
"@searchPlaylists": {
|
"@searchPlaylists": {
|
||||||
"description": "Search result category - playlists"
|
"description": "Search result category - playlists"
|
||||||
},
|
},
|
||||||
|
"searchSortTitle": "Sort Results",
|
||||||
|
"@searchSortTitle": {
|
||||||
|
"description": "Bottom sheet title for search sort options"
|
||||||
|
},
|
||||||
|
"searchSortDefault": "Default",
|
||||||
|
"@searchSortDefault": {
|
||||||
|
"description": "Sort option - default API order"
|
||||||
|
},
|
||||||
|
"searchSortTitleAZ": "Title (A-Z)",
|
||||||
|
"@searchSortTitleAZ": {
|
||||||
|
"description": "Sort option - title ascending"
|
||||||
|
},
|
||||||
|
"searchSortTitleZA": "Title (Z-A)",
|
||||||
|
"@searchSortTitleZA": {
|
||||||
|
"description": "Sort option - title descending"
|
||||||
|
},
|
||||||
|
"searchSortArtistAZ": "Artist (A-Z)",
|
||||||
|
"@searchSortArtistAZ": {
|
||||||
|
"description": "Sort option - artist ascending"
|
||||||
|
},
|
||||||
|
"searchSortArtistZA": "Artist (Z-A)",
|
||||||
|
"@searchSortArtistZA": {
|
||||||
|
"description": "Sort option - artist descending"
|
||||||
|
},
|
||||||
|
"searchSortDurationShort": "Duration (Shortest)",
|
||||||
|
"@searchSortDurationShort": {
|
||||||
|
"description": "Sort option - shortest duration first"
|
||||||
|
},
|
||||||
|
"searchSortDurationLong": "Duration (Longest)",
|
||||||
|
"@searchSortDurationLong": {
|
||||||
|
"description": "Sort option - longest duration first"
|
||||||
|
},
|
||||||
|
"searchSortDateOldest": "Release Date (Oldest)",
|
||||||
|
"@searchSortDateOldest": {
|
||||||
|
"description": "Sort option - oldest release first"
|
||||||
|
},
|
||||||
|
"searchSortDateNewest": "Release Date (Newest)",
|
||||||
|
"@searchSortDateNewest": {
|
||||||
|
"description": "Sort option - newest release first"
|
||||||
|
},
|
||||||
"tooltipPlay": "Play",
|
"tooltipPlay": "Play",
|
||||||
"@tooltipPlay": {
|
"@tooltipPlay": {
|
||||||
"description": "Tooltip - play button"
|
"description": "Tooltip - play button"
|
||||||
@@ -1119,6 +1203,18 @@
|
|||||||
"@providerPriorityInfo": {
|
"@providerPriorityInfo": {
|
||||||
"description": "Info tip about fallback behavior"
|
"description": "Info tip about fallback behavior"
|
||||||
},
|
},
|
||||||
|
"providerPriorityFallbackExtensionsTitle": "Extension Fallback",
|
||||||
|
"@providerPriorityFallbackExtensionsTitle": {
|
||||||
|
"description": "Section title for choosing which download extensions can be used as fallback providers"
|
||||||
|
},
|
||||||
|
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.",
|
||||||
|
"@providerPriorityFallbackExtensionsDescription": {
|
||||||
|
"description": "Section description for extension fallback selection"
|
||||||
|
},
|
||||||
|
"providerPriorityFallbackExtensionsHint": "Only enabled extensions with download-provider capability are listed here.",
|
||||||
|
"@providerPriorityFallbackExtensionsHint": {
|
||||||
|
"description": "Hint below the extension fallback selection list"
|
||||||
|
},
|
||||||
"providerBuiltIn": "Built-in",
|
"providerBuiltIn": "Built-in",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||||
@@ -1479,6 +1575,14 @@
|
|||||||
"@trackLyricsNotAvailable": {
|
"@trackLyricsNotAvailable": {
|
||||||
"description": "Message when lyrics not found"
|
"description": "Message when lyrics not found"
|
||||||
},
|
},
|
||||||
|
"trackLyricsNotInFile": "No lyrics found in this file",
|
||||||
|
"@trackLyricsNotInFile": {
|
||||||
|
"description": "Message when no embedded lyrics in audio file"
|
||||||
|
},
|
||||||
|
"trackFetchOnlineLyrics": "Fetch from Online",
|
||||||
|
"@trackFetchOnlineLyrics": {
|
||||||
|
"description": "Action - fetch lyrics from online providers"
|
||||||
|
},
|
||||||
"trackLyricsTimeout": "Request timed out. Try again later.",
|
"trackLyricsTimeout": "Request timed out. Try again later.",
|
||||||
"@trackLyricsTimeout": {
|
"@trackLyricsTimeout": {
|
||||||
"description": "Message when lyrics request times out"
|
"description": "Message when lyrics request times out"
|
||||||
@@ -1614,7 +1718,7 @@
|
|||||||
"@storeNewRepoUrlLabel": {
|
"@storeNewRepoUrlLabel": {
|
||||||
"description": "Label for the new repository URL field inside the dialog"
|
"description": "Label for the new repository URL field inside the dialog"
|
||||||
},
|
},
|
||||||
"storeLoadError": "Failed to load store",
|
"storeLoadError": "Failed to load repository",
|
||||||
"@storeLoadError": {
|
"@storeLoadError": {
|
||||||
"description": "Error heading when the store cannot be loaded"
|
"description": "Error heading when the store cannot be loaded"
|
||||||
},
|
},
|
||||||
@@ -1626,7 +1730,7 @@
|
|||||||
"@storeEmptyNoResults": {
|
"@storeEmptyNoResults": {
|
||||||
"description": "Message when search/filter returns no results"
|
"description": "Message when search/filter returns no results"
|
||||||
},
|
},
|
||||||
"extensionDefaultProvider": "Default (Deezer/Spotify)",
|
"extensionDefaultProvider": "Default (Deezer)",
|
||||||
"@extensionDefaultProvider": {
|
"@extensionDefaultProvider": {
|
||||||
"description": "Default search provider option"
|
"description": "Default search provider option"
|
||||||
},
|
},
|
||||||
@@ -1765,6 +1869,14 @@
|
|||||||
"@extensionsDownloadPrioritySubtitle": {
|
"@extensionsDownloadPrioritySubtitle": {
|
||||||
"description": "Subtitle for download priority"
|
"description": "Subtitle for download priority"
|
||||||
},
|
},
|
||||||
|
"extensionsFallbackTitle": "Fallback Extensions",
|
||||||
|
"@extensionsFallbackTitle": {
|
||||||
|
"description": "Setting and page title for choosing which download extensions can be used during fallback"
|
||||||
|
},
|
||||||
|
"extensionsFallbackSubtitle": "Choose which installed download extensions can be used as fallback",
|
||||||
|
"@extensionsFallbackSubtitle": {
|
||||||
|
"description": "Subtitle for download fallback extensions menu"
|
||||||
|
},
|
||||||
"extensionsNoDownloadProvider": "No extensions with download provider",
|
"extensionsNoDownloadProvider": "No extensions with download provider",
|
||||||
"@extensionsNoDownloadProvider": {
|
"@extensionsNoDownloadProvider": {
|
||||||
"description": "Empty state - no download providers"
|
"description": "Empty state - no download providers"
|
||||||
@@ -1825,22 +1937,50 @@
|
|||||||
"@qualityHiResFlacMaxSubtitle": {
|
"@qualityHiResFlacMaxSubtitle": {
|
||||||
"description": "Technical spec for hi-res max"
|
"description": "Technical spec for hi-res max"
|
||||||
},
|
},
|
||||||
|
"downloadLossy320": "Lossy 320kbps",
|
||||||
|
"@downloadLossy320": {
|
||||||
|
"description": "Quality option label for Tidal lossy 320kbps"
|
||||||
|
},
|
||||||
|
"downloadLossyFormat": "Lossy Format",
|
||||||
|
"@downloadLossyFormat": {
|
||||||
|
"description": "Setting title to pick output format for Tidal lossy downloads"
|
||||||
|
},
|
||||||
|
"downloadLossy320Format": "Lossy 320kbps Format",
|
||||||
|
"@downloadLossy320Format": {
|
||||||
|
"description": "Title of the Tidal lossy format picker bottom sheet"
|
||||||
|
},
|
||||||
|
"downloadLossy320FormatDesc": "Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.",
|
||||||
|
"@downloadLossy320FormatDesc": {
|
||||||
|
"description": "Description in the Tidal lossy format picker"
|
||||||
|
},
|
||||||
|
"downloadLossyMp3": "MP3 320kbps",
|
||||||
|
"@downloadLossyMp3": {
|
||||||
|
"description": "Tidal lossy format option - MP3 320kbps"
|
||||||
|
},
|
||||||
|
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
|
||||||
|
"@downloadLossyMp3Subtitle": {
|
||||||
|
"description": "Subtitle for MP3 320kbps Tidal lossy option"
|
||||||
|
},
|
||||||
|
"downloadLossyOpus256": "Opus 256kbps",
|
||||||
|
"@downloadLossyOpus256": {
|
||||||
|
"description": "Tidal lossy format option - Opus 256kbps"
|
||||||
|
},
|
||||||
|
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
|
||||||
|
"@downloadLossyOpus256Subtitle": {
|
||||||
|
"description": "Subtitle for Opus 256kbps Tidal lossy option"
|
||||||
|
},
|
||||||
|
"downloadLossyOpus128": "Opus 128kbps",
|
||||||
|
"@downloadLossyOpus128": {
|
||||||
|
"description": "Tidal lossy format option - Opus 128kbps"
|
||||||
|
},
|
||||||
|
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
|
||||||
|
"@downloadLossyOpus128Subtitle": {
|
||||||
|
"description": "Subtitle for Opus 128kbps Tidal lossy option"
|
||||||
|
},
|
||||||
"qualityNote": "Actual quality depends on track availability from the service",
|
"qualityNote": "Actual quality depends on track availability from the service",
|
||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Ask Before Download",
|
"downloadAskBeforeDownload": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -1961,6 +2101,14 @@
|
|||||||
"@albumFolderArtistAlbumSinglesSubtitle": {
|
"@albumFolderArtistAlbumSinglesSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
|
"albumFolderArtistAlbumFlat": "Artist / Album (Singles flat)",
|
||||||
|
"@albumFolderArtistAlbumFlat": {
|
||||||
|
"description": "Album folder option with singles directly in artist folder"
|
||||||
|
},
|
||||||
|
"albumFolderArtistAlbumFlatSubtitle": "Artist/Album/ and Artist/song.flac",
|
||||||
|
"@albumFolderArtistAlbumFlatSubtitle": {
|
||||||
|
"description": "Folder structure example for flat singles"
|
||||||
|
},
|
||||||
"downloadedAlbumDeleteSelected": "Delete Selected",
|
"downloadedAlbumDeleteSelected": "Delete Selected",
|
||||||
"@downloadedAlbumDeleteSelected": {
|
"@downloadedAlbumDeleteSelected": {
|
||||||
"description": "Button - delete selected tracks"
|
"description": "Button - delete selected tracks"
|
||||||
@@ -2323,6 +2471,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"libraryFilesUnit": "{count, plural, =1{file} other{files}}",
|
||||||
|
"@libraryFilesUnit": {
|
||||||
|
"description": "Unit label for files count during library scanning",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"libraryLastScanned": "Last scanned: {time}",
|
"libraryLastScanned": "Last scanned: {time}",
|
||||||
"@libraryLastScanned": {
|
"@libraryLastScanned": {
|
||||||
"description": "Last scan time display",
|
"description": "Last scan time display",
|
||||||
@@ -2340,6 +2497,10 @@
|
|||||||
"@libraryScanning": {
|
"@libraryScanning": {
|
||||||
"description": "Status during scan"
|
"description": "Status during scan"
|
||||||
},
|
},
|
||||||
|
"libraryScanFinalizing": "Finalizing library...",
|
||||||
|
"@libraryScanFinalizing": {
|
||||||
|
"description": "Status shown after file scanning finishes but library persistence is still running"
|
||||||
|
},
|
||||||
"libraryScanProgress": "{progress}% of {total} files",
|
"libraryScanProgress": "{progress}% of {total} files",
|
||||||
"@libraryScanProgress": {
|
"@libraryScanProgress": {
|
||||||
"description": "Scan progress display",
|
"description": "Scan progress display",
|
||||||
@@ -2437,6 +2598,30 @@
|
|||||||
"@libraryFilterFormat": {
|
"@libraryFilterFormat": {
|
||||||
"description": "Filter section - file format"
|
"description": "Filter section - file format"
|
||||||
},
|
},
|
||||||
|
"libraryFilterMetadata": "Metadata",
|
||||||
|
"@libraryFilterMetadata": {
|
||||||
|
"description": "Filter section - metadata completeness"
|
||||||
|
},
|
||||||
|
"libraryFilterMetadataComplete": "Complete metadata",
|
||||||
|
"@libraryFilterMetadataComplete": {
|
||||||
|
"description": "Filter option - items with complete metadata"
|
||||||
|
},
|
||||||
|
"libraryFilterMetadataMissingAny": "Missing any metadata",
|
||||||
|
"@libraryFilterMetadataMissingAny": {
|
||||||
|
"description": "Filter option - items missing any tracked metadata field"
|
||||||
|
},
|
||||||
|
"libraryFilterMetadataMissingYear": "Missing year",
|
||||||
|
"@libraryFilterMetadataMissingYear": {
|
||||||
|
"description": "Filter option - items missing release year/date"
|
||||||
|
},
|
||||||
|
"libraryFilterMetadataMissingGenre": "Missing genre",
|
||||||
|
"@libraryFilterMetadataMissingGenre": {
|
||||||
|
"description": "Filter option - items missing genre"
|
||||||
|
},
|
||||||
|
"libraryFilterMetadataMissingAlbumArtist": "Missing album artist",
|
||||||
|
"@libraryFilterMetadataMissingAlbumArtist": {
|
||||||
|
"description": "Filter option - items missing album artist"
|
||||||
|
},
|
||||||
"libraryFilterSort": "Sort",
|
"libraryFilterSort": "Sort",
|
||||||
"@libraryFilterSort": {
|
"@libraryFilterSort": {
|
||||||
"description": "Filter section - sort order"
|
"description": "Filter section - sort order"
|
||||||
@@ -2449,6 +2634,22 @@
|
|||||||
"@libraryFilterSortOldest": {
|
"@libraryFilterSortOldest": {
|
||||||
"description": "Sort option - oldest first"
|
"description": "Sort option - oldest first"
|
||||||
},
|
},
|
||||||
|
"libraryFilterSortAlbumAsc": "Album (A-Z)",
|
||||||
|
"@libraryFilterSortAlbumAsc": {
|
||||||
|
"description": "Sort option - album ascending"
|
||||||
|
},
|
||||||
|
"libraryFilterSortAlbumDesc": "Album (Z-A)",
|
||||||
|
"@libraryFilterSortAlbumDesc": {
|
||||||
|
"description": "Sort option - album descending"
|
||||||
|
},
|
||||||
|
"libraryFilterSortGenreAsc": "Genre (A-Z)",
|
||||||
|
"@libraryFilterSortGenreAsc": {
|
||||||
|
"description": "Sort option - genre ascending"
|
||||||
|
},
|
||||||
|
"libraryFilterSortGenreDesc": "Genre (Z-A)",
|
||||||
|
"@libraryFilterSortGenreDesc": {
|
||||||
|
"description": "Sort option - genre descending"
|
||||||
|
},
|
||||||
"timeJustNow": "Just now",
|
"timeJustNow": "Just now",
|
||||||
"@timeJustNow": {
|
"@timeJustNow": {
|
||||||
"description": "Relative time - less than a minute ago"
|
"description": "Relative time - less than a minute ago"
|
||||||
@@ -2535,7 +2736,7 @@
|
|||||||
"@tutorialExtensionsDesc": {
|
"@tutorialExtensionsDesc": {
|
||||||
"description": "Tutorial extensions page description"
|
"description": "Tutorial extensions page description"
|
||||||
},
|
},
|
||||||
"tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions",
|
"tutorialExtensionsTip1": "Browse the Repo tab to discover useful extensions",
|
||||||
"@tutorialExtensionsTip1": {
|
"@tutorialExtensionsTip1": {
|
||||||
"description": "Tutorial extensions tip 1"
|
"description": "Tutorial extensions tip 1"
|
||||||
},
|
},
|
||||||
@@ -2801,6 +3002,38 @@
|
|||||||
"@trackReEnrichOnlineSubtitle": {
|
"@trackReEnrichOnlineSubtitle": {
|
||||||
"description": "Subtitle for re-enrich metadata action for local items"
|
"description": "Subtitle for re-enrich metadata action for local items"
|
||||||
},
|
},
|
||||||
|
"trackReEnrichFieldsTitle": "Fields to update",
|
||||||
|
"@trackReEnrichFieldsTitle": {
|
||||||
|
"description": "Section title for field selection in re-enrich dialog"
|
||||||
|
},
|
||||||
|
"trackReEnrichFieldCover": "Cover Art",
|
||||||
|
"@trackReEnrichFieldCover": {
|
||||||
|
"description": "Checkbox label for cover art field in re-enrich"
|
||||||
|
},
|
||||||
|
"trackReEnrichFieldLyrics": "Lyrics",
|
||||||
|
"@trackReEnrichFieldLyrics": {
|
||||||
|
"description": "Checkbox label for lyrics field in re-enrich"
|
||||||
|
},
|
||||||
|
"trackReEnrichFieldBasicTags": "Album, Album Artist",
|
||||||
|
"@trackReEnrichFieldBasicTags": {
|
||||||
|
"description": "Checkbox label for basic tags in re-enrich (title/artist are never overwritten)"
|
||||||
|
},
|
||||||
|
"trackReEnrichFieldTrackInfo": "Track & Disc Number",
|
||||||
|
"@trackReEnrichFieldTrackInfo": {
|
||||||
|
"description": "Checkbox label for track info in re-enrich"
|
||||||
|
},
|
||||||
|
"trackReEnrichFieldReleaseInfo": "Date & ISRC",
|
||||||
|
"@trackReEnrichFieldReleaseInfo": {
|
||||||
|
"description": "Checkbox label for release info in re-enrich"
|
||||||
|
},
|
||||||
|
"trackReEnrichFieldExtra": "Genre, Label, Copyright",
|
||||||
|
"@trackReEnrichFieldExtra": {
|
||||||
|
"description": "Checkbox label for extra metadata in re-enrich"
|
||||||
|
},
|
||||||
|
"trackReEnrichSelectAll": "Select All",
|
||||||
|
"@trackReEnrichSelectAll": {
|
||||||
|
"description": "Select all fields checkbox in re-enrich"
|
||||||
|
},
|
||||||
"trackEditMetadata": "Edit Metadata",
|
"trackEditMetadata": "Edit Metadata",
|
||||||
"@trackEditMetadata": {
|
"@trackEditMetadata": {
|
||||||
"description": "Menu action - edit embedded metadata"
|
"description": "Menu action - edit embedded metadata"
|
||||||
@@ -3398,10 +3631,6 @@
|
|||||||
"@lyricsProvidersDiscardContent": {
|
"@lyricsProvidersDiscardContent": {
|
||||||
"description": "Body text of the discard-changes dialog on lyrics provider page"
|
"description": "Body text of the discard-changes dialog on lyrics provider page"
|
||||||
},
|
},
|
||||||
"lyricsProviderSpotifyApiDesc": "Spotify-sourced synced lyrics via community API",
|
|
||||||
"@lyricsProviderSpotifyApiDesc": {
|
|
||||||
"description": "Description for Spotify Lyrics API provider"
|
|
||||||
},
|
|
||||||
"lyricsProviderLrclibDesc": "Open-source synced lyrics database",
|
"lyricsProviderLrclibDesc": "Open-source synced lyrics database",
|
||||||
"@lyricsProviderLrclibDesc": {
|
"@lyricsProviderLrclibDesc": {
|
||||||
"description": "Description for LRCLIB provider"
|
"description": "Description for LRCLIB provider"
|
||||||
@@ -3586,6 +3815,22 @@
|
|||||||
"@downloadArtistNameFilters": {
|
"@downloadArtistNameFilters": {
|
||||||
"description": "Setting title for artist folder filter options"
|
"description": "Setting title for artist folder filter options"
|
||||||
},
|
},
|
||||||
|
"downloadCreatePlaylistSourceFolder": "Create playlist source folder",
|
||||||
|
"@downloadCreatePlaylistSourceFolder": {
|
||||||
|
"description": "Setting title for adding a playlist folder prefix before the normal organization structure"
|
||||||
|
},
|
||||||
|
"downloadCreatePlaylistSourceFolderEnabled": "Playlist downloads use Playlist/ plus your normal folder structure.",
|
||||||
|
"@downloadCreatePlaylistSourceFolderEnabled": {
|
||||||
|
"description": "Subtitle when playlist source folder prefix is enabled"
|
||||||
|
},
|
||||||
|
"downloadCreatePlaylistSourceFolderDisabled": "Playlist downloads use the normal folder structure only.",
|
||||||
|
"@downloadCreatePlaylistSourceFolderDisabled": {
|
||||||
|
"description": "Subtitle when playlist source folder prefix is disabled"
|
||||||
|
},
|
||||||
|
"downloadCreatePlaylistSourceFolderRedundant": "By Playlist already places downloads inside a playlist folder.",
|
||||||
|
"@downloadCreatePlaylistSourceFolderRedundant": {
|
||||||
|
"description": "Subtitle when playlist folder prefix setting is redundant because folder organization is already by playlist"
|
||||||
|
},
|
||||||
"downloadSongLinkRegion": "SongLink Region",
|
"downloadSongLinkRegion": "SongLink Region",
|
||||||
"@downloadSongLinkRegion": {
|
"@downloadSongLinkRegion": {
|
||||||
"description": "Setting title for SongLink country region"
|
"description": "Setting title for SongLink country region"
|
||||||
@@ -3847,5 +4092,441 @@
|
|||||||
"editMetadataSelectEmpty": "Empty only",
|
"editMetadataSelectEmpty": "Empty only",
|
||||||
"@editMetadataSelectEmpty": {
|
"@editMetadataSelectEmpty": {
|
||||||
"description": "Button to select only fields that are currently empty"
|
"description": "Button to select only fields that are currently empty"
|
||||||
|
},
|
||||||
|
|
||||||
|
"queueDownloadingCount": "Downloading ({count})",
|
||||||
|
"@queueDownloadingCount": {
|
||||||
|
"description": "Header for active downloads section with count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"queueDownloadedHeader": "Downloaded",
|
||||||
|
"@queueDownloadedHeader": {
|
||||||
|
"description": "Header label for downloaded items section in library"
|
||||||
|
},
|
||||||
|
"queueFilteringIndicator": "Filtering...",
|
||||||
|
"@queueFilteringIndicator": {
|
||||||
|
"description": "Shown while filter results are being computed"
|
||||||
|
},
|
||||||
|
"queueTrackCount": "{count, plural, =1{1 track} other{{count} tracks}}",
|
||||||
|
"@queueTrackCount": {
|
||||||
|
"description": "Track count label with plural support",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"queueAlbumCount": "{count, plural, =1{1 album} other{{count} albums}}",
|
||||||
|
"@queueAlbumCount": {
|
||||||
|
"description": "Album count label with plural support",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"queueEmptyAlbums": "No album downloads",
|
||||||
|
"@queueEmptyAlbums": {
|
||||||
|
"description": "Empty state title when no album downloads exist"
|
||||||
|
},
|
||||||
|
"queueEmptyAlbumsSubtitle": "Download multiple tracks from an album to see them here",
|
||||||
|
"@queueEmptyAlbumsSubtitle": {
|
||||||
|
"description": "Empty state subtitle for album downloads"
|
||||||
|
},
|
||||||
|
"queueEmptySingles": "No single downloads",
|
||||||
|
"@queueEmptySingles": {
|
||||||
|
"description": "Empty state title when no single track downloads exist"
|
||||||
|
},
|
||||||
|
"queueEmptySinglesSubtitle": "Single track downloads will appear here",
|
||||||
|
"@queueEmptySinglesSubtitle": {
|
||||||
|
"description": "Empty state subtitle for single track downloads"
|
||||||
|
},
|
||||||
|
"queueEmptyHistory": "No download history",
|
||||||
|
"@queueEmptyHistory": {
|
||||||
|
"description": "Empty state title when download history is empty"
|
||||||
|
},
|
||||||
|
"queueEmptyHistorySubtitle": "Downloaded tracks will appear here",
|
||||||
|
"@queueEmptyHistorySubtitle": {
|
||||||
|
"description": "Empty state subtitle for download history"
|
||||||
|
},
|
||||||
|
"selectionAllPlaylistsSelected": "All playlists selected",
|
||||||
|
"@selectionAllPlaylistsSelected": {
|
||||||
|
"description": "Shown when all playlists are selected in selection mode"
|
||||||
|
},
|
||||||
|
"selectionTapPlaylistsToSelect": "Tap playlists to select",
|
||||||
|
"@selectionTapPlaylistsToSelect": {
|
||||||
|
"description": "Hint shown in playlist selection mode"
|
||||||
|
},
|
||||||
|
"selectionSelectPlaylistsToDelete": "Select playlists to delete",
|
||||||
|
"@selectionSelectPlaylistsToDelete": {
|
||||||
|
"description": "Hint shown when no playlists are selected for deletion"
|
||||||
|
},
|
||||||
|
"audioAnalysisTitle": "Audio Quality Analysis",
|
||||||
|
"@audioAnalysisTitle": {
|
||||||
|
"description": "Title for audio analysis section"
|
||||||
|
},
|
||||||
|
"audioAnalysisDescription": "Verify lossless quality with spectrum analysis",
|
||||||
|
"@audioAnalysisDescription": {
|
||||||
|
"description": "Description for audio analysis tap-to-analyze prompt"
|
||||||
|
},
|
||||||
|
"audioAnalysisAnalyzing": "Analyzing audio...",
|
||||||
|
"@audioAnalysisAnalyzing": {
|
||||||
|
"description": "Loading text while analyzing audio"
|
||||||
|
},
|
||||||
|
"audioAnalysisSampleRate": "Sample Rate",
|
||||||
|
"@audioAnalysisSampleRate": {
|
||||||
|
"description": "Sample rate metric label"
|
||||||
|
},
|
||||||
|
"audioAnalysisBitDepth": "Bit Depth",
|
||||||
|
"@audioAnalysisBitDepth": {
|
||||||
|
"description": "Bit depth metric label"
|
||||||
|
},
|
||||||
|
"audioAnalysisChannels": "Channels",
|
||||||
|
"@audioAnalysisChannels": {
|
||||||
|
"description": "Channels metric label"
|
||||||
|
},
|
||||||
|
"audioAnalysisDuration": "Duration",
|
||||||
|
"@audioAnalysisDuration": {
|
||||||
|
"description": "Duration metric label"
|
||||||
|
},
|
||||||
|
"audioAnalysisNyquist": "Nyquist",
|
||||||
|
"@audioAnalysisNyquist": {
|
||||||
|
"description": "Nyquist frequency metric label"
|
||||||
|
},
|
||||||
|
"audioAnalysisFileSize": "Size",
|
||||||
|
"@audioAnalysisFileSize": {
|
||||||
|
"description": "File size metric label"
|
||||||
|
},
|
||||||
|
"audioAnalysisDynamicRange": "Dynamic Range",
|
||||||
|
"@audioAnalysisDynamicRange": {
|
||||||
|
"description": "Dynamic range metric label"
|
||||||
|
},
|
||||||
|
"audioAnalysisPeak": "Peak",
|
||||||
|
"@audioAnalysisPeak": {
|
||||||
|
"description": "Peak amplitude metric label"
|
||||||
|
},
|
||||||
|
"audioAnalysisRms": "RMS",
|
||||||
|
"@audioAnalysisRms": {
|
||||||
|
"description": "RMS level metric label"
|
||||||
|
},
|
||||||
|
"audioAnalysisSamples": "Samples",
|
||||||
|
"@audioAnalysisSamples": {
|
||||||
|
"description": "Total samples metric label"
|
||||||
|
},
|
||||||
|
|
||||||
|
"extensionsSearchWith": "Search with {providerName}",
|
||||||
|
"@extensionsSearchWith": {
|
||||||
|
"description": "Extensions page - subtitle for built-in search provider option",
|
||||||
|
"placeholders": {
|
||||||
|
"providerName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"extensionsHomeFeedProvider": "Home Feed Provider",
|
||||||
|
"@extensionsHomeFeedProvider": {
|
||||||
|
"description": "Extensions page - label for home feed provider selector"
|
||||||
|
},
|
||||||
|
"extensionsHomeFeedDescription": "Choose which extension provides the home feed on the main screen",
|
||||||
|
"@extensionsHomeFeedDescription": {
|
||||||
|
"description": "Extensions page - description for home feed provider picker"
|
||||||
|
},
|
||||||
|
"extensionsHomeFeedAuto": "Auto",
|
||||||
|
"@extensionsHomeFeedAuto": {
|
||||||
|
"description": "Extensions page - home feed provider option: auto"
|
||||||
|
},
|
||||||
|
"extensionsHomeFeedAutoSubtitle": "Automatically select the best available",
|
||||||
|
"@extensionsHomeFeedAutoSubtitle": {
|
||||||
|
"description": "Extensions page - subtitle for auto home feed option"
|
||||||
|
},
|
||||||
|
"extensionsHomeFeedUse": "Use {extensionName} home feed",
|
||||||
|
"@extensionsHomeFeedUse": {
|
||||||
|
"description": "Extensions page - subtitle for a specific extension home feed option",
|
||||||
|
"placeholders": {
|
||||||
|
"extensionName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"extensionsNoHomeFeedExtensions": "No extensions with home feed",
|
||||||
|
"@extensionsNoHomeFeedExtensions": {
|
||||||
|
"description": "Extensions page - shown when no installed extension has home feed"
|
||||||
|
},
|
||||||
|
|
||||||
|
"sortAlphaAsc": "A-Z",
|
||||||
|
"@sortAlphaAsc": {
|
||||||
|
"description": "Sort option - alphabetical ascending"
|
||||||
|
},
|
||||||
|
"sortAlphaDesc": "Z-A",
|
||||||
|
"@sortAlphaDesc": {
|
||||||
|
"description": "Sort option - alphabetical descending"
|
||||||
|
},
|
||||||
|
"cancelDownloadTitle": "Cancel download?",
|
||||||
|
"@cancelDownloadTitle": {
|
||||||
|
"description": "Dialog title when confirming cancellation of an active download"
|
||||||
|
},
|
||||||
|
"cancelDownloadContent": "This will cancel the active download for \"{trackName}\".",
|
||||||
|
"@cancelDownloadContent": {
|
||||||
|
"description": "Dialog body when confirming cancellation of an active download",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cancelDownloadKeep": "Keep",
|
||||||
|
"@cancelDownloadKeep": {
|
||||||
|
"description": "Dialog button - keep the active download (do not cancel)"
|
||||||
|
},
|
||||||
|
|
||||||
|
"metadataSaveFailedFfmpeg": "Failed to save metadata via FFmpeg",
|
||||||
|
"@metadataSaveFailedFfmpeg": {
|
||||||
|
"description": "Snackbar error when FFmpeg fails to write metadata"
|
||||||
|
},
|
||||||
|
"metadataSaveFailedStorage": "Failed to write metadata back to storage",
|
||||||
|
"@metadataSaveFailedStorage": {
|
||||||
|
"description": "Snackbar error when writing metadata file back to storage fails"
|
||||||
|
},
|
||||||
|
|
||||||
|
"snackbarFolderPickerFailed": "Failed to open folder picker: {error}",
|
||||||
|
"@snackbarFolderPickerFailed": {
|
||||||
|
"description": "Snackbar shown when folder picker fails to open",
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"errorLoadAlbum": "Failed to load album",
|
||||||
|
"@errorLoadAlbum": {
|
||||||
|
"description": "Error state shown when album fails to load"
|
||||||
|
},
|
||||||
|
"errorLoadPlaylist": "Failed to load playlist",
|
||||||
|
"@errorLoadPlaylist": {
|
||||||
|
"description": "Error state shown when playlist fails to load"
|
||||||
|
},
|
||||||
|
"errorLoadArtist": "Failed to load artist",
|
||||||
|
"@errorLoadArtist": {
|
||||||
|
"description": "Error state shown when artist fails to load"
|
||||||
|
},
|
||||||
|
|
||||||
|
"notifChannelDownloadName": "Download Progress",
|
||||||
|
"@notifChannelDownloadName": {
|
||||||
|
"description": "Android notification channel name for download progress"
|
||||||
|
},
|
||||||
|
"notifChannelDownloadDesc": "Shows download progress for tracks",
|
||||||
|
"@notifChannelDownloadDesc": {
|
||||||
|
"description": "Android notification channel description for download progress"
|
||||||
|
},
|
||||||
|
"notifChannelLibraryScanName": "Library Scan",
|
||||||
|
"@notifChannelLibraryScanName": {
|
||||||
|
"description": "Android notification channel name for library scan"
|
||||||
|
},
|
||||||
|
"notifChannelLibraryScanDesc": "Shows local library scan progress",
|
||||||
|
"@notifChannelLibraryScanDesc": {
|
||||||
|
"description": "Android notification channel description for library scan"
|
||||||
|
},
|
||||||
|
"notifDownloadingTrack": "Downloading {trackName}",
|
||||||
|
"@notifDownloadingTrack": {
|
||||||
|
"description": "Notification title while downloading a track",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifFinalizingTrack": "Finalizing {trackName}",
|
||||||
|
"@notifFinalizingTrack": {
|
||||||
|
"description": "Notification title while finalizing (embedding metadata) a track",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifEmbeddingMetadata": "Embedding metadata...",
|
||||||
|
"@notifEmbeddingMetadata": {
|
||||||
|
"description": "Notification body while embedding metadata into a downloaded track"
|
||||||
|
},
|
||||||
|
"notifAlreadyInLibraryCount": "Already in Library ({completed}/{total})",
|
||||||
|
"@notifAlreadyInLibraryCount": {
|
||||||
|
"description": "Notification title when track is already in library, with count",
|
||||||
|
"placeholders": {
|
||||||
|
"completed": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifAlreadyInLibrary": "Already in Library",
|
||||||
|
"@notifAlreadyInLibrary": {
|
||||||
|
"description": "Notification title when track is already in library"
|
||||||
|
},
|
||||||
|
"notifDownloadCompleteCount": "Download Complete ({completed}/{total})",
|
||||||
|
"@notifDownloadCompleteCount": {
|
||||||
|
"description": "Notification title when download is complete, with count",
|
||||||
|
"placeholders": {
|
||||||
|
"completed": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifDownloadComplete": "Download Complete",
|
||||||
|
"@notifDownloadComplete": {
|
||||||
|
"description": "Notification title when a single download is complete"
|
||||||
|
},
|
||||||
|
"notifDownloadsFinished": "Downloads Finished ({completed} done, {failed} failed)",
|
||||||
|
"@notifDownloadsFinished": {
|
||||||
|
"description": "Notification title when queue finishes with some failures",
|
||||||
|
"placeholders": {
|
||||||
|
"completed": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"failed": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifAllDownloadsComplete": "All Downloads Complete",
|
||||||
|
"@notifAllDownloadsComplete": {
|
||||||
|
"description": "Notification title when all downloads finish successfully"
|
||||||
|
},
|
||||||
|
"notifTracksDownloadedSuccess": "{count} tracks downloaded successfully",
|
||||||
|
"@notifTracksDownloadedSuccess": {
|
||||||
|
"description": "Notification body for queue complete - how many tracks were downloaded",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifScanningLibrary": "Scanning local library",
|
||||||
|
"@notifScanningLibrary": {
|
||||||
|
"description": "Notification title while scanning local library"
|
||||||
|
},
|
||||||
|
"notifLibraryScanProgressWithTotal": "{scanned}/{total} files • {percentage}%",
|
||||||
|
"@notifLibraryScanProgressWithTotal": {
|
||||||
|
"description": "Notification body for library scan progress when total is known",
|
||||||
|
"placeholders": {
|
||||||
|
"scanned": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"percentage": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifLibraryScanProgressNoTotal": "{scanned} files scanned • {percentage}%",
|
||||||
|
"@notifLibraryScanProgressNoTotal": {
|
||||||
|
"description": "Notification body for library scan progress when total is unknown",
|
||||||
|
"placeholders": {
|
||||||
|
"scanned": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"percentage": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifLibraryScanComplete": "Library scan complete",
|
||||||
|
"@notifLibraryScanComplete": {
|
||||||
|
"description": "Notification title when library scan finishes"
|
||||||
|
},
|
||||||
|
"notifLibraryScanCompleteBody": "{count} tracks indexed",
|
||||||
|
"@notifLibraryScanCompleteBody": {
|
||||||
|
"description": "Notification body for library scan complete - number of indexed tracks",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifLibraryScanExcluded": "{count} excluded",
|
||||||
|
"@notifLibraryScanExcluded": {
|
||||||
|
"description": "Library scan complete suffix - excluded track count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifLibraryScanErrors": "{count} errors",
|
||||||
|
"@notifLibraryScanErrors": {
|
||||||
|
"description": "Library scan complete suffix - error count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifLibraryScanFailed": "Library scan failed",
|
||||||
|
"@notifLibraryScanFailed": {
|
||||||
|
"description": "Notification title when library scan fails"
|
||||||
|
},
|
||||||
|
"notifLibraryScanCancelled": "Library scan cancelled",
|
||||||
|
"@notifLibraryScanCancelled": {
|
||||||
|
"description": "Notification title when library scan is cancelled by the user"
|
||||||
|
},
|
||||||
|
"notifLibraryScanStopped": "Scan stopped before completion.",
|
||||||
|
"@notifLibraryScanStopped": {
|
||||||
|
"description": "Notification body when library scan is cancelled"
|
||||||
|
},
|
||||||
|
"notifDownloadingUpdate": "Downloading SpotiFLAC v{version}",
|
||||||
|
"@notifDownloadingUpdate": {
|
||||||
|
"description": "Notification title while downloading an app update",
|
||||||
|
"placeholders": {
|
||||||
|
"version": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifUpdateProgress": "{received} / {total} MB • {percentage}%",
|
||||||
|
"@notifUpdateProgress": {
|
||||||
|
"description": "Notification body showing update download progress",
|
||||||
|
"placeholders": {
|
||||||
|
"received": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"percentage": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifUpdateReady": "Update Ready",
|
||||||
|
"@notifUpdateReady": {
|
||||||
|
"description": "Notification title when app update download is complete"
|
||||||
|
},
|
||||||
|
"notifUpdateReadyBody": "SpotiFLAC v{version} downloaded. Tap to install.",
|
||||||
|
"@notifUpdateReadyBody": {
|
||||||
|
"description": "Notification body when app update is ready to install",
|
||||||
|
"placeholders": {
|
||||||
|
"version": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifUpdateFailed": "Update Failed",
|
||||||
|
"@notifUpdateFailed": {
|
||||||
|
"description": "Notification title when app update download fails"
|
||||||
|
},
|
||||||
|
"notifUpdateFailedBody": "Could not download update. Try again later.",
|
||||||
|
"@notifUpdateFailedBody": {
|
||||||
|
"description": "Notification body when app update download fails"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+401
-7
@@ -450,7 +450,7 @@
|
|||||||
"@aboutSpotiSaverDesc": {
|
"@aboutSpotiSaverDesc": {
|
||||||
"description": "Credit for SpotiSaver API"
|
"description": "Credit for SpotiSaver API"
|
||||||
},
|
},
|
||||||
"aboutAppDescription": "Descarga pistas de Spotify con calidad sin pérdida de Tidal y Qobuz.",
|
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -897,6 +897,18 @@
|
|||||||
"@errorNoTracksFound": {
|
"@errorNoTracksFound": {
|
||||||
"description": "Error - search returned no results"
|
"description": "Error - search returned no results"
|
||||||
},
|
},
|
||||||
|
"errorUrlNotRecognized": "Link not recognized",
|
||||||
|
"@errorUrlNotRecognized": {
|
||||||
|
"description": "Error title - URL not handled by any extension or service"
|
||||||
|
},
|
||||||
|
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
|
||||||
|
"@errorUrlNotRecognizedMessage": {
|
||||||
|
"description": "Error message - URL not recognized explanation"
|
||||||
|
},
|
||||||
|
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
|
||||||
|
"@errorUrlFetchFailed": {
|
||||||
|
"description": "Error message - generic URL fetch failure"
|
||||||
|
},
|
||||||
"errorMissingExtensionSource": "No se puede cargar {item}: falta una fuente de extensión",
|
"errorMissingExtensionSource": "No se puede cargar {item}: falta una fuente de extensión",
|
||||||
"@errorMissingExtensionSource": {
|
"@errorMissingExtensionSource": {
|
||||||
"description": "Error - extension source not available",
|
"description": "Error - extension source not available",
|
||||||
@@ -991,10 +1003,26 @@
|
|||||||
"@filenameFormat": {
|
"@filenameFormat": {
|
||||||
"description": "Setting title - filename pattern"
|
"description": "Setting title - filename pattern"
|
||||||
},
|
},
|
||||||
|
"filenameShowAdvancedTags": "Show advanced tags",
|
||||||
|
"@filenameShowAdvancedTags": {
|
||||||
|
"description": "Toggle label for showing advanced filename tags"
|
||||||
|
},
|
||||||
|
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
|
||||||
|
"@filenameShowAdvancedTagsDescription": {
|
||||||
|
"description": "Description for advanced filename tag toggle"
|
||||||
|
},
|
||||||
"folderOrganizationNone": "Ninguna organización",
|
"folderOrganizationNone": "Ninguna organización",
|
||||||
"@folderOrganizationNone": {
|
"@folderOrganizationNone": {
|
||||||
"description": "Folder option - flat structure"
|
"description": "Folder option - flat structure"
|
||||||
},
|
},
|
||||||
|
"folderOrganizationByPlaylist": "By Playlist",
|
||||||
|
"@folderOrganizationByPlaylist": {
|
||||||
|
"description": "Folder option - playlist folders"
|
||||||
|
},
|
||||||
|
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
|
||||||
|
"@folderOrganizationByPlaylistSubtitle": {
|
||||||
|
"description": "Subtitle for playlist folder option"
|
||||||
|
},
|
||||||
"folderOrganizationByArtist": "Por Artista",
|
"folderOrganizationByArtist": "Por Artista",
|
||||||
"@folderOrganizationByArtist": {
|
"@folderOrganizationByArtist": {
|
||||||
"description": "Folder option - artist folders"
|
"description": "Folder option - artist folders"
|
||||||
@@ -1745,10 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Preguntar antes de descargar",
|
"downloadAskBeforeDownload": "Preguntar antes de descargar",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -2198,6 +2222,15 @@
|
|||||||
"@libraryAboutDescription": {
|
"@libraryAboutDescription": {
|
||||||
"description": "Description of local library feature"
|
"description": "Description of local library feature"
|
||||||
},
|
},
|
||||||
|
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
|
||||||
|
"@libraryTracksUnit": {
|
||||||
|
"description": "Unit label for tracks count (without the number itself)",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"libraryLastScanned": "Last scanned: {time}",
|
"libraryLastScanned": "Last scanned: {time}",
|
||||||
"@libraryLastScanned": {
|
"@libraryLastScanned": {
|
||||||
"description": "Last scan time display",
|
"description": "Last scan time display",
|
||||||
@@ -2358,7 +2391,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"description": "Tutorial welcome tip 1"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip2": "Obtén audio en calidad FLAC de Tidal, Qobuz o Deezer",
|
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
||||||
"@tutorialWelcomeTip2": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2783,6 +2816,367 @@
|
|||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"description": "Snackbar when conversion fails"
|
||||||
},
|
},
|
||||||
|
"cueSplitTitle": "Split CUE Sheet",
|
||||||
|
"@cueSplitTitle": {
|
||||||
|
"description": "Title for CUE split bottom sheet"
|
||||||
|
},
|
||||||
|
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
|
||||||
|
"@cueSplitSubtitle": {
|
||||||
|
"description": "Subtitle for CUE split menu item"
|
||||||
|
},
|
||||||
|
"cueSplitAlbum": "Album: {album}",
|
||||||
|
"@cueSplitAlbum": {
|
||||||
|
"description": "Album name in CUE split sheet",
|
||||||
|
"placeholders": {
|
||||||
|
"album": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitArtist": "Artist: {artist}",
|
||||||
|
"@cueSplitArtist": {
|
||||||
|
"description": "Artist name in CUE split sheet",
|
||||||
|
"placeholders": {
|
||||||
|
"artist": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitTrackCount": "{count} tracks",
|
||||||
|
"@cueSplitTrackCount": {
|
||||||
|
"description": "Number of tracks in CUE sheet",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitConfirmTitle": "Split CUE Album",
|
||||||
|
"@cueSplitConfirmTitle": {
|
||||||
|
"description": "CUE split confirmation dialog title"
|
||||||
|
},
|
||||||
|
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
|
||||||
|
"@cueSplitConfirmMessage": {
|
||||||
|
"description": "CUE split confirmation dialog message",
|
||||||
|
"placeholders": {
|
||||||
|
"album": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
|
||||||
|
"@cueSplitSplitting": {
|
||||||
|
"description": "Snackbar while splitting CUE",
|
||||||
|
"placeholders": {
|
||||||
|
"current": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitSuccess": "Split into {count} tracks successfully",
|
||||||
|
"@cueSplitSuccess": {
|
||||||
|
"description": "Snackbar after successful CUE split",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitFailed": "CUE split failed",
|
||||||
|
"@cueSplitFailed": {
|
||||||
|
"description": "Snackbar when CUE split fails"
|
||||||
|
},
|
||||||
|
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
|
||||||
|
"@cueSplitNoAudioFile": {
|
||||||
|
"description": "Error when CUE audio file is missing"
|
||||||
|
},
|
||||||
|
"cueSplitButton": "Split into Tracks",
|
||||||
|
"@cueSplitButton": {
|
||||||
|
"description": "Button text to start CUE splitting"
|
||||||
|
},
|
||||||
|
"actionCreate": "Create",
|
||||||
|
"@actionCreate": {
|
||||||
|
"description": "Generic action button - create"
|
||||||
|
},
|
||||||
|
"collectionFoldersTitle": "My folders",
|
||||||
|
"@collectionFoldersTitle": {
|
||||||
|
"description": "Library section title for custom folders"
|
||||||
|
},
|
||||||
|
"collectionWishlist": "Wishlist",
|
||||||
|
"@collectionWishlist": {
|
||||||
|
"description": "Custom folder for saved tracks to download later"
|
||||||
|
},
|
||||||
|
"collectionLoved": "Loved",
|
||||||
|
"@collectionLoved": {
|
||||||
|
"description": "Custom folder for favorite tracks"
|
||||||
|
},
|
||||||
|
"collectionPlaylists": "Playlists",
|
||||||
|
"@collectionPlaylists": {
|
||||||
|
"description": "Custom user playlists folder"
|
||||||
|
},
|
||||||
|
"collectionPlaylist": "Playlist",
|
||||||
|
"@collectionPlaylist": {
|
||||||
|
"description": "Single playlist label"
|
||||||
|
},
|
||||||
|
"collectionAddToPlaylist": "Add to playlist",
|
||||||
|
"@collectionAddToPlaylist": {
|
||||||
|
"description": "Action to add a track to user playlist"
|
||||||
|
},
|
||||||
|
"collectionCreatePlaylist": "Create playlist",
|
||||||
|
"@collectionCreatePlaylist": {
|
||||||
|
"description": "Action to create a new playlist"
|
||||||
|
},
|
||||||
|
"collectionNoPlaylistsYet": "No playlists yet",
|
||||||
|
"@collectionNoPlaylistsYet": {
|
||||||
|
"description": "Empty state title when user has no playlists"
|
||||||
|
},
|
||||||
|
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
|
||||||
|
"@collectionNoPlaylistsSubtitle": {
|
||||||
|
"description": "Empty state subtitle when user has no playlists"
|
||||||
|
},
|
||||||
|
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
|
||||||
|
"@collectionPlaylistTracks": {
|
||||||
|
"description": "Track count label for custom playlists",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
|
||||||
|
"@collectionAddedToPlaylist": {
|
||||||
|
"description": "Snackbar after adding track to playlist",
|
||||||
|
"placeholders": {
|
||||||
|
"playlistName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
|
||||||
|
"@collectionAlreadyInPlaylist": {
|
||||||
|
"description": "Snackbar when track already exists in playlist",
|
||||||
|
"placeholders": {
|
||||||
|
"playlistName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionPlaylistCreated": "Playlist created",
|
||||||
|
"@collectionPlaylistCreated": {
|
||||||
|
"description": "Snackbar after creating playlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistNameHint": "Playlist name",
|
||||||
|
"@collectionPlaylistNameHint": {
|
||||||
|
"description": "Hint text for playlist name input"
|
||||||
|
},
|
||||||
|
"collectionPlaylistNameRequired": "Playlist name is required",
|
||||||
|
"@collectionPlaylistNameRequired": {
|
||||||
|
"description": "Validation error for empty playlist name"
|
||||||
|
},
|
||||||
|
"collectionRenamePlaylist": "Rename playlist",
|
||||||
|
"@collectionRenamePlaylist": {
|
||||||
|
"description": "Action to rename playlist"
|
||||||
|
},
|
||||||
|
"collectionDeletePlaylist": "Delete playlist",
|
||||||
|
"@collectionDeletePlaylist": {
|
||||||
|
"description": "Action to delete playlist"
|
||||||
|
},
|
||||||
|
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
|
||||||
|
"@collectionDeletePlaylistMessage": {
|
||||||
|
"description": "Confirmation message for deleting playlist",
|
||||||
|
"placeholders": {
|
||||||
|
"playlistName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionPlaylistDeleted": "Playlist deleted",
|
||||||
|
"@collectionPlaylistDeleted": {
|
||||||
|
"description": "Snackbar after deleting playlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistRenamed": "Playlist renamed",
|
||||||
|
"@collectionPlaylistRenamed": {
|
||||||
|
"description": "Snackbar after renaming playlist"
|
||||||
|
},
|
||||||
|
"collectionWishlistEmptyTitle": "Wishlist is empty",
|
||||||
|
"@collectionWishlistEmptyTitle": {
|
||||||
|
"description": "Wishlist empty state title"
|
||||||
|
},
|
||||||
|
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
|
||||||
|
"@collectionWishlistEmptySubtitle": {
|
||||||
|
"description": "Wishlist empty state subtitle"
|
||||||
|
},
|
||||||
|
"collectionLovedEmptyTitle": "Loved folder is empty",
|
||||||
|
"@collectionLovedEmptyTitle": {
|
||||||
|
"description": "Loved empty state title"
|
||||||
|
},
|
||||||
|
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
|
||||||
|
"@collectionLovedEmptySubtitle": {
|
||||||
|
"description": "Loved empty state subtitle"
|
||||||
|
},
|
||||||
|
"collectionPlaylistEmptyTitle": "Playlist is empty",
|
||||||
|
"@collectionPlaylistEmptyTitle": {
|
||||||
|
"description": "Playlist empty state title"
|
||||||
|
},
|
||||||
|
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
|
||||||
|
"@collectionPlaylistEmptySubtitle": {
|
||||||
|
"description": "Playlist empty state subtitle"
|
||||||
|
},
|
||||||
|
"collectionRemoveFromPlaylist": "Remove from playlist",
|
||||||
|
"@collectionRemoveFromPlaylist": {
|
||||||
|
"description": "Tooltip for removing track from playlist"
|
||||||
|
},
|
||||||
|
"collectionRemoveFromFolder": "Remove from folder",
|
||||||
|
"@collectionRemoveFromFolder": {
|
||||||
|
"description": "Tooltip for removing track from wishlist/loved folder"
|
||||||
|
},
|
||||||
|
"collectionRemoved": "\"{trackName}\" removed",
|
||||||
|
"@collectionRemoved": {
|
||||||
|
"description": "Snackbar after removing a track from a collection",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
|
||||||
|
"@collectionAddedToLoved": {
|
||||||
|
"description": "Snackbar after adding track to loved folder",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
|
||||||
|
"@collectionRemovedFromLoved": {
|
||||||
|
"description": "Snackbar after removing track from loved folder",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
|
||||||
|
"@collectionAddedToWishlist": {
|
||||||
|
"description": "Snackbar after adding track to wishlist",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
|
||||||
|
"@collectionRemovedFromWishlist": {
|
||||||
|
"description": "Snackbar after removing track from wishlist",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackOptionAddToLoved": "Add to Loved",
|
||||||
|
"@trackOptionAddToLoved": {
|
||||||
|
"description": "Bottom sheet action label - add track to loved folder"
|
||||||
|
},
|
||||||
|
"trackOptionRemoveFromLoved": "Remove from Loved",
|
||||||
|
"@trackOptionRemoveFromLoved": {
|
||||||
|
"description": "Bottom sheet action label - remove track from loved folder"
|
||||||
|
},
|
||||||
|
"trackOptionAddToWishlist": "Add to Wishlist",
|
||||||
|
"@trackOptionAddToWishlist": {
|
||||||
|
"description": "Bottom sheet action label - add track to wishlist"
|
||||||
|
},
|
||||||
|
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
|
||||||
|
"@trackOptionRemoveFromWishlist": {
|
||||||
|
"description": "Bottom sheet action label - remove track from wishlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistChangeCover": "Change cover image",
|
||||||
|
"@collectionPlaylistChangeCover": {
|
||||||
|
"description": "Bottom sheet action to pick a custom cover image for a playlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistRemoveCover": "Remove cover image",
|
||||||
|
"@collectionPlaylistRemoveCover": {
|
||||||
|
"description": "Bottom sheet action to remove custom cover image from a playlist"
|
||||||
|
},
|
||||||
|
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
|
||||||
|
"@selectionShareCount": {
|
||||||
|
"description": "Share button text with count in selection mode",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionShareNoFiles": "No shareable files found",
|
||||||
|
"@selectionShareNoFiles": {
|
||||||
|
"description": "Snackbar when no selected files exist on disk"
|
||||||
|
},
|
||||||
|
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
|
||||||
|
"@selectionConvertCount": {
|
||||||
|
"description": "Convert button text with count in selection mode",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionConvertNoConvertible": "No convertible tracks selected",
|
||||||
|
"@selectionConvertNoConvertible": {
|
||||||
|
"description": "Snackbar when no selected tracks support conversion"
|
||||||
|
},
|
||||||
|
"selectionBatchConvertConfirmTitle": "Batch Convert",
|
||||||
|
"@selectionBatchConvertConfirmTitle": {
|
||||||
|
"description": "Confirmation dialog title for batch conversion"
|
||||||
|
},
|
||||||
|
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
|
||||||
|
"@selectionBatchConvertConfirmMessage": {
|
||||||
|
"description": "Confirmation dialog message for batch conversion",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"bitrate": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionBatchConvertProgress": "Converting {current} of {total}...",
|
||||||
|
"@selectionBatchConvertProgress": {
|
||||||
|
"description": "Snackbar during batch conversion progress",
|
||||||
|
"placeholders": {
|
||||||
|
"current": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
|
||||||
|
"@selectionBatchConvertSuccess": {
|
||||||
|
"description": "Snackbar after batch conversion completes",
|
||||||
|
"placeholders": {
|
||||||
|
"success": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"downloadedAlbumDownloadedCount": "{count} descargado",
|
"downloadedAlbumDownloadedCount": "{count} descargado",
|
||||||
"@downloadedAlbumDownloadedCount": {
|
"@downloadedAlbumDownloadedCount": {
|
||||||
"description": "Downloaded tracks count badge",
|
"description": "Downloaded tracks count badge",
|
||||||
@@ -2800,4 +3194,4 @@
|
|||||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||||
"description": "Subtitle when Track Artist is used for folder naming"
|
"description": "Subtitle when Track Artist is used for folder naming"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+107
-15
@@ -450,7 +450,7 @@
|
|||||||
"@aboutSpotiSaverDesc": {
|
"@aboutSpotiSaverDesc": {
|
||||||
"description": "Credit for SpotiSaver API"
|
"description": "Credit for SpotiSaver API"
|
||||||
},
|
},
|
||||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
|
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -897,6 +897,18 @@
|
|||||||
"@errorNoTracksFound": {
|
"@errorNoTracksFound": {
|
||||||
"description": "Error - search returned no results"
|
"description": "Error - search returned no results"
|
||||||
},
|
},
|
||||||
|
"errorUrlNotRecognized": "Link not recognized",
|
||||||
|
"@errorUrlNotRecognized": {
|
||||||
|
"description": "Error title - URL not handled by any extension or service"
|
||||||
|
},
|
||||||
|
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
|
||||||
|
"@errorUrlNotRecognizedMessage": {
|
||||||
|
"description": "Error message - URL not recognized explanation"
|
||||||
|
},
|
||||||
|
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
|
||||||
|
"@errorUrlFetchFailed": {
|
||||||
|
"description": "Error message - generic URL fetch failure"
|
||||||
|
},
|
||||||
"errorMissingExtensionSource": "Cannot load {item}: missing extension source",
|
"errorMissingExtensionSource": "Cannot load {item}: missing extension source",
|
||||||
"@errorMissingExtensionSource": {
|
"@errorMissingExtensionSource": {
|
||||||
"description": "Error - extension source not available",
|
"description": "Error - extension source not available",
|
||||||
@@ -1003,6 +1015,14 @@
|
|||||||
"@folderOrganizationNone": {
|
"@folderOrganizationNone": {
|
||||||
"description": "Folder option - flat structure"
|
"description": "Folder option - flat structure"
|
||||||
},
|
},
|
||||||
|
"folderOrganizationByPlaylist": "By Playlist",
|
||||||
|
"@folderOrganizationByPlaylist": {
|
||||||
|
"description": "Folder option - playlist folders"
|
||||||
|
},
|
||||||
|
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
|
||||||
|
"@folderOrganizationByPlaylistSubtitle": {
|
||||||
|
"description": "Subtitle for playlist folder option"
|
||||||
|
},
|
||||||
"folderOrganizationByArtist": "By Artist",
|
"folderOrganizationByArtist": "By Artist",
|
||||||
"@folderOrganizationByArtist": {
|
"@folderOrganizationByArtist": {
|
||||||
"description": "Folder option - artist folders"
|
"description": "Folder option - artist folders"
|
||||||
@@ -1097,7 +1117,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "Built-in",
|
"providerBuiltIn": "Built-in",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Extension",
|
"providerExtension": "Extension",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1753,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Ask Before Download",
|
"downloadAskBeforeDownload": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -2383,7 +2391,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"description": "Tutorial welcome tip 1"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
|
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
||||||
"@tutorialWelcomeTip2": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2808,6 +2816,90 @@
|
|||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"description": "Snackbar when conversion fails"
|
||||||
},
|
},
|
||||||
|
"cueSplitTitle": "Split CUE Sheet",
|
||||||
|
"@cueSplitTitle": {
|
||||||
|
"description": "Title for CUE split bottom sheet"
|
||||||
|
},
|
||||||
|
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
|
||||||
|
"@cueSplitSubtitle": {
|
||||||
|
"description": "Subtitle for CUE split menu item"
|
||||||
|
},
|
||||||
|
"cueSplitAlbum": "Album: {album}",
|
||||||
|
"@cueSplitAlbum": {
|
||||||
|
"description": "Album name in CUE split sheet",
|
||||||
|
"placeholders": {
|
||||||
|
"album": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitArtist": "Artist: {artist}",
|
||||||
|
"@cueSplitArtist": {
|
||||||
|
"description": "Artist name in CUE split sheet",
|
||||||
|
"placeholders": {
|
||||||
|
"artist": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitTrackCount": "{count} tracks",
|
||||||
|
"@cueSplitTrackCount": {
|
||||||
|
"description": "Number of tracks in CUE sheet",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitConfirmTitle": "Split CUE Album",
|
||||||
|
"@cueSplitConfirmTitle": {
|
||||||
|
"description": "CUE split confirmation dialog title"
|
||||||
|
},
|
||||||
|
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
|
||||||
|
"@cueSplitConfirmMessage": {
|
||||||
|
"description": "CUE split confirmation dialog message",
|
||||||
|
"placeholders": {
|
||||||
|
"album": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
|
||||||
|
"@cueSplitSplitting": {
|
||||||
|
"description": "Snackbar while splitting CUE",
|
||||||
|
"placeholders": {
|
||||||
|
"current": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitSuccess": "Split into {count} tracks successfully",
|
||||||
|
"@cueSplitSuccess": {
|
||||||
|
"description": "Snackbar after successful CUE split",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitFailed": "CUE split failed",
|
||||||
|
"@cueSplitFailed": {
|
||||||
|
"description": "Snackbar when CUE split fails"
|
||||||
|
},
|
||||||
|
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
|
||||||
|
"@cueSplitNoAudioFile": {
|
||||||
|
"description": "Error when CUE audio file is missing"
|
||||||
|
},
|
||||||
|
"cueSplitButton": "Split into Tracks",
|
||||||
|
"@cueSplitButton": {
|
||||||
|
"description": "Button text to start CUE splitting"
|
||||||
|
},
|
||||||
"actionCreate": "Create",
|
"actionCreate": "Create",
|
||||||
"@actionCreate": {
|
"@actionCreate": {
|
||||||
"description": "Generic action button - create"
|
"description": "Generic action button - create"
|
||||||
|
|||||||
+107
-15
@@ -450,7 +450,7 @@
|
|||||||
"@aboutSpotiSaverDesc": {
|
"@aboutSpotiSaverDesc": {
|
||||||
"description": "Credit for SpotiSaver API"
|
"description": "Credit for SpotiSaver API"
|
||||||
},
|
},
|
||||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
|
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -897,6 +897,18 @@
|
|||||||
"@errorNoTracksFound": {
|
"@errorNoTracksFound": {
|
||||||
"description": "Error - search returned no results"
|
"description": "Error - search returned no results"
|
||||||
},
|
},
|
||||||
|
"errorUrlNotRecognized": "Link not recognized",
|
||||||
|
"@errorUrlNotRecognized": {
|
||||||
|
"description": "Error title - URL not handled by any extension or service"
|
||||||
|
},
|
||||||
|
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
|
||||||
|
"@errorUrlNotRecognizedMessage": {
|
||||||
|
"description": "Error message - URL not recognized explanation"
|
||||||
|
},
|
||||||
|
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
|
||||||
|
"@errorUrlFetchFailed": {
|
||||||
|
"description": "Error message - generic URL fetch failure"
|
||||||
|
},
|
||||||
"errorMissingExtensionSource": "Cannot load {item}: missing extension source",
|
"errorMissingExtensionSource": "Cannot load {item}: missing extension source",
|
||||||
"@errorMissingExtensionSource": {
|
"@errorMissingExtensionSource": {
|
||||||
"description": "Error - extension source not available",
|
"description": "Error - extension source not available",
|
||||||
@@ -1003,6 +1015,14 @@
|
|||||||
"@folderOrganizationNone": {
|
"@folderOrganizationNone": {
|
||||||
"description": "Folder option - flat structure"
|
"description": "Folder option - flat structure"
|
||||||
},
|
},
|
||||||
|
"folderOrganizationByPlaylist": "By Playlist",
|
||||||
|
"@folderOrganizationByPlaylist": {
|
||||||
|
"description": "Folder option - playlist folders"
|
||||||
|
},
|
||||||
|
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
|
||||||
|
"@folderOrganizationByPlaylistSubtitle": {
|
||||||
|
"description": "Subtitle for playlist folder option"
|
||||||
|
},
|
||||||
"folderOrganizationByArtist": "By Artist",
|
"folderOrganizationByArtist": "By Artist",
|
||||||
"@folderOrganizationByArtist": {
|
"@folderOrganizationByArtist": {
|
||||||
"description": "Folder option - artist folders"
|
"description": "Folder option - artist folders"
|
||||||
@@ -1097,7 +1117,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "Built-in",
|
"providerBuiltIn": "Built-in",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Extension",
|
"providerExtension": "Extension",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1753,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Ask Before Download",
|
"downloadAskBeforeDownload": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -2383,7 +2391,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"description": "Tutorial welcome tip 1"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
|
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
||||||
"@tutorialWelcomeTip2": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2808,6 +2816,90 @@
|
|||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"description": "Snackbar when conversion fails"
|
||||||
},
|
},
|
||||||
|
"cueSplitTitle": "Split CUE Sheet",
|
||||||
|
"@cueSplitTitle": {
|
||||||
|
"description": "Title for CUE split bottom sheet"
|
||||||
|
},
|
||||||
|
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
|
||||||
|
"@cueSplitSubtitle": {
|
||||||
|
"description": "Subtitle for CUE split menu item"
|
||||||
|
},
|
||||||
|
"cueSplitAlbum": "Album: {album}",
|
||||||
|
"@cueSplitAlbum": {
|
||||||
|
"description": "Album name in CUE split sheet",
|
||||||
|
"placeholders": {
|
||||||
|
"album": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitArtist": "Artist: {artist}",
|
||||||
|
"@cueSplitArtist": {
|
||||||
|
"description": "Artist name in CUE split sheet",
|
||||||
|
"placeholders": {
|
||||||
|
"artist": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitTrackCount": "{count} tracks",
|
||||||
|
"@cueSplitTrackCount": {
|
||||||
|
"description": "Number of tracks in CUE sheet",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitConfirmTitle": "Split CUE Album",
|
||||||
|
"@cueSplitConfirmTitle": {
|
||||||
|
"description": "CUE split confirmation dialog title"
|
||||||
|
},
|
||||||
|
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
|
||||||
|
"@cueSplitConfirmMessage": {
|
||||||
|
"description": "CUE split confirmation dialog message",
|
||||||
|
"placeholders": {
|
||||||
|
"album": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
|
||||||
|
"@cueSplitSplitting": {
|
||||||
|
"description": "Snackbar while splitting CUE",
|
||||||
|
"placeholders": {
|
||||||
|
"current": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitSuccess": "Split into {count} tracks successfully",
|
||||||
|
"@cueSplitSuccess": {
|
||||||
|
"description": "Snackbar after successful CUE split",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitFailed": "CUE split failed",
|
||||||
|
"@cueSplitFailed": {
|
||||||
|
"description": "Snackbar when CUE split fails"
|
||||||
|
},
|
||||||
|
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
|
||||||
|
"@cueSplitNoAudioFile": {
|
||||||
|
"description": "Error when CUE audio file is missing"
|
||||||
|
},
|
||||||
|
"cueSplitButton": "Split into Tracks",
|
||||||
|
"@cueSplitButton": {
|
||||||
|
"description": "Button text to start CUE splitting"
|
||||||
|
},
|
||||||
"actionCreate": "Create",
|
"actionCreate": "Create",
|
||||||
"@actionCreate": {
|
"@actionCreate": {
|
||||||
"description": "Generic action button - create"
|
"description": "Generic action button - create"
|
||||||
|
|||||||
+189
-69
@@ -17,7 +17,7 @@
|
|||||||
"@navSettings": {
|
"@navSettings": {
|
||||||
"description": "Bottom navigation - Settings tab"
|
"description": "Bottom navigation - Settings tab"
|
||||||
},
|
},
|
||||||
"navStore": "Toko",
|
"navStore": "Repo",
|
||||||
"@navStore": {
|
"@navStore": {
|
||||||
"description": "Bottom navigation - Extension store tab"
|
"description": "Bottom navigation - Extension store tab"
|
||||||
},
|
},
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"@homeTitle": {
|
"@homeTitle": {
|
||||||
"description": "Home screen title"
|
"description": "Home screen title"
|
||||||
},
|
},
|
||||||
"homeSubtitle": "Tempel link Spotify atau cari berdasarkan nama",
|
"homeSubtitle": "Tempel URL yang didukung atau cari berdasarkan nama",
|
||||||
"@homeSubtitle": {
|
"@homeSubtitle": {
|
||||||
"description": "Subtitle shown below search box"
|
"description": "Subtitle shown below search box"
|
||||||
},
|
},
|
||||||
@@ -211,11 +211,11 @@
|
|||||||
"@optionsConcurrentWarning": {
|
"@optionsConcurrentWarning": {
|
||||||
"description": "Warning about rate limits"
|
"description": "Warning about rate limits"
|
||||||
},
|
},
|
||||||
"optionsExtensionStore": "Toko Ekstensi",
|
"optionsExtensionStore": "Repo Ekstensi",
|
||||||
"@optionsExtensionStore": {
|
"@optionsExtensionStore": {
|
||||||
"description": "Show/hide store tab"
|
"description": "Show/hide store tab"
|
||||||
},
|
},
|
||||||
"optionsExtensionStoreSubtitle": "Tampilkan tab Toko di navigasi",
|
"optionsExtensionStoreSubtitle": "Tampilkan tab Repo di navigasi",
|
||||||
"@optionsExtensionStoreSubtitle": {
|
"@optionsExtensionStoreSubtitle": {
|
||||||
"description": "Subtitle for extension store toggle"
|
"description": "Subtitle for extension store toggle"
|
||||||
},
|
},
|
||||||
@@ -318,10 +318,14 @@
|
|||||||
"@extensionsUninstall": {
|
"@extensionsUninstall": {
|
||||||
"description": "Uninstall extension button"
|
"description": "Uninstall extension button"
|
||||||
},
|
},
|
||||||
"storeTitle": "Toko Ekstensi",
|
"storeTitle": "Repo Ekstensi",
|
||||||
"@storeTitle": {
|
"@storeTitle": {
|
||||||
"description": "Store screen title"
|
"description": "Store screen title"
|
||||||
},
|
},
|
||||||
|
"storeLoadError": "Gagal memuat repo",
|
||||||
|
"@storeLoadError": {
|
||||||
|
"description": "Error heading when the store cannot be loaded"
|
||||||
|
},
|
||||||
"storeSearch": "Cari ekstensi...",
|
"storeSearch": "Cari ekstensi...",
|
||||||
"@storeSearch": {
|
"@storeSearch": {
|
||||||
"description": "Store search placeholder"
|
"description": "Store search placeholder"
|
||||||
@@ -450,7 +454,7 @@
|
|||||||
"@aboutSpotiSaverDesc": {
|
"@aboutSpotiSaverDesc": {
|
||||||
"description": "Credit for SpotiSaver API"
|
"description": "Credit for SpotiSaver API"
|
||||||
},
|
},
|
||||||
"aboutAppDescription": "Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.",
|
"aboutAppDescription": "Unduh lagu-lagu Spotify dalam kualitas lossless dari Tidal dan Qobuz.",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -1003,11 +1007,11 @@
|
|||||||
"@filenameFormat": {
|
"@filenameFormat": {
|
||||||
"description": "Setting title - filename pattern"
|
"description": "Setting title - filename pattern"
|
||||||
},
|
},
|
||||||
"filenameShowAdvancedTags": "Show advanced tags",
|
"filenameShowAdvancedTags": "Tampilkan tag lanjutan",
|
||||||
"@filenameShowAdvancedTags": {
|
"@filenameShowAdvancedTags": {
|
||||||
"description": "Toggle label for showing advanced filename tags"
|
"description": "Toggle label for showing advanced filename tags"
|
||||||
},
|
},
|
||||||
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
|
"filenameShowAdvancedTagsDescription": "Aktifkan tag yang diformat untuk padding trek dan pola tanggal",
|
||||||
"@filenameShowAdvancedTagsDescription": {
|
"@filenameShowAdvancedTagsDescription": {
|
||||||
"description": "Description for advanced filename tag toggle"
|
"description": "Description for advanced filename tag toggle"
|
||||||
},
|
},
|
||||||
@@ -1015,6 +1019,14 @@
|
|||||||
"@folderOrganizationNone": {
|
"@folderOrganizationNone": {
|
||||||
"description": "Folder option - flat structure"
|
"description": "Folder option - flat structure"
|
||||||
},
|
},
|
||||||
|
"folderOrganizationByPlaylist": "Berdasarkan Daftar Putar",
|
||||||
|
"@folderOrganizationByPlaylist": {
|
||||||
|
"description": "Folder option - playlist folders"
|
||||||
|
},
|
||||||
|
"folderOrganizationByPlaylistSubtitle": "Setiap daftar putar memerlukan folder terpisah",
|
||||||
|
"@folderOrganizationByPlaylistSubtitle": {
|
||||||
|
"description": "Subtitle for playlist folder option"
|
||||||
|
},
|
||||||
"folderOrganizationByArtist": "Berdasarkan Artis",
|
"folderOrganizationByArtist": "Berdasarkan Artis",
|
||||||
"@folderOrganizationByArtist": {
|
"@folderOrganizationByArtist": {
|
||||||
"description": "Folder option - artist folders"
|
"description": "Folder option - artist folders"
|
||||||
@@ -1107,9 +1119,21 @@
|
|||||||
"@providerPriorityInfo": {
|
"@providerPriorityInfo": {
|
||||||
"description": "Info tip about fallback behavior"
|
"description": "Info tip about fallback behavior"
|
||||||
},
|
},
|
||||||
|
"providerPriorityFallbackExtensionsTitle": "Fallback Ekstensi",
|
||||||
|
"@providerPriorityFallbackExtensionsTitle": {
|
||||||
|
"description": "Section title for choosing which download extensions can be used as fallback providers"
|
||||||
|
},
|
||||||
|
"providerPriorityFallbackExtensionsDescription": "Pilih ekstensi unduhan terpasang mana yang boleh dipakai saat fallback otomatis. Provider bawaan tetap mengikuti urutan prioritas di atas.",
|
||||||
|
"@providerPriorityFallbackExtensionsDescription": {
|
||||||
|
"description": "Section description for extension fallback selection"
|
||||||
|
},
|
||||||
|
"providerPriorityFallbackExtensionsHint": "Hanya ekstensi aktif dengan kemampuan download provider yang ditampilkan di sini.",
|
||||||
|
"@providerPriorityFallbackExtensionsHint": {
|
||||||
|
"description": "Hint below the extension fallback selection list"
|
||||||
|
},
|
||||||
"providerBuiltIn": "Bawaan",
|
"providerBuiltIn": "Bawaan",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Ekstensi",
|
"providerExtension": "Ekstensi",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1209,7 +1233,7 @@
|
|||||||
"@credentialsDescription": {
|
"@credentialsDescription": {
|
||||||
"description": "Credentials dialog explanation"
|
"description": "Credentials dialog explanation"
|
||||||
},
|
},
|
||||||
"credentialsClientId": "Client ID",
|
"credentialsClientId": "ID Klien",
|
||||||
"@credentialsClientId": {
|
"@credentialsClientId": {
|
||||||
"description": "Client ID field label - DO NOT TRANSLATE"
|
"description": "Client ID field label - DO NOT TRANSLATE"
|
||||||
},
|
},
|
||||||
@@ -1217,7 +1241,7 @@
|
|||||||
"@credentialsClientIdHint": {
|
"@credentialsClientIdHint": {
|
||||||
"description": "Client ID placeholder"
|
"description": "Client ID placeholder"
|
||||||
},
|
},
|
||||||
"credentialsClientSecret": "Client Secret",
|
"credentialsClientSecret": "Rahasia Klien",
|
||||||
"@credentialsClientSecret": {
|
"@credentialsClientSecret": {
|
||||||
"description": "Client Secret field label - DO NOT TRANSLATE"
|
"description": "Client Secret field label - DO NOT TRANSLATE"
|
||||||
},
|
},
|
||||||
@@ -1229,7 +1253,7 @@
|
|||||||
"@channelStable": {
|
"@channelStable": {
|
||||||
"description": "Update channel - stable releases"
|
"description": "Update channel - stable releases"
|
||||||
},
|
},
|
||||||
"channelPreview": "Preview",
|
"channelPreview": "Pratinjau",
|
||||||
"@channelPreview": {
|
"@channelPreview": {
|
||||||
"description": "Update channel - beta/preview releases"
|
"description": "Update channel - beta/preview releases"
|
||||||
},
|
},
|
||||||
@@ -1269,39 +1293,39 @@
|
|||||||
"@sectionFileSettings": {
|
"@sectionFileSettings": {
|
||||||
"description": "Settings section header"
|
"description": "Settings section header"
|
||||||
},
|
},
|
||||||
"sectionLyrics": "Lyrics",
|
"sectionLyrics": "Lirik",
|
||||||
"@sectionLyrics": {
|
"@sectionLyrics": {
|
||||||
"description": "Settings section header"
|
"description": "Settings section header"
|
||||||
},
|
},
|
||||||
"lyricsMode": "Lyrics Mode",
|
"lyricsMode": "Mode Lirik",
|
||||||
"@lyricsMode": {
|
"@lyricsMode": {
|
||||||
"description": "Setting - how to save lyrics"
|
"description": "Setting - how to save lyrics"
|
||||||
},
|
},
|
||||||
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
|
"lyricsModeDescription": "Pilih cara lirik disimpan bersama unduhan Anda",
|
||||||
"@lyricsModeDescription": {
|
"@lyricsModeDescription": {
|
||||||
"description": "Lyrics mode picker description"
|
"description": "Lyrics mode picker description"
|
||||||
},
|
},
|
||||||
"lyricsModeEmbed": "Embed in file",
|
"lyricsModeEmbed": "Sematkan dalam file",
|
||||||
"@lyricsModeEmbed": {
|
"@lyricsModeEmbed": {
|
||||||
"description": "Lyrics mode option - embed in audio file"
|
"description": "Lyrics mode option - embed in audio file"
|
||||||
},
|
},
|
||||||
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
|
"lyricsModeEmbedSubtitle": "Lirik tersimpan di dalam metadata FLAC",
|
||||||
"@lyricsModeEmbedSubtitle": {
|
"@lyricsModeEmbedSubtitle": {
|
||||||
"description": "Subtitle for embed option"
|
"description": "Subtitle for embed option"
|
||||||
},
|
},
|
||||||
"lyricsModeExternal": "External .lrc file",
|
"lyricsModeExternal": "File .lrc eksternal",
|
||||||
"@lyricsModeExternal": {
|
"@lyricsModeExternal": {
|
||||||
"description": "Lyrics mode option - separate LRC file"
|
"description": "Lyrics mode option - separate LRC file"
|
||||||
},
|
},
|
||||||
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
|
"lyricsModeExternalSubtitle": "File .lrc terpisah untuk pemutar musik seperti Samsung Music",
|
||||||
"@lyricsModeExternalSubtitle": {
|
"@lyricsModeExternalSubtitle": {
|
||||||
"description": "Subtitle for external option"
|
"description": "Subtitle for external option"
|
||||||
},
|
},
|
||||||
"lyricsModeBoth": "Both",
|
"lyricsModeBoth": "Keduanya",
|
||||||
"@lyricsModeBoth": {
|
"@lyricsModeBoth": {
|
||||||
"description": "Lyrics mode option - embed and external"
|
"description": "Lyrics mode option - embed and external"
|
||||||
},
|
},
|
||||||
"lyricsModeBothSubtitle": "Embed and save .lrc file",
|
"lyricsModeBothSubtitle": "Sematkan dan simpan file .lrc",
|
||||||
"@lyricsModeBothSubtitle": {
|
"@lyricsModeBothSubtitle": {
|
||||||
"description": "Subtitle for both option"
|
"description": "Subtitle for both option"
|
||||||
},
|
},
|
||||||
@@ -1447,11 +1471,11 @@
|
|||||||
"@trackGenre": {
|
"@trackGenre": {
|
||||||
"description": "Metadata label - music genre"
|
"description": "Metadata label - music genre"
|
||||||
},
|
},
|
||||||
"trackLabel": "Label",
|
"trackLabel": "Lebel",
|
||||||
"@trackLabel": {
|
"@trackLabel": {
|
||||||
"description": "Metadata label - record label"
|
"description": "Metadata label - record label"
|
||||||
},
|
},
|
||||||
"trackCopyright": "Copyright",
|
"trackCopyright": "Hak cipta",
|
||||||
"@trackCopyright": {
|
"@trackCopyright": {
|
||||||
"description": "Metadata label - copyright information"
|
"description": "Metadata label - copyright information"
|
||||||
},
|
},
|
||||||
@@ -1475,15 +1499,15 @@
|
|||||||
"@trackLyricsLoadFailed": {
|
"@trackLyricsLoadFailed": {
|
||||||
"description": "Message when lyrics loading fails"
|
"description": "Message when lyrics loading fails"
|
||||||
},
|
},
|
||||||
"trackEmbedLyrics": "Embed Lyrics",
|
"trackEmbedLyrics": "Sematkan Lirik",
|
||||||
"@trackEmbedLyrics": {
|
"@trackEmbedLyrics": {
|
||||||
"description": "Action - embed lyrics into audio file"
|
"description": "Action - embed lyrics into audio file"
|
||||||
},
|
},
|
||||||
"trackLyricsEmbedded": "Lyrics embedded successfully",
|
"trackLyricsEmbedded": "Lirik berhasil disematkan",
|
||||||
"@trackLyricsEmbedded": {
|
"@trackLyricsEmbedded": {
|
||||||
"description": "Snackbar - lyrics saved to file"
|
"description": "Snackbar - lyrics saved to file"
|
||||||
},
|
},
|
||||||
"trackInstrumental": "Instrumental track",
|
"trackInstrumental": "Lagu instrumental",
|
||||||
"@trackInstrumental": {
|
"@trackInstrumental": {
|
||||||
"description": "Message when track is instrumental (no lyrics)"
|
"description": "Message when track is instrumental (no lyrics)"
|
||||||
},
|
},
|
||||||
@@ -1562,7 +1586,7 @@
|
|||||||
"@storeClearFilters": {
|
"@storeClearFilters": {
|
||||||
"description": "Button to clear all filters"
|
"description": "Button to clear all filters"
|
||||||
},
|
},
|
||||||
"extensionDefaultProvider": "Default (Deezer/Spotify)",
|
"extensionDefaultProvider": "Bawaan (Deezer/Spotify)",
|
||||||
"@extensionDefaultProvider": {
|
"@extensionDefaultProvider": {
|
||||||
"description": "Default search provider option"
|
"description": "Default search provider option"
|
||||||
},
|
},
|
||||||
@@ -1578,7 +1602,7 @@
|
|||||||
"@extensionId": {
|
"@extensionId": {
|
||||||
"description": "Extension detail - unique ID"
|
"description": "Extension detail - unique ID"
|
||||||
},
|
},
|
||||||
"extensionError": "Error",
|
"extensionError": "Terjadi kesalahan",
|
||||||
"@extensionError": {
|
"@extensionError": {
|
||||||
"description": "Extension detail - error message"
|
"description": "Extension detail - error message"
|
||||||
},
|
},
|
||||||
@@ -1701,6 +1725,14 @@
|
|||||||
"@extensionsDownloadPrioritySubtitle": {
|
"@extensionsDownloadPrioritySubtitle": {
|
||||||
"description": "Subtitle for download priority"
|
"description": "Subtitle for download priority"
|
||||||
},
|
},
|
||||||
|
"extensionsFallbackTitle": "Fallback Extensions",
|
||||||
|
"@extensionsFallbackTitle": {
|
||||||
|
"description": "Setting and page title for choosing which download extensions can be used during fallback"
|
||||||
|
},
|
||||||
|
"extensionsFallbackSubtitle": "Pilih ekstensi unduhan terpasang yang boleh dipakai saat fallback",
|
||||||
|
"@extensionsFallbackSubtitle": {
|
||||||
|
"description": "Subtitle for download fallback extensions menu"
|
||||||
|
},
|
||||||
"extensionsNoDownloadProvider": "Tidak ada ekstensi dengan provider unduhan",
|
"extensionsNoDownloadProvider": "Tidak ada ekstensi dengan provider unduhan",
|
||||||
"@extensionsNoDownloadProvider": {
|
"@extensionsNoDownloadProvider": {
|
||||||
"description": "Empty state - no download providers"
|
"description": "Empty state - no download providers"
|
||||||
@@ -1765,18 +1797,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Tanya Sebelum Unduh",
|
"downloadAskBeforeDownload": "Tanya Sebelum Unduh",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -1793,19 +1813,35 @@
|
|||||||
"@downloadAlbumFolderStructure": {
|
"@downloadAlbumFolderStructure": {
|
||||||
"description": "Setting - album folder organization"
|
"description": "Setting - album folder organization"
|
||||||
},
|
},
|
||||||
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
"downloadUseAlbumArtistForFolders": "Gunakan Artis Album untuk folder",
|
||||||
"@downloadUseAlbumArtistForFolders": {
|
"@downloadUseAlbumArtistForFolders": {
|
||||||
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
||||||
},
|
},
|
||||||
"downloadUsePrimaryArtistOnly": "Primary artist only for folders",
|
"downloadCreatePlaylistSourceFolder": "Buat folder sumber playlist",
|
||||||
|
"@downloadCreatePlaylistSourceFolder": {
|
||||||
|
"description": "Setting title for adding a playlist folder prefix before the normal organization structure"
|
||||||
|
},
|
||||||
|
"downloadCreatePlaylistSourceFolderEnabled": "Unduhan dari playlist memakai Playlist/ lalu struktur folder normal Anda.",
|
||||||
|
"@downloadCreatePlaylistSourceFolderEnabled": {
|
||||||
|
"description": "Subtitle when playlist source folder prefix is enabled"
|
||||||
|
},
|
||||||
|
"downloadCreatePlaylistSourceFolderDisabled": "Unduhan dari playlist hanya memakai struktur folder normal.",
|
||||||
|
"@downloadCreatePlaylistSourceFolderDisabled": {
|
||||||
|
"description": "Subtitle when playlist source folder prefix is disabled"
|
||||||
|
},
|
||||||
|
"downloadCreatePlaylistSourceFolderRedundant": "Mode Berdasarkan Playlist sudah menaruh unduhan ke dalam folder playlist.",
|
||||||
|
"@downloadCreatePlaylistSourceFolderRedundant": {
|
||||||
|
"description": "Subtitle when playlist folder prefix setting is redundant because folder organization is already by playlist"
|
||||||
|
},
|
||||||
|
"downloadUsePrimaryArtistOnly": "Hanya artis utama untuk folder",
|
||||||
"@downloadUsePrimaryArtistOnly": {
|
"@downloadUsePrimaryArtistOnly": {
|
||||||
"description": "Setting - strip featured artists from folder name"
|
"description": "Setting - strip featured artists from folder name"
|
||||||
},
|
},
|
||||||
"downloadUsePrimaryArtistOnlyEnabled": "Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)",
|
"downloadUsePrimaryArtistOnlyEnabled": "Artis unggulan dihapus dari nama folder (misalnya Justin Bieber, Quavo → Justin Bieber)",
|
||||||
"@downloadUsePrimaryArtistOnlyEnabled": {
|
"@downloadUsePrimaryArtistOnlyEnabled": {
|
||||||
"description": "Subtitle when primary artist only is enabled"
|
"description": "Subtitle when primary artist only is enabled"
|
||||||
},
|
},
|
||||||
"downloadUsePrimaryArtistOnlyDisabled": "Full artist string used for folder name",
|
"downloadUsePrimaryArtistOnlyDisabled": "Nama lengkap artis digunakan untuk nama folder",
|
||||||
"@downloadUsePrimaryArtistOnlyDisabled": {
|
"@downloadUsePrimaryArtistOnlyDisabled": {
|
||||||
"description": "Subtitle when primary artist only is disabled"
|
"description": "Subtitle when primary artist only is disabled"
|
||||||
},
|
},
|
||||||
@@ -1833,27 +1869,27 @@
|
|||||||
"@queueClearAllMessage": {
|
"@queueClearAllMessage": {
|
||||||
"description": "Clear queue confirmation"
|
"description": "Clear queue confirmation"
|
||||||
},
|
},
|
||||||
"settingsAutoExportFailed": "Auto-export failed downloads",
|
"settingsAutoExportFailed": "Unduhan yang gagal diekspor otomatis",
|
||||||
"@settingsAutoExportFailed": {
|
"@settingsAutoExportFailed": {
|
||||||
"description": "Setting toggle for auto-export"
|
"description": "Setting toggle for auto-export"
|
||||||
},
|
},
|
||||||
"settingsAutoExportFailedSubtitle": "Save failed downloads to TXT file automatically",
|
"settingsAutoExportFailedSubtitle": "Simpan unduhan yang gagal ke file TXT secara otomatis",
|
||||||
"@settingsAutoExportFailedSubtitle": {
|
"@settingsAutoExportFailedSubtitle": {
|
||||||
"description": "Subtitle for auto-export setting"
|
"description": "Subtitle for auto-export setting"
|
||||||
},
|
},
|
||||||
"settingsDownloadNetwork": "Download Network",
|
"settingsDownloadNetwork": "Jaringan Unduhan",
|
||||||
"@settingsDownloadNetwork": {
|
"@settingsDownloadNetwork": {
|
||||||
"description": "Setting for network type preference"
|
"description": "Setting for network type preference"
|
||||||
},
|
},
|
||||||
"settingsDownloadNetworkAny": "WiFi + Mobile Data",
|
"settingsDownloadNetworkAny": "WiFi + Data Seluler",
|
||||||
"@settingsDownloadNetworkAny": {
|
"@settingsDownloadNetworkAny": {
|
||||||
"description": "Network option - use any connection"
|
"description": "Network option - use any connection"
|
||||||
},
|
},
|
||||||
"settingsDownloadNetworkWifiOnly": "WiFi Only",
|
"settingsDownloadNetworkWifiOnly": "Hanya WiFi",
|
||||||
"@settingsDownloadNetworkWifiOnly": {
|
"@settingsDownloadNetworkWifiOnly": {
|
||||||
"description": "Network option - only use WiFi"
|
"description": "Network option - only use WiFi"
|
||||||
},
|
},
|
||||||
"settingsDownloadNetworkSubtitle": "Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.",
|
"settingsDownloadNetworkSubtitle": "Pilih jaringan mana yang akan digunakan untuk mengunduh. Jika diatur ke Hanya WiFi, unduhan akan berhenti sementara dan menggunakan data seluler.",
|
||||||
"@settingsDownloadNetworkSubtitle": {
|
"@settingsDownloadNetworkSubtitle": {
|
||||||
"description": "Subtitle explaining network preference"
|
"description": "Subtitle explaining network preference"
|
||||||
},
|
},
|
||||||
@@ -1889,11 +1925,11 @@
|
|||||||
"@albumFolderYearAlbumSubtitle": {
|
"@albumFolderYearAlbumSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
|
"albumFolderArtistAlbumSingles": "Artis / Album + Singel",
|
||||||
"@albumFolderArtistAlbumSingles": {
|
"@albumFolderArtistAlbumSingles": {
|
||||||
"description": "Album folder option with singles inside artist"
|
"description": "Album folder option with singles inside artist"
|
||||||
},
|
},
|
||||||
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
|
"albumFolderArtistAlbumSinglesSubtitle": "Artis/Album/ dan Artis/Single/",
|
||||||
"@albumFolderArtistAlbumSinglesSubtitle": {
|
"@albumFolderArtistAlbumSinglesSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
@@ -1962,19 +1998,19 @@
|
|||||||
"@recentTypeSong": {
|
"@recentTypeSong": {
|
||||||
"description": "Recent access item type - song/track"
|
"description": "Recent access item type - song/track"
|
||||||
},
|
},
|
||||||
"recentTypePlaylist": "Playlist",
|
"recentTypePlaylist": "Daftar putar",
|
||||||
"@recentTypePlaylist": {
|
"@recentTypePlaylist": {
|
||||||
"description": "Recent access item type - playlist"
|
"description": "Recent access item type - playlist"
|
||||||
},
|
},
|
||||||
"recentEmpty": "No recent items yet",
|
"recentEmpty": "Belum ada item terbaru",
|
||||||
"@recentEmpty": {
|
"@recentEmpty": {
|
||||||
"description": "Empty state text for recent access list"
|
"description": "Empty state text for recent access list"
|
||||||
},
|
},
|
||||||
"recentShowAllDownloads": "Show All Downloads",
|
"recentShowAllDownloads": "Tampilkan Semua Unduhan",
|
||||||
"@recentShowAllDownloads": {
|
"@recentShowAllDownloads": {
|
||||||
"description": "Button label to unhide hidden downloads in recent access"
|
"description": "Button label to unhide hidden downloads in recent access"
|
||||||
},
|
},
|
||||||
"recentPlaylistInfo": "Playlist: {name}",
|
"recentPlaylistInfo": "Daftar Putar: {name}",
|
||||||
"@recentPlaylistInfo": {
|
"@recentPlaylistInfo": {
|
||||||
"description": "Snackbar message when tapping playlist in recent access",
|
"description": "Snackbar message when tapping playlist in recent access",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1984,7 +2020,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"discographyDownload": "Download Discography",
|
"discographyDownload": "Unduh Diskografi",
|
||||||
"@discographyDownload": {
|
"@discographyDownload": {
|
||||||
"description": "Button - download artist discography"
|
"description": "Button - download artist discography"
|
||||||
},
|
},
|
||||||
@@ -2383,47 +2419,47 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTitle": "Welcome to SpotiFLAC!",
|
"tutorialWelcomeTitle": "Selamat Datang di SpotiFLAC!",
|
||||||
"@tutorialWelcomeTitle": {
|
"@tutorialWelcomeTitle": {
|
||||||
"description": "Tutorial welcome page title"
|
"description": "Tutorial welcome page title"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeDesc": "Let's learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.",
|
"tutorialWelcomeDesc": "Mari kita pelajari cara mengunduh musik favorit Anda dalam kualitas lossless. Tutorial singkat ini akan menunjukkan dasar-dasarnya.",
|
||||||
"@tutorialWelcomeDesc": {
|
"@tutorialWelcomeDesc": {
|
||||||
"description": "Tutorial welcome page description"
|
"description": "Tutorial welcome page description"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip1": "Download music from Spotify, Deezer, or paste any supported URL",
|
"tutorialWelcomeTip1": "Unduh musik dari Spotify, Deezer, atau tempel URL yang didukung",
|
||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"description": "Tutorial welcome tip 1"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
|
"tutorialWelcomeTip2": "Dapatkan audio berkualitas FLAC dari Tidal, Qobuz, atau Deezer",
|
||||||
"@tutorialWelcomeTip2": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip3": "Automatic metadata, cover art, and lyrics embedding",
|
"tutorialWelcomeTip3": "Penyematan metadata, sampul album, dan lirik secara otomatis",
|
||||||
"@tutorialWelcomeTip3": {
|
"@tutorialWelcomeTip3": {
|
||||||
"description": "Tutorial welcome tip 3"
|
"description": "Tutorial welcome tip 3"
|
||||||
},
|
},
|
||||||
"tutorialSearchTitle": "Finding Music",
|
"tutorialSearchTitle": "Menemukan Musik",
|
||||||
"@tutorialSearchTitle": {
|
"@tutorialSearchTitle": {
|
||||||
"description": "Tutorial search page title"
|
"description": "Tutorial search page title"
|
||||||
},
|
},
|
||||||
"tutorialSearchDesc": "There are two easy ways to find music you want to download.",
|
"tutorialSearchDesc": "Ada dua cara mudah untuk menemukan musik yang ingin Anda unduh.",
|
||||||
"@tutorialSearchDesc": {
|
"@tutorialSearchDesc": {
|
||||||
"description": "Tutorial search page description"
|
"description": "Tutorial search page description"
|
||||||
},
|
},
|
||||||
"tutorialDownloadTitle": "Downloading Music",
|
"tutorialDownloadTitle": "Mengunduh Musik",
|
||||||
"@tutorialDownloadTitle": {
|
"@tutorialDownloadTitle": {
|
||||||
"description": "Tutorial download page title"
|
"description": "Tutorial download page title"
|
||||||
},
|
},
|
||||||
"tutorialDownloadDesc": "Downloading music is simple and fast. Here's how it works.",
|
"tutorialDownloadDesc": "Mengunduh musik itu mudah dan cepat. Begini cara kerjanya.",
|
||||||
"@tutorialDownloadDesc": {
|
"@tutorialDownloadDesc": {
|
||||||
"description": "Tutorial download page description"
|
"description": "Tutorial download page description"
|
||||||
},
|
},
|
||||||
"tutorialLibraryTitle": "Your Library",
|
"tutorialLibraryTitle": "Perpustakaan Anda",
|
||||||
"@tutorialLibraryTitle": {
|
"@tutorialLibraryTitle": {
|
||||||
"description": "Tutorial library page title"
|
"description": "Tutorial library page title"
|
||||||
},
|
},
|
||||||
"tutorialLibraryDesc": "All your downloaded music is organized in the Library tab.",
|
"tutorialLibraryDesc": "Semua musik yang Anda unduh tersusun rapi di tab Perpustakaan.",
|
||||||
"@tutorialLibraryDesc": {
|
"@tutorialLibraryDesc": {
|
||||||
"description": "Tutorial library page description"
|
"description": "Tutorial library page description"
|
||||||
},
|
},
|
||||||
@@ -2447,7 +2483,7 @@
|
|||||||
"@tutorialExtensionsDesc": {
|
"@tutorialExtensionsDesc": {
|
||||||
"description": "Tutorial extensions page description"
|
"description": "Tutorial extensions page description"
|
||||||
},
|
},
|
||||||
"tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions",
|
"tutorialExtensionsTip1": "Buka tab Repo untuk menemukan ekstensi yang berguna",
|
||||||
"@tutorialExtensionsTip1": {
|
"@tutorialExtensionsTip1": {
|
||||||
"description": "Tutorial extensions tip 1"
|
"description": "Tutorial extensions tip 1"
|
||||||
},
|
},
|
||||||
@@ -2877,6 +2913,90 @@
|
|||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"description": "Snackbar when conversion fails"
|
||||||
},
|
},
|
||||||
|
"cueSplitTitle": "Split CUE Sheet",
|
||||||
|
"@cueSplitTitle": {
|
||||||
|
"description": "Title for CUE split bottom sheet"
|
||||||
|
},
|
||||||
|
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
|
||||||
|
"@cueSplitSubtitle": {
|
||||||
|
"description": "Subtitle for CUE split menu item"
|
||||||
|
},
|
||||||
|
"cueSplitAlbum": "Album: {album}",
|
||||||
|
"@cueSplitAlbum": {
|
||||||
|
"description": "Album name in CUE split sheet",
|
||||||
|
"placeholders": {
|
||||||
|
"album": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitArtist": "Artist: {artist}",
|
||||||
|
"@cueSplitArtist": {
|
||||||
|
"description": "Artist name in CUE split sheet",
|
||||||
|
"placeholders": {
|
||||||
|
"artist": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitTrackCount": "{count} tracks",
|
||||||
|
"@cueSplitTrackCount": {
|
||||||
|
"description": "Number of tracks in CUE sheet",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitConfirmTitle": "Split CUE Album",
|
||||||
|
"@cueSplitConfirmTitle": {
|
||||||
|
"description": "CUE split confirmation dialog title"
|
||||||
|
},
|
||||||
|
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
|
||||||
|
"@cueSplitConfirmMessage": {
|
||||||
|
"description": "CUE split confirmation dialog message",
|
||||||
|
"placeholders": {
|
||||||
|
"album": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
|
||||||
|
"@cueSplitSplitting": {
|
||||||
|
"description": "Snackbar while splitting CUE",
|
||||||
|
"placeholders": {
|
||||||
|
"current": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitSuccess": "Split into {count} tracks successfully",
|
||||||
|
"@cueSplitSuccess": {
|
||||||
|
"description": "Snackbar after successful CUE split",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitFailed": "CUE split failed",
|
||||||
|
"@cueSplitFailed": {
|
||||||
|
"description": "Snackbar when CUE split fails"
|
||||||
|
},
|
||||||
|
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
|
||||||
|
"@cueSplitNoAudioFile": {
|
||||||
|
"description": "Error when CUE audio file is missing"
|
||||||
|
},
|
||||||
|
"cueSplitButton": "Split into Tracks",
|
||||||
|
"@cueSplitButton": {
|
||||||
|
"description": "Button text to start CUE splitting"
|
||||||
|
},
|
||||||
"actionCreate": "Create",
|
"actionCreate": "Create",
|
||||||
"@actionCreate": {
|
"@actionCreate": {
|
||||||
"description": "Generic action button - create"
|
"description": "Generic action button - create"
|
||||||
|
|||||||
+116
-24
@@ -450,7 +450,7 @@
|
|||||||
"@aboutSpotiSaverDesc": {
|
"@aboutSpotiSaverDesc": {
|
||||||
"description": "Credit for SpotiSaver API"
|
"description": "Credit for SpotiSaver API"
|
||||||
},
|
},
|
||||||
"aboutAppDescription": "Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。",
|
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -897,6 +897,18 @@
|
|||||||
"@errorNoTracksFound": {
|
"@errorNoTracksFound": {
|
||||||
"description": "Error - search returned no results"
|
"description": "Error - search returned no results"
|
||||||
},
|
},
|
||||||
|
"errorUrlNotRecognized": "Link not recognized",
|
||||||
|
"@errorUrlNotRecognized": {
|
||||||
|
"description": "Error title - URL not handled by any extension or service"
|
||||||
|
},
|
||||||
|
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
|
||||||
|
"@errorUrlNotRecognizedMessage": {
|
||||||
|
"description": "Error message - URL not recognized explanation"
|
||||||
|
},
|
||||||
|
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
|
||||||
|
"@errorUrlFetchFailed": {
|
||||||
|
"description": "Error message - generic URL fetch failure"
|
||||||
|
},
|
||||||
"errorMissingExtensionSource": "{item} を読み込めません: 拡張ソースがありません",
|
"errorMissingExtensionSource": "{item} を読み込めません: 拡張ソースがありません",
|
||||||
"@errorMissingExtensionSource": {
|
"@errorMissingExtensionSource": {
|
||||||
"description": "Error - extension source not available",
|
"description": "Error - extension source not available",
|
||||||
@@ -991,7 +1003,7 @@
|
|||||||
"@filenameFormat": {
|
"@filenameFormat": {
|
||||||
"description": "Setting title - filename pattern"
|
"description": "Setting title - filename pattern"
|
||||||
},
|
},
|
||||||
"filenameShowAdvancedTags": "Show advanced tags",
|
"filenameShowAdvancedTags": "高度なタグを表示",
|
||||||
"@filenameShowAdvancedTags": {
|
"@filenameShowAdvancedTags": {
|
||||||
"description": "Toggle label for showing advanced filename tags"
|
"description": "Toggle label for showing advanced filename tags"
|
||||||
},
|
},
|
||||||
@@ -1003,6 +1015,14 @@
|
|||||||
"@folderOrganizationNone": {
|
"@folderOrganizationNone": {
|
||||||
"description": "Folder option - flat structure"
|
"description": "Folder option - flat structure"
|
||||||
},
|
},
|
||||||
|
"folderOrganizationByPlaylist": "By Playlist",
|
||||||
|
"@folderOrganizationByPlaylist": {
|
||||||
|
"description": "Folder option - playlist folders"
|
||||||
|
},
|
||||||
|
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
|
||||||
|
"@folderOrganizationByPlaylistSubtitle": {
|
||||||
|
"description": "Subtitle for playlist folder option"
|
||||||
|
},
|
||||||
"folderOrganizationByArtist": "アーティスト別",
|
"folderOrganizationByArtist": "アーティスト別",
|
||||||
"@folderOrganizationByArtist": {
|
"@folderOrganizationByArtist": {
|
||||||
"description": "Folder option - artist folders"
|
"description": "Folder option - artist folders"
|
||||||
@@ -1097,7 +1117,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "内蔵",
|
"providerBuiltIn": "内蔵",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||||
},
|
},
|
||||||
"providerExtension": "拡張",
|
"providerExtension": "拡張",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1471,7 +1491,7 @@
|
|||||||
"@trackLyricsEmbedded": {
|
"@trackLyricsEmbedded": {
|
||||||
"description": "Snackbar - lyrics saved to file"
|
"description": "Snackbar - lyrics saved to file"
|
||||||
},
|
},
|
||||||
"trackInstrumental": "Instrumental track",
|
"trackInstrumental": "インストゥルメンタルのトラック",
|
||||||
"@trackInstrumental": {
|
"@trackInstrumental": {
|
||||||
"description": "Message when track is instrumental (no lyrics)"
|
"description": "Message when track is instrumental (no lyrics)"
|
||||||
},
|
},
|
||||||
@@ -1753,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "YouTube Opus のビットレート",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "YouTube MP3 のビットレート",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "ダウンロード前に確認する",
|
"downloadAskBeforeDownload": "ダウンロード前に確認する",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -2383,7 +2391,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"description": "Tutorial welcome tip 1"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
|
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
||||||
"@tutorialWelcomeTip2": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2808,6 +2816,90 @@
|
|||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"description": "Snackbar when conversion fails"
|
||||||
},
|
},
|
||||||
|
"cueSplitTitle": "分割 CUE シート",
|
||||||
|
"@cueSplitTitle": {
|
||||||
|
"description": "Title for CUE split bottom sheet"
|
||||||
|
},
|
||||||
|
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
|
||||||
|
"@cueSplitSubtitle": {
|
||||||
|
"description": "Subtitle for CUE split menu item"
|
||||||
|
},
|
||||||
|
"cueSplitAlbum": "Album: {album}",
|
||||||
|
"@cueSplitAlbum": {
|
||||||
|
"description": "Album name in CUE split sheet",
|
||||||
|
"placeholders": {
|
||||||
|
"album": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitArtist": "Artist: {artist}",
|
||||||
|
"@cueSplitArtist": {
|
||||||
|
"description": "Artist name in CUE split sheet",
|
||||||
|
"placeholders": {
|
||||||
|
"artist": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitTrackCount": "{count} tracks",
|
||||||
|
"@cueSplitTrackCount": {
|
||||||
|
"description": "Number of tracks in CUE sheet",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitConfirmTitle": "Split CUE Album",
|
||||||
|
"@cueSplitConfirmTitle": {
|
||||||
|
"description": "CUE split confirmation dialog title"
|
||||||
|
},
|
||||||
|
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
|
||||||
|
"@cueSplitConfirmMessage": {
|
||||||
|
"description": "CUE split confirmation dialog message",
|
||||||
|
"placeholders": {
|
||||||
|
"album": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
|
||||||
|
"@cueSplitSplitting": {
|
||||||
|
"description": "Snackbar while splitting CUE",
|
||||||
|
"placeholders": {
|
||||||
|
"current": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitSuccess": "Split into {count} tracks successfully",
|
||||||
|
"@cueSplitSuccess": {
|
||||||
|
"description": "Snackbar after successful CUE split",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitFailed": "CUE split failed",
|
||||||
|
"@cueSplitFailed": {
|
||||||
|
"description": "Snackbar when CUE split fails"
|
||||||
|
},
|
||||||
|
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
|
||||||
|
"@cueSplitNoAudioFile": {
|
||||||
|
"description": "Error when CUE audio file is missing"
|
||||||
|
},
|
||||||
|
"cueSplitButton": "Split into Tracks",
|
||||||
|
"@cueSplitButton": {
|
||||||
|
"description": "Button text to start CUE splitting"
|
||||||
|
},
|
||||||
"actionCreate": "Create",
|
"actionCreate": "Create",
|
||||||
"@actionCreate": {
|
"@actionCreate": {
|
||||||
"description": "Generic action button - create"
|
"description": "Generic action button - create"
|
||||||
@@ -2940,7 +3032,7 @@
|
|||||||
"@collectionRemoveFromPlaylist": {
|
"@collectionRemoveFromPlaylist": {
|
||||||
"description": "Tooltip for removing track from playlist"
|
"description": "Tooltip for removing track from playlist"
|
||||||
},
|
},
|
||||||
"collectionRemoveFromFolder": "Remove from folder",
|
"collectionRemoveFromFolder": "フォルダから削除",
|
||||||
"@collectionRemoveFromFolder": {
|
"@collectionRemoveFromFolder": {
|
||||||
"description": "Tooltip for removing track from wishlist/loved folder"
|
"description": "Tooltip for removing track from wishlist/loved folder"
|
||||||
},
|
},
|
||||||
@@ -2997,23 +3089,23 @@
|
|||||||
"@trackOptionRemoveFromLoved": {
|
"@trackOptionRemoveFromLoved": {
|
||||||
"description": "Bottom sheet action label - remove track from loved folder"
|
"description": "Bottom sheet action label - remove track from loved folder"
|
||||||
},
|
},
|
||||||
"trackOptionAddToWishlist": "Add to Wishlist",
|
"trackOptionAddToWishlist": "ウィッシュリストに追加",
|
||||||
"@trackOptionAddToWishlist": {
|
"@trackOptionAddToWishlist": {
|
||||||
"description": "Bottom sheet action label - add track to wishlist"
|
"description": "Bottom sheet action label - add track to wishlist"
|
||||||
},
|
},
|
||||||
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
|
"trackOptionRemoveFromWishlist": "ウィッシュから削除",
|
||||||
"@trackOptionRemoveFromWishlist": {
|
"@trackOptionRemoveFromWishlist": {
|
||||||
"description": "Bottom sheet action label - remove track from wishlist"
|
"description": "Bottom sheet action label - remove track from wishlist"
|
||||||
},
|
},
|
||||||
"collectionPlaylistChangeCover": "Change cover image",
|
"collectionPlaylistChangeCover": "カバー画像を変更",
|
||||||
"@collectionPlaylistChangeCover": {
|
"@collectionPlaylistChangeCover": {
|
||||||
"description": "Bottom sheet action to pick a custom cover image for a playlist"
|
"description": "Bottom sheet action to pick a custom cover image for a playlist"
|
||||||
},
|
},
|
||||||
"collectionPlaylistRemoveCover": "Remove cover image",
|
"collectionPlaylistRemoveCover": "カバー画像を削除",
|
||||||
"@collectionPlaylistRemoveCover": {
|
"@collectionPlaylistRemoveCover": {
|
||||||
"description": "Bottom sheet action to remove custom cover image from a playlist"
|
"description": "Bottom sheet action to remove custom cover image from a playlist"
|
||||||
},
|
},
|
||||||
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
|
"selectionShareCount": "{count} {count, plural, =1{個のトラック} other{個のトラック}}を共有",
|
||||||
"@selectionShareCount": {
|
"@selectionShareCount": {
|
||||||
"description": "Share button text with count in selection mode",
|
"description": "Share button text with count in selection mode",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -3039,7 +3131,7 @@
|
|||||||
"@selectionConvertNoConvertible": {
|
"@selectionConvertNoConvertible": {
|
||||||
"description": "Snackbar when no selected tracks support conversion"
|
"description": "Snackbar when no selected tracks support conversion"
|
||||||
},
|
},
|
||||||
"selectionBatchConvertConfirmTitle": "Batch Convert",
|
"selectionBatchConvertConfirmTitle": "一括変換",
|
||||||
"@selectionBatchConvertConfirmTitle": {
|
"@selectionBatchConvertConfirmTitle": {
|
||||||
"description": "Confirmation dialog title for batch conversion"
|
"description": "Confirmation dialog title for batch conversion"
|
||||||
},
|
},
|
||||||
|
|||||||
+107
-15
@@ -450,7 +450,7 @@
|
|||||||
"@aboutSpotiSaverDesc": {
|
"@aboutSpotiSaverDesc": {
|
||||||
"description": "Credit for SpotiSaver API"
|
"description": "Credit for SpotiSaver API"
|
||||||
},
|
},
|
||||||
"aboutAppDescription": "Tidal, Qobuz, Amazon Music에서 Spotify 트랙을 무손실 음질로 다운로드하세요.",
|
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -897,6 +897,18 @@
|
|||||||
"@errorNoTracksFound": {
|
"@errorNoTracksFound": {
|
||||||
"description": "Error - search returned no results"
|
"description": "Error - search returned no results"
|
||||||
},
|
},
|
||||||
|
"errorUrlNotRecognized": "Link not recognized",
|
||||||
|
"@errorUrlNotRecognized": {
|
||||||
|
"description": "Error title - URL not handled by any extension or service"
|
||||||
|
},
|
||||||
|
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
|
||||||
|
"@errorUrlNotRecognizedMessage": {
|
||||||
|
"description": "Error message - URL not recognized explanation"
|
||||||
|
},
|
||||||
|
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
|
||||||
|
"@errorUrlFetchFailed": {
|
||||||
|
"description": "Error message - generic URL fetch failure"
|
||||||
|
},
|
||||||
"errorMissingExtensionSource": "확장 소스가 누락되어, {item}(을)를 로드할 수 없습니다",
|
"errorMissingExtensionSource": "확장 소스가 누락되어, {item}(을)를 로드할 수 없습니다",
|
||||||
"@errorMissingExtensionSource": {
|
"@errorMissingExtensionSource": {
|
||||||
"description": "Error - extension source not available",
|
"description": "Error - extension source not available",
|
||||||
@@ -1003,6 +1015,14 @@
|
|||||||
"@folderOrganizationNone": {
|
"@folderOrganizationNone": {
|
||||||
"description": "Folder option - flat structure"
|
"description": "Folder option - flat structure"
|
||||||
},
|
},
|
||||||
|
"folderOrganizationByPlaylist": "By Playlist",
|
||||||
|
"@folderOrganizationByPlaylist": {
|
||||||
|
"description": "Folder option - playlist folders"
|
||||||
|
},
|
||||||
|
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
|
||||||
|
"@folderOrganizationByPlaylistSubtitle": {
|
||||||
|
"description": "Subtitle for playlist folder option"
|
||||||
|
},
|
||||||
"folderOrganizationByArtist": "By Artist",
|
"folderOrganizationByArtist": "By Artist",
|
||||||
"@folderOrganizationByArtist": {
|
"@folderOrganizationByArtist": {
|
||||||
"description": "Folder option - artist folders"
|
"description": "Folder option - artist folders"
|
||||||
@@ -1097,7 +1117,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "Built-in",
|
"providerBuiltIn": "Built-in",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Extension",
|
"providerExtension": "Extension",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1753,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Ask Before Download",
|
"downloadAskBeforeDownload": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -2383,7 +2391,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"description": "Tutorial welcome tip 1"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
|
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
||||||
"@tutorialWelcomeTip2": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2808,6 +2816,90 @@
|
|||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"description": "Snackbar when conversion fails"
|
||||||
},
|
},
|
||||||
|
"cueSplitTitle": "Split CUE Sheet",
|
||||||
|
"@cueSplitTitle": {
|
||||||
|
"description": "Title for CUE split bottom sheet"
|
||||||
|
},
|
||||||
|
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
|
||||||
|
"@cueSplitSubtitle": {
|
||||||
|
"description": "Subtitle for CUE split menu item"
|
||||||
|
},
|
||||||
|
"cueSplitAlbum": "Album: {album}",
|
||||||
|
"@cueSplitAlbum": {
|
||||||
|
"description": "Album name in CUE split sheet",
|
||||||
|
"placeholders": {
|
||||||
|
"album": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitArtist": "Artist: {artist}",
|
||||||
|
"@cueSplitArtist": {
|
||||||
|
"description": "Artist name in CUE split sheet",
|
||||||
|
"placeholders": {
|
||||||
|
"artist": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitTrackCount": "{count} tracks",
|
||||||
|
"@cueSplitTrackCount": {
|
||||||
|
"description": "Number of tracks in CUE sheet",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitConfirmTitle": "Split CUE Album",
|
||||||
|
"@cueSplitConfirmTitle": {
|
||||||
|
"description": "CUE split confirmation dialog title"
|
||||||
|
},
|
||||||
|
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
|
||||||
|
"@cueSplitConfirmMessage": {
|
||||||
|
"description": "CUE split confirmation dialog message",
|
||||||
|
"placeholders": {
|
||||||
|
"album": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
|
||||||
|
"@cueSplitSplitting": {
|
||||||
|
"description": "Snackbar while splitting CUE",
|
||||||
|
"placeholders": {
|
||||||
|
"current": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitSuccess": "Split into {count} tracks successfully",
|
||||||
|
"@cueSplitSuccess": {
|
||||||
|
"description": "Snackbar after successful CUE split",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitFailed": "CUE split failed",
|
||||||
|
"@cueSplitFailed": {
|
||||||
|
"description": "Snackbar when CUE split fails"
|
||||||
|
},
|
||||||
|
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
|
||||||
|
"@cueSplitNoAudioFile": {
|
||||||
|
"description": "Error when CUE audio file is missing"
|
||||||
|
},
|
||||||
|
"cueSplitButton": "Split into Tracks",
|
||||||
|
"@cueSplitButton": {
|
||||||
|
"description": "Button text to start CUE splitting"
|
||||||
|
},
|
||||||
"actionCreate": "Create",
|
"actionCreate": "Create",
|
||||||
"@actionCreate": {
|
"@actionCreate": {
|
||||||
"description": "Generic action button - create"
|
"description": "Generic action button - create"
|
||||||
|
|||||||
+111
-19
@@ -194,11 +194,11 @@
|
|||||||
"@optionsConcurrentDownloads": {
|
"@optionsConcurrentDownloads": {
|
||||||
"description": "Number of parallel downloads"
|
"description": "Number of parallel downloads"
|
||||||
},
|
},
|
||||||
"optionsConcurrentSequential": "Sequential (1 at a time)",
|
"optionsConcurrentSequential": "Sequentiële (1 per keer)",
|
||||||
"@optionsConcurrentSequential": {
|
"@optionsConcurrentSequential": {
|
||||||
"description": "Download one at a time"
|
"description": "Download one at a time"
|
||||||
},
|
},
|
||||||
"optionsConcurrentParallel": "{count} parallel downloads",
|
"optionsConcurrentParallel": "",
|
||||||
"@optionsConcurrentParallel": {
|
"@optionsConcurrentParallel": {
|
||||||
"description": "Multiple parallel downloads",
|
"description": "Multiple parallel downloads",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -207,7 +207,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
|
"optionsConcurrentWarning": "Parallel downloaden kan leiden tot rate-limiting",
|
||||||
"@optionsConcurrentWarning": {
|
"@optionsConcurrentWarning": {
|
||||||
"description": "Warning about rate limits"
|
"description": "Warning about rate limits"
|
||||||
},
|
},
|
||||||
@@ -346,7 +346,7 @@
|
|||||||
"@aboutContributors": {
|
"@aboutContributors": {
|
||||||
"description": "Section for contributors"
|
"description": "Section for contributors"
|
||||||
},
|
},
|
||||||
"aboutMobileDeveloper": "Mobile version developer",
|
"aboutMobileDeveloper": "",
|
||||||
"@aboutMobileDeveloper": {
|
"@aboutMobileDeveloper": {
|
||||||
"description": "Role description for mobile dev"
|
"description": "Role description for mobile dev"
|
||||||
},
|
},
|
||||||
@@ -450,7 +450,7 @@
|
|||||||
"@aboutSpotiSaverDesc": {
|
"@aboutSpotiSaverDesc": {
|
||||||
"description": "Credit for SpotiSaver API"
|
"description": "Credit for SpotiSaver API"
|
||||||
},
|
},
|
||||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
|
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -897,6 +897,18 @@
|
|||||||
"@errorNoTracksFound": {
|
"@errorNoTracksFound": {
|
||||||
"description": "Error - search returned no results"
|
"description": "Error - search returned no results"
|
||||||
},
|
},
|
||||||
|
"errorUrlNotRecognized": "Link not recognized",
|
||||||
|
"@errorUrlNotRecognized": {
|
||||||
|
"description": "Error title - URL not handled by any extension or service"
|
||||||
|
},
|
||||||
|
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
|
||||||
|
"@errorUrlNotRecognizedMessage": {
|
||||||
|
"description": "Error message - URL not recognized explanation"
|
||||||
|
},
|
||||||
|
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
|
||||||
|
"@errorUrlFetchFailed": {
|
||||||
|
"description": "Error message - generic URL fetch failure"
|
||||||
|
},
|
||||||
"errorMissingExtensionSource": "Cannot load {item}: missing extension source",
|
"errorMissingExtensionSource": "Cannot load {item}: missing extension source",
|
||||||
"@errorMissingExtensionSource": {
|
"@errorMissingExtensionSource": {
|
||||||
"description": "Error - extension source not available",
|
"description": "Error - extension source not available",
|
||||||
@@ -1003,6 +1015,14 @@
|
|||||||
"@folderOrganizationNone": {
|
"@folderOrganizationNone": {
|
||||||
"description": "Folder option - flat structure"
|
"description": "Folder option - flat structure"
|
||||||
},
|
},
|
||||||
|
"folderOrganizationByPlaylist": "By Playlist",
|
||||||
|
"@folderOrganizationByPlaylist": {
|
||||||
|
"description": "Folder option - playlist folders"
|
||||||
|
},
|
||||||
|
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
|
||||||
|
"@folderOrganizationByPlaylistSubtitle": {
|
||||||
|
"description": "Subtitle for playlist folder option"
|
||||||
|
},
|
||||||
"folderOrganizationByArtist": "By Artist",
|
"folderOrganizationByArtist": "By Artist",
|
||||||
"@folderOrganizationByArtist": {
|
"@folderOrganizationByArtist": {
|
||||||
"description": "Folder option - artist folders"
|
"description": "Folder option - artist folders"
|
||||||
@@ -1097,7 +1117,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "Built-in",
|
"providerBuiltIn": "Built-in",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Extension",
|
"providerExtension": "Extension",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1753,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Ask Before Download",
|
"downloadAskBeforeDownload": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -2383,7 +2391,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"description": "Tutorial welcome tip 1"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
|
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
||||||
"@tutorialWelcomeTip2": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2808,6 +2816,90 @@
|
|||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"description": "Snackbar when conversion fails"
|
||||||
},
|
},
|
||||||
|
"cueSplitTitle": "Split CUE Sheet",
|
||||||
|
"@cueSplitTitle": {
|
||||||
|
"description": "Title for CUE split bottom sheet"
|
||||||
|
},
|
||||||
|
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
|
||||||
|
"@cueSplitSubtitle": {
|
||||||
|
"description": "Subtitle for CUE split menu item"
|
||||||
|
},
|
||||||
|
"cueSplitAlbum": "Album: {album}",
|
||||||
|
"@cueSplitAlbum": {
|
||||||
|
"description": "Album name in CUE split sheet",
|
||||||
|
"placeholders": {
|
||||||
|
"album": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitArtist": "Artist: {artist}",
|
||||||
|
"@cueSplitArtist": {
|
||||||
|
"description": "Artist name in CUE split sheet",
|
||||||
|
"placeholders": {
|
||||||
|
"artist": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitTrackCount": "{count} tracks",
|
||||||
|
"@cueSplitTrackCount": {
|
||||||
|
"description": "Number of tracks in CUE sheet",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitConfirmTitle": "Split CUE Album",
|
||||||
|
"@cueSplitConfirmTitle": {
|
||||||
|
"description": "CUE split confirmation dialog title"
|
||||||
|
},
|
||||||
|
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
|
||||||
|
"@cueSplitConfirmMessage": {
|
||||||
|
"description": "CUE split confirmation dialog message",
|
||||||
|
"placeholders": {
|
||||||
|
"album": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
|
||||||
|
"@cueSplitSplitting": {
|
||||||
|
"description": "Snackbar while splitting CUE",
|
||||||
|
"placeholders": {
|
||||||
|
"current": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitSuccess": "Split into {count} tracks successfully",
|
||||||
|
"@cueSplitSuccess": {
|
||||||
|
"description": "Snackbar after successful CUE split",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitFailed": "CUE split failed",
|
||||||
|
"@cueSplitFailed": {
|
||||||
|
"description": "Snackbar when CUE split fails"
|
||||||
|
},
|
||||||
|
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
|
||||||
|
"@cueSplitNoAudioFile": {
|
||||||
|
"description": "Error when CUE audio file is missing"
|
||||||
|
},
|
||||||
|
"cueSplitButton": "Split into Tracks",
|
||||||
|
"@cueSplitButton": {
|
||||||
|
"description": "Button text to start CUE splitting"
|
||||||
|
},
|
||||||
"actionCreate": "Create",
|
"actionCreate": "Create",
|
||||||
"@actionCreate": {
|
"@actionCreate": {
|
||||||
"description": "Generic action button - create"
|
"description": "Generic action button - create"
|
||||||
|
|||||||
+401
-7
@@ -450,7 +450,7 @@
|
|||||||
"@aboutSpotiSaverDesc": {
|
"@aboutSpotiSaverDesc": {
|
||||||
"description": "Credit for SpotiSaver API"
|
"description": "Credit for SpotiSaver API"
|
||||||
},
|
},
|
||||||
"aboutAppDescription": "Baixe faixas do Spotify em qualidade sem perdas do Tidal e Qobuz.",
|
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -897,6 +897,18 @@
|
|||||||
"@errorNoTracksFound": {
|
"@errorNoTracksFound": {
|
||||||
"description": "Error - search returned no results"
|
"description": "Error - search returned no results"
|
||||||
},
|
},
|
||||||
|
"errorUrlNotRecognized": "Link not recognized",
|
||||||
|
"@errorUrlNotRecognized": {
|
||||||
|
"description": "Error title - URL not handled by any extension or service"
|
||||||
|
},
|
||||||
|
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
|
||||||
|
"@errorUrlNotRecognizedMessage": {
|
||||||
|
"description": "Error message - URL not recognized explanation"
|
||||||
|
},
|
||||||
|
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
|
||||||
|
"@errorUrlFetchFailed": {
|
||||||
|
"description": "Error message - generic URL fetch failure"
|
||||||
|
},
|
||||||
"errorMissingExtensionSource": "Não é possível carregar {item}: faltando a fonte da extensão",
|
"errorMissingExtensionSource": "Não é possível carregar {item}: faltando a fonte da extensão",
|
||||||
"@errorMissingExtensionSource": {
|
"@errorMissingExtensionSource": {
|
||||||
"description": "Error - extension source not available",
|
"description": "Error - extension source not available",
|
||||||
@@ -991,10 +1003,26 @@
|
|||||||
"@filenameFormat": {
|
"@filenameFormat": {
|
||||||
"description": "Setting title - filename pattern"
|
"description": "Setting title - filename pattern"
|
||||||
},
|
},
|
||||||
|
"filenameShowAdvancedTags": "Show advanced tags",
|
||||||
|
"@filenameShowAdvancedTags": {
|
||||||
|
"description": "Toggle label for showing advanced filename tags"
|
||||||
|
},
|
||||||
|
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
|
||||||
|
"@filenameShowAdvancedTagsDescription": {
|
||||||
|
"description": "Description for advanced filename tag toggle"
|
||||||
|
},
|
||||||
"folderOrganizationNone": "Nenhuma organização",
|
"folderOrganizationNone": "Nenhuma organização",
|
||||||
"@folderOrganizationNone": {
|
"@folderOrganizationNone": {
|
||||||
"description": "Folder option - flat structure"
|
"description": "Folder option - flat structure"
|
||||||
},
|
},
|
||||||
|
"folderOrganizationByPlaylist": "By Playlist",
|
||||||
|
"@folderOrganizationByPlaylist": {
|
||||||
|
"description": "Folder option - playlist folders"
|
||||||
|
},
|
||||||
|
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
|
||||||
|
"@folderOrganizationByPlaylistSubtitle": {
|
||||||
|
"description": "Subtitle for playlist folder option"
|
||||||
|
},
|
||||||
"folderOrganizationByArtist": "Por Artista",
|
"folderOrganizationByArtist": "Por Artista",
|
||||||
"@folderOrganizationByArtist": {
|
"@folderOrganizationByArtist": {
|
||||||
"description": "Folder option - artist folders"
|
"description": "Folder option - artist folders"
|
||||||
@@ -1745,10 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Perguntar qualidade antes de baixar",
|
"downloadAskBeforeDownload": "Perguntar qualidade antes de baixar",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -2198,6 +2222,15 @@
|
|||||||
"@libraryAboutDescription": {
|
"@libraryAboutDescription": {
|
||||||
"description": "Description of local library feature"
|
"description": "Description of local library feature"
|
||||||
},
|
},
|
||||||
|
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
|
||||||
|
"@libraryTracksUnit": {
|
||||||
|
"description": "Unit label for tracks count (without the number itself)",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"libraryLastScanned": "Last scanned: {time}",
|
"libraryLastScanned": "Last scanned: {time}",
|
||||||
"@libraryLastScanned": {
|
"@libraryLastScanned": {
|
||||||
"description": "Last scan time display",
|
"description": "Last scan time display",
|
||||||
@@ -2358,7 +2391,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"description": "Tutorial welcome tip 1"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip2": "Obtenha áudio em qualidade FLAC do Tidal, Qobuz ou Deezer",
|
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
||||||
"@tutorialWelcomeTip2": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2783,6 +2816,367 @@
|
|||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"description": "Snackbar when conversion fails"
|
||||||
},
|
},
|
||||||
|
"cueSplitTitle": "Split CUE Sheet",
|
||||||
|
"@cueSplitTitle": {
|
||||||
|
"description": "Title for CUE split bottom sheet"
|
||||||
|
},
|
||||||
|
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
|
||||||
|
"@cueSplitSubtitle": {
|
||||||
|
"description": "Subtitle for CUE split menu item"
|
||||||
|
},
|
||||||
|
"cueSplitAlbum": "Album: {album}",
|
||||||
|
"@cueSplitAlbum": {
|
||||||
|
"description": "Album name in CUE split sheet",
|
||||||
|
"placeholders": {
|
||||||
|
"album": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitArtist": "Artist: {artist}",
|
||||||
|
"@cueSplitArtist": {
|
||||||
|
"description": "Artist name in CUE split sheet",
|
||||||
|
"placeholders": {
|
||||||
|
"artist": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitTrackCount": "{count} tracks",
|
||||||
|
"@cueSplitTrackCount": {
|
||||||
|
"description": "Number of tracks in CUE sheet",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitConfirmTitle": "Split CUE Album",
|
||||||
|
"@cueSplitConfirmTitle": {
|
||||||
|
"description": "CUE split confirmation dialog title"
|
||||||
|
},
|
||||||
|
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
|
||||||
|
"@cueSplitConfirmMessage": {
|
||||||
|
"description": "CUE split confirmation dialog message",
|
||||||
|
"placeholders": {
|
||||||
|
"album": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
|
||||||
|
"@cueSplitSplitting": {
|
||||||
|
"description": "Snackbar while splitting CUE",
|
||||||
|
"placeholders": {
|
||||||
|
"current": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitSuccess": "Split into {count} tracks successfully",
|
||||||
|
"@cueSplitSuccess": {
|
||||||
|
"description": "Snackbar after successful CUE split",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cueSplitFailed": "CUE split failed",
|
||||||
|
"@cueSplitFailed": {
|
||||||
|
"description": "Snackbar when CUE split fails"
|
||||||
|
},
|
||||||
|
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
|
||||||
|
"@cueSplitNoAudioFile": {
|
||||||
|
"description": "Error when CUE audio file is missing"
|
||||||
|
},
|
||||||
|
"cueSplitButton": "Split into Tracks",
|
||||||
|
"@cueSplitButton": {
|
||||||
|
"description": "Button text to start CUE splitting"
|
||||||
|
},
|
||||||
|
"actionCreate": "Create",
|
||||||
|
"@actionCreate": {
|
||||||
|
"description": "Generic action button - create"
|
||||||
|
},
|
||||||
|
"collectionFoldersTitle": "My folders",
|
||||||
|
"@collectionFoldersTitle": {
|
||||||
|
"description": "Library section title for custom folders"
|
||||||
|
},
|
||||||
|
"collectionWishlist": "Wishlist",
|
||||||
|
"@collectionWishlist": {
|
||||||
|
"description": "Custom folder for saved tracks to download later"
|
||||||
|
},
|
||||||
|
"collectionLoved": "Loved",
|
||||||
|
"@collectionLoved": {
|
||||||
|
"description": "Custom folder for favorite tracks"
|
||||||
|
},
|
||||||
|
"collectionPlaylists": "Playlists",
|
||||||
|
"@collectionPlaylists": {
|
||||||
|
"description": "Custom user playlists folder"
|
||||||
|
},
|
||||||
|
"collectionPlaylist": "Playlist",
|
||||||
|
"@collectionPlaylist": {
|
||||||
|
"description": "Single playlist label"
|
||||||
|
},
|
||||||
|
"collectionAddToPlaylist": "Add to playlist",
|
||||||
|
"@collectionAddToPlaylist": {
|
||||||
|
"description": "Action to add a track to user playlist"
|
||||||
|
},
|
||||||
|
"collectionCreatePlaylist": "Create playlist",
|
||||||
|
"@collectionCreatePlaylist": {
|
||||||
|
"description": "Action to create a new playlist"
|
||||||
|
},
|
||||||
|
"collectionNoPlaylistsYet": "No playlists yet",
|
||||||
|
"@collectionNoPlaylistsYet": {
|
||||||
|
"description": "Empty state title when user has no playlists"
|
||||||
|
},
|
||||||
|
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
|
||||||
|
"@collectionNoPlaylistsSubtitle": {
|
||||||
|
"description": "Empty state subtitle when user has no playlists"
|
||||||
|
},
|
||||||
|
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
|
||||||
|
"@collectionPlaylistTracks": {
|
||||||
|
"description": "Track count label for custom playlists",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
|
||||||
|
"@collectionAddedToPlaylist": {
|
||||||
|
"description": "Snackbar after adding track to playlist",
|
||||||
|
"placeholders": {
|
||||||
|
"playlistName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
|
||||||
|
"@collectionAlreadyInPlaylist": {
|
||||||
|
"description": "Snackbar when track already exists in playlist",
|
||||||
|
"placeholders": {
|
||||||
|
"playlistName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionPlaylistCreated": "Playlist created",
|
||||||
|
"@collectionPlaylistCreated": {
|
||||||
|
"description": "Snackbar after creating playlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistNameHint": "Playlist name",
|
||||||
|
"@collectionPlaylistNameHint": {
|
||||||
|
"description": "Hint text for playlist name input"
|
||||||
|
},
|
||||||
|
"collectionPlaylistNameRequired": "Playlist name is required",
|
||||||
|
"@collectionPlaylistNameRequired": {
|
||||||
|
"description": "Validation error for empty playlist name"
|
||||||
|
},
|
||||||
|
"collectionRenamePlaylist": "Rename playlist",
|
||||||
|
"@collectionRenamePlaylist": {
|
||||||
|
"description": "Action to rename playlist"
|
||||||
|
},
|
||||||
|
"collectionDeletePlaylist": "Delete playlist",
|
||||||
|
"@collectionDeletePlaylist": {
|
||||||
|
"description": "Action to delete playlist"
|
||||||
|
},
|
||||||
|
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
|
||||||
|
"@collectionDeletePlaylistMessage": {
|
||||||
|
"description": "Confirmation message for deleting playlist",
|
||||||
|
"placeholders": {
|
||||||
|
"playlistName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionPlaylistDeleted": "Playlist deleted",
|
||||||
|
"@collectionPlaylistDeleted": {
|
||||||
|
"description": "Snackbar after deleting playlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistRenamed": "Playlist renamed",
|
||||||
|
"@collectionPlaylistRenamed": {
|
||||||
|
"description": "Snackbar after renaming playlist"
|
||||||
|
},
|
||||||
|
"collectionWishlistEmptyTitle": "Wishlist is empty",
|
||||||
|
"@collectionWishlistEmptyTitle": {
|
||||||
|
"description": "Wishlist empty state title"
|
||||||
|
},
|
||||||
|
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
|
||||||
|
"@collectionWishlistEmptySubtitle": {
|
||||||
|
"description": "Wishlist empty state subtitle"
|
||||||
|
},
|
||||||
|
"collectionLovedEmptyTitle": "Loved folder is empty",
|
||||||
|
"@collectionLovedEmptyTitle": {
|
||||||
|
"description": "Loved empty state title"
|
||||||
|
},
|
||||||
|
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
|
||||||
|
"@collectionLovedEmptySubtitle": {
|
||||||
|
"description": "Loved empty state subtitle"
|
||||||
|
},
|
||||||
|
"collectionPlaylistEmptyTitle": "Playlist is empty",
|
||||||
|
"@collectionPlaylistEmptyTitle": {
|
||||||
|
"description": "Playlist empty state title"
|
||||||
|
},
|
||||||
|
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
|
||||||
|
"@collectionPlaylistEmptySubtitle": {
|
||||||
|
"description": "Playlist empty state subtitle"
|
||||||
|
},
|
||||||
|
"collectionRemoveFromPlaylist": "Remove from playlist",
|
||||||
|
"@collectionRemoveFromPlaylist": {
|
||||||
|
"description": "Tooltip for removing track from playlist"
|
||||||
|
},
|
||||||
|
"collectionRemoveFromFolder": "Remove from folder",
|
||||||
|
"@collectionRemoveFromFolder": {
|
||||||
|
"description": "Tooltip for removing track from wishlist/loved folder"
|
||||||
|
},
|
||||||
|
"collectionRemoved": "\"{trackName}\" removed",
|
||||||
|
"@collectionRemoved": {
|
||||||
|
"description": "Snackbar after removing a track from a collection",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
|
||||||
|
"@collectionAddedToLoved": {
|
||||||
|
"description": "Snackbar after adding track to loved folder",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
|
||||||
|
"@collectionRemovedFromLoved": {
|
||||||
|
"description": "Snackbar after removing track from loved folder",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
|
||||||
|
"@collectionAddedToWishlist": {
|
||||||
|
"description": "Snackbar after adding track to wishlist",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
|
||||||
|
"@collectionRemovedFromWishlist": {
|
||||||
|
"description": "Snackbar after removing track from wishlist",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackOptionAddToLoved": "Add to Loved",
|
||||||
|
"@trackOptionAddToLoved": {
|
||||||
|
"description": "Bottom sheet action label - add track to loved folder"
|
||||||
|
},
|
||||||
|
"trackOptionRemoveFromLoved": "Remove from Loved",
|
||||||
|
"@trackOptionRemoveFromLoved": {
|
||||||
|
"description": "Bottom sheet action label - remove track from loved folder"
|
||||||
|
},
|
||||||
|
"trackOptionAddToWishlist": "Add to Wishlist",
|
||||||
|
"@trackOptionAddToWishlist": {
|
||||||
|
"description": "Bottom sheet action label - add track to wishlist"
|
||||||
|
},
|
||||||
|
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
|
||||||
|
"@trackOptionRemoveFromWishlist": {
|
||||||
|
"description": "Bottom sheet action label - remove track from wishlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistChangeCover": "Change cover image",
|
||||||
|
"@collectionPlaylistChangeCover": {
|
||||||
|
"description": "Bottom sheet action to pick a custom cover image for a playlist"
|
||||||
|
},
|
||||||
|
"collectionPlaylistRemoveCover": "Remove cover image",
|
||||||
|
"@collectionPlaylistRemoveCover": {
|
||||||
|
"description": "Bottom sheet action to remove custom cover image from a playlist"
|
||||||
|
},
|
||||||
|
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
|
||||||
|
"@selectionShareCount": {
|
||||||
|
"description": "Share button text with count in selection mode",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionShareNoFiles": "No shareable files found",
|
||||||
|
"@selectionShareNoFiles": {
|
||||||
|
"description": "Snackbar when no selected files exist on disk"
|
||||||
|
},
|
||||||
|
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
|
||||||
|
"@selectionConvertCount": {
|
||||||
|
"description": "Convert button text with count in selection mode",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionConvertNoConvertible": "No convertible tracks selected",
|
||||||
|
"@selectionConvertNoConvertible": {
|
||||||
|
"description": "Snackbar when no selected tracks support conversion"
|
||||||
|
},
|
||||||
|
"selectionBatchConvertConfirmTitle": "Batch Convert",
|
||||||
|
"@selectionBatchConvertConfirmTitle": {
|
||||||
|
"description": "Confirmation dialog title for batch conversion"
|
||||||
|
},
|
||||||
|
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
|
||||||
|
"@selectionBatchConvertConfirmMessage": {
|
||||||
|
"description": "Confirmation dialog message for batch conversion",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"bitrate": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionBatchConvertProgress": "Converting {current} of {total}...",
|
||||||
|
"@selectionBatchConvertProgress": {
|
||||||
|
"description": "Snackbar during batch conversion progress",
|
||||||
|
"placeholders": {
|
||||||
|
"current": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
|
||||||
|
"@selectionBatchConvertSuccess": {
|
||||||
|
"description": "Snackbar after batch conversion completes",
|
||||||
|
"placeholders": {
|
||||||
|
"success": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"downloadedAlbumDownloadedCount": "{count} baixado(s)",
|
"downloadedAlbumDownloadedCount": "{count} baixado(s)",
|
||||||
"@downloadedAlbumDownloadedCount": {
|
"@downloadedAlbumDownloadedCount": {
|
||||||
"description": "Downloaded tracks count badge",
|
"description": "Downloaded tracks count badge",
|
||||||
@@ -2800,4 +3194,4 @@
|
|||||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||||
"description": "Subtitle when Track Artist is used for folder naming"
|
"description": "Subtitle when Track Artist is used for folder naming"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user