mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-05 04:08:02 +02:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ac4f555f6 | |||
| 098544393e |
@@ -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://spotiflac.zarz.moe/docs
|
url: https://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.8"
|
go-version: "1.25.7"
|
||||||
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 r29 (supports 16KB page size for Android 15+)
|
# Install NDK r27d LTS (required for 16KB page size support on 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;29.0.14206865" "platforms;android-36" "build-tools;36.0.0"
|
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;27.3.13750724" "platforms;android-36" "build-tools;36.0.0"
|
||||||
|
|
||||||
# Set NDK path
|
# Set NDK path
|
||||||
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> $GITHUB_ENV
|
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/27.3.13750724" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Install gomobile
|
- name: Install gomobile
|
||||||
run: |
|
run: |
|
||||||
@@ -174,7 +174,7 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "1.25.8"
|
go-version: "1.25.7"
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
# Cache CocoaPods
|
# Cache CocoaPods
|
||||||
@@ -344,18 +344,9 @@ jobs:
|
|||||||
VERSION=${{ needs.get-version.outputs.version }}
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
REPO_OWNER="${{ github.repository_owner }}"
|
REPO_OWNER="${{ github.repository_owner }}"
|
||||||
REPO_NAME="${{ github.event.repository.name }}"
|
REPO_NAME="${{ github.event.repository.name }}"
|
||||||
CURRENT_REF=$(git rev-list -n 1 "$VERSION" 2>/dev/null || git rev-parse HEAD)
|
|
||||||
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${CURRENT_REF}^" 2>/dev/null || true)
|
|
||||||
|
|
||||||
# Start with git-cliff changelog, but replace its compare footer with a
|
# Start with git-cliff changelog
|
||||||
# deterministic previous-tag lookup from git.
|
cp /tmp/changelog.txt /tmp/release_body.txt
|
||||||
sed '/^## [0-9][0-9.[:alpha:]-]*$/d; /^\*\*Full Changelog\*\*/d' /tmp/changelog.txt > /tmp/release_body.txt
|
|
||||||
|
|
||||||
if [ -n "$PREVIOUS_TAG" ]; then
|
|
||||||
printf '\n**Full Changelog**: [%s...%s](https://github.com/%s/%s/compare/%s...%s)\n' \
|
|
||||||
"$PREVIOUS_TAG" "$VERSION" "$REPO_OWNER" "$REPO_NAME" "$PREVIOUS_TAG" "$VERSION" \
|
|
||||||
>> /tmp/release_body.txt
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Append download section
|
# Append download section
|
||||||
cat >> /tmp/release_body.txt << FOOTER
|
cat >> /tmp/release_body.txt << FOOTER
|
||||||
@@ -393,63 +384,6 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
update-altstore:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [get-version, build-ios, create-release]
|
|
||||||
if: ${{ needs.get-version.outputs.is_prerelease != 'true' }}
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout main branch
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
with:
|
|
||||||
ref: main
|
|
||||||
|
|
||||||
- name: Download iOS IPA
|
|
||||||
uses: actions/download-artifact@v7
|
|
||||||
with:
|
|
||||||
name: ios-ipa
|
|
||||||
path: ./release
|
|
||||||
|
|
||||||
- name: Update apps.json
|
|
||||||
run: |
|
|
||||||
VERSION="${{ needs.get-version.outputs.version }}"
|
|
||||||
VERSION_NUM="${VERSION#v}"
|
|
||||||
DATE=$(date -u +%Y-%m-%d)
|
|
||||||
IPA_FILE=$(find ./release -name "*ios*.ipa" | head -1)
|
|
||||||
|
|
||||||
if [ -z "$IPA_FILE" ]; then
|
|
||||||
echo "WARNING: IPA file not found, skipping apps.json update"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
IPA_SIZE=$(stat -c%s "$IPA_FILE" 2>/dev/null || stat -f%z "$IPA_FILE")
|
|
||||||
|
|
||||||
if [ ! -f apps.json ]; then
|
|
||||||
echo "WARNING: apps.json not found on main, skipping"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
jq --arg ver "$VERSION_NUM" \
|
|
||||||
--arg date "$DATE" \
|
|
||||||
--arg url "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa" \
|
|
||||||
--argjson size "$IPA_SIZE" \
|
|
||||||
'.apps[0].version = $ver | .apps[0].versionDate = $date | .apps[0].downloadURL = $url | .apps[0].size = $size' \
|
|
||||||
apps.json > apps.json.tmp && mv apps.json.tmp apps.json
|
|
||||||
|
|
||||||
echo "Updated apps.json:"
|
|
||||||
cat apps.json
|
|
||||||
|
|
||||||
- name: Commit and push
|
|
||||||
run: |
|
|
||||||
VERSION="${{ needs.get-version.outputs.version }}"
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git add apps.json
|
|
||||||
git diff --cached --quiet && echo "No changes to commit" || \
|
|
||||||
(git commit -m "chore: update AltStore source to ${VERSION}" && git push)
|
|
||||||
|
|
||||||
notify-telegram:
|
notify-telegram:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [get-version, create-release]
|
needs: [get-version, create-release]
|
||||||
@@ -490,10 +424,7 @@ jobs:
|
|||||||
else
|
else
|
||||||
# Convert Markdown to Telegram HTML
|
# Convert Markdown to Telegram HTML
|
||||||
CHANGELOG=$(cat /tmp/cliff_tg.txt | \
|
CHANGELOG=$(cat /tmp/cliff_tg.txt | \
|
||||||
sed '/^## [0-9][0-9.[:alpha:]-]*$/d' | \
|
|
||||||
sed '/^\*\*Full Changelog\*\*/d' | \
|
sed '/^\*\*Full Changelog\*\*/d' | \
|
||||||
sed 's/ by \[@[^]]*\](https:\/\/github\.com\/[^)]*)//g' | \
|
|
||||||
sed 's/ by @[A-Za-z0-9_-]\+//g' | \
|
|
||||||
sed 's/\[#\([0-9]*\)\]([^)]*)/#\1/g' | \
|
sed 's/\[#\([0-9]*\)\]([^)]*)/#\1/g' | \
|
||||||
sed 's/\[@\([^]]*\)\]([^)]*)/@\1/g' | \
|
sed 's/\[@\([^]]*\)\]([^)]*)/@\1/g' | \
|
||||||
sed 's/&/\&/g' | \
|
sed 's/&/\&/g' | \
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ AGENTS.md
|
|||||||
|
|
||||||
# Temp/misc
|
# Temp/misc
|
||||||
nul
|
nul
|
||||||
network_requests.txt
|
|
||||||
|
|
||||||
# Log files
|
# Log files
|
||||||
*.log
|
*.log
|
||||||
@@ -77,7 +76,3 @@ flutter_*.log
|
|||||||
# Development tools
|
# Development tools
|
||||||
tool/
|
tool/
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.playwright-mcp/
|
|
||||||
|
|
||||||
# FVM Version Cache
|
|
||||||
.fvm/
|
|
||||||
|
|||||||
+2
-2
@@ -334,7 +334,7 @@ Thank you for your understanding and continued support. This decision was made t
|
|||||||
- Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service
|
- Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service
|
||||||
- New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)`
|
- New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)`
|
||||||
- SpotFetch metadata fallback integration for Spotify-blocked regions
|
- SpotFetch metadata fallback integration for Spotify-blocked regions
|
||||||
- New backend client for `sp.afkarxyz.qzz.io/api`
|
- New backend client for `spotify.afkarxyz.fun/api`
|
||||||
- Automatic fallback in Spotify metadata fetch path when primary source fails
|
- Automatic fallback in Spotify metadata fetch path when primary source fails
|
||||||
- Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC
|
- Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC
|
||||||
- Includes heuristic detection of lyrics stored in Comment fields
|
- Includes heuristic detection of lyrics stored in Comment fields
|
||||||
@@ -349,7 +349,7 @@ Thank you for your understanding and continued support. This decision was made t
|
|||||||
- Legacy Dart bridge methods (`downloadTrack`, `downloadWithFallback`, `downloadWithExtensions`, `downloadFromYouTube`) are now thin wrappers and marked `@Deprecated`
|
- Legacy Dart bridge methods (`downloadTrack`, `downloadWithFallback`, `downloadWithExtensions`, `downloadFromYouTube`) are now thin wrappers and marked `@Deprecated`
|
||||||
- Qobuz downloader updated to latest Jumo API contract (`/get` endpoint, required headers)
|
- Qobuz downloader updated to latest Jumo API contract (`/get` endpoint, required headers)
|
||||||
- Amazon download flow now returns `decryption_key` from Go and performs decryption in Flutter (local file + SAF paths)
|
- Amazon download flow now returns `decryption_key` from Go and performs decryption in Flutter (local file + SAF paths)
|
||||||
- Amazon now uses the new `amzn.afkarxyz.qzz.io` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
|
- Amazon now uses the new `amazon.afkarxyz.fun` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
|
||||||
- Amazon ASIN extraction rewritten with robust URL/query-param parsing and regex fallback
|
- Amazon ASIN extraction rewritten with robust URL/query-param parsing and regex fallback
|
||||||
- Amazon provider re-enabled in download service picker and download settings (alongside Tidal, Qobuz, and YouTube picker flow)
|
- Amazon provider re-enabled in download service picker and download settings (alongside Tidal, Qobuz, and YouTube picker flow)
|
||||||
- Track Metadata cover UI now refreshes from the embedded file after Edit Metadata/Re-enrich, so the displayed art matches actual file tags
|
- Track Metadata cover UI now refreshes from the embedded file after Edit Metadata/Re-enrich, so the displayed art matches actual file tags
|
||||||
|
|||||||
+3
-17
@@ -86,31 +86,17 @@ Translation files are located in `lib/l10n/arb/`.
|
|||||||
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
|
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Use FVM (Flutter Version: 3.38.1)**
|
3. **Install dependencies**
|
||||||
```bash
|
|
||||||
fvm use
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Install dependencies**
|
|
||||||
```bash
|
```bash
|
||||||
flutter pub get
|
flutter pub get
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **Generate code** (for Riverpod, JSON serialization, etc.)
|
4. **Generate code** (for Riverpod, JSON serialization, etc.)
|
||||||
```bash
|
```bash
|
||||||
dart run build_runner build --delete-conflicting-outputs
|
dart run build_runner build --delete-conflicting-outputs
|
||||||
```
|
```
|
||||||
|
|
||||||
6. **Set up Go environment (Go Version: 1.25.7)**
|
5. **Run the app**
|
||||||
```bash
|
|
||||||
cd go_backend
|
|
||||||
mkdir -p ../android/app/libs
|
|
||||||
gomobile init
|
|
||||||
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
|
|
||||||
cd ..
|
|
||||||
```
|
|
||||||
|
|
||||||
7. **Run the app**
|
|
||||||
```bash
|
```bash
|
||||||
flutter run
|
flutter run
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -14,17 +14,6 @@
|
|||||||
|
|
||||||
</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">
|
||||||
@@ -34,154 +23,68 @@
|
|||||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
<div align="center">
|
||||||
|
|
||||||
|
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
|
[](https://www.virustotal.com/gui/file/0a2bd2a033551983fc9fcd83f82fd912c83914fd1094cd8d1c7c6a68eb23233f)
|
||||||
|
[](https://crowdin.com/project/spotiflac-mobile)
|
||||||
|
|
||||||
|
[](https://t.me/spotiflac)
|
||||||
|
[](https://t.me/spotiflac_chat)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
## Extensions
|
## Extensions
|
||||||
|
|
||||||
Extensions 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.
|
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.
|
||||||
|
|
||||||
### Installing Extensions
|
### Installing Extensions
|
||||||
|
1. Go to **Store** tab in the app
|
||||||
1. Open the **Store** tab in the app
|
2. Browse and install extensions with one tap
|
||||||
2. On first launch, enter an **Extension Repository URL** when prompted
|
3. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
||||||
3. Browse and install extensions with one tap
|
4. Configure extension settings if needed
|
||||||
4. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
5. Set provider priority in **Settings > Extensions > Provider Priority**
|
||||||
5. Configure extension settings if needed
|
|
||||||
6. Set provider priority under **Settings > Extensions > Provider Priority**
|
|
||||||
|
|
||||||
### Developing Extensions
|
### Developing Extensions
|
||||||
|
Want to create your own extension? Check out the [Extension Development Guide](https://zarz.moe/docs) for complete documentation.
|
||||||
|
|
||||||
> [!NOTE]
|
## Other project
|
||||||
> 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 available for Windows, macOS & Linux.
|
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music 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
|
||||||
|
|
||||||
<details>
|
**Q: Why is my download failing with "Song not found"?**
|
||||||
<summary><b>Why does the Store tab ask me to enter a URL?</b></summary>
|
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.
|
||||||
<br>
|
|
||||||
|
|
||||||
Starting from version 3.8.0, SpotiFLAC uses a decentralized extension repository system extensions are hosted on GitHub repositories rather than a built-in server, so anyone can create and host their own. Enter a repository URL in the Store tab to browse and install extensions.
|
**Q: Why are some tracks downloading in lower quality?**
|
||||||
|
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.
|
||||||
|
|
||||||
</details>
|
**Q: Can I download playlists?**
|
||||||
|
A: Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
|
||||||
|
|
||||||
<details>
|
**Q: Why do I need to grant storage permission?**
|
||||||
<summary><b>Why is my download failing with "Song not found"?</b></summary>
|
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.
|
||||||
<br>
|
|
||||||
|
|
||||||
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.
|
**Q: Is this app safe?**
|
||||||
|
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).
|
||||||
|
|
||||||
</details>
|
**Q: Why is download not working in my country?**
|
||||||
|
A: Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
Quality depends on what's available from the streaming service and its extensions. Built-in providers:
|
### Want to support SpotiFLAC-Mobile?
|
||||||
- **Tidal** up to 24-bit/192kHz
|
|
||||||
- **Qobuz** up to 24-bit/192kHz
|
|
||||||
- **Deezer** up to 16-bit/44.1kHz
|
|
||||||
|
|
||||||
</details>
|
_If this software is useful and brings you value, consider supporting the project. Your support helps keep development going._
|
||||||
|
|
||||||
<details>
|
[](https://ko-fi.com/zarzet)
|
||||||
<summary><b>Can I download playlists?</b></summary>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Why do I need to grant storage permission?</b></summary>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant **All files access** under **Settings > Apps > SpotiFLAC > Permissions**.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Is this app safe?</b></summary>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
Yes SpotiFLAC is open source and you can verify the code yourself. Each release is also scanned with VirusTotal (see badge above).
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Why is downloading not working in my country?</b></summary>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Can I add SpotiFLAC to AltStore or SideStore?</b></summary>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
Yes! Add the official source to receive updates directly within the app. Copy this link:
|
|
||||||
|
|
||||||
```
|
|
||||||
https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/refs/heads/main/apps.json
|
|
||||||
```
|
|
||||||
|
|
||||||
In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> If SpotiFLAC is useful to you, consider supporting development:
|
|
||||||
>
|
|
||||||
> [](https://ko-fi.com/zarzet)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Contributors
|
|
||||||
|
|
||||||
Thanks to everyone who has contributed to SpotiFLAC Mobile!
|
|
||||||
|
|
||||||
<a href="https://github.com/zarzet/SpotiFLAC-Mobile/graphs/contributors">
|
|
||||||
<img src="https://contrib.rocks/image?repo=zarzet/SpotiFLAC-Mobile" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
We also appreciate everyone who helped with [translations on Crowdin](https://crowdin.com/project/spotiflac-mobile), reported bugs, suggested features, and spread the word.
|
|
||||||
|
|
||||||
Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md) to get started!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Credits
|
## API Credits
|
||||||
|
|
||||||
| | | | | |
|
[hifi-api](https://github.com/binimum/hifi-api) · [music.binimum.org](https://music.binimum.org) · [qqdl.site](https://qqdl.site) · [squid.wtf](https://squid.wtf) · [spotisaver.net](https://spotisaver.net) · [dabmusic.xyz](https://dabmusic.xyz) · [AfkarXYZ](https://github.com/afkarxyz) · [LRCLib](https://lrclib.net) · [Paxsenix](https://lyrics.paxsenix.org) · [Cobalt](https://cobalt.tools) · [qwkuns.me](https://qwkuns.me) · [SpotubeDL](https://spotubedl.com) · [Song.link](https://song.link) · [IDHS](https://github.com/sjdonado/idonthavespotify)
|
||||||
|---|---|---|---|---|
|
|
||||||
| [hifi-api](https://github.com/binimum/hifi-api) | [music.binimum.org](https://music.binimum.org) | [qqdl.site](https://qqdl.site) | [squid.wtf](https://squid.wtf) | [spotisaver.net](https://spotisaver.net) |
|
|
||||||
| [dabmusic.xyz](https://dabmusic.xyz) | [AfkarXYZ](https://github.com/afkarxyz) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) |
|
|
||||||
| [qwkuns.me](https://qwkuns.me) | [SpotubeDL](https://spotubedl.com) | [Song.link](https://song.link) | [IDHS](https://github.com/sjdonado/idonthavespotify) | |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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,19 +9,6 @@
|
|||||||
# 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`
|
||||||
@@ -36,13 +23,6 @@ linter:
|
|||||||
rules:
|
rules:
|
||||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||||
avoid_dynamic_calls: true
|
|
||||||
cancel_subscriptions: true
|
|
||||||
close_sinks: true
|
|
||||||
|
|
||||||
custom_lint:
|
|
||||||
rules:
|
|
||||||
- avoid_public_notifier_properties
|
|
||||||
|
|
||||||
# Additional information about this file can be found at
|
# Additional information about this file can be found at
|
||||||
# https://dart.dev/guides/language/analysis-options
|
# https://dart.dev/guides/language/analysis-options
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ class DownloadService : Service() {
|
|||||||
updateNotification(progress, total)
|
updateNotification(progress, total)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return START_NOT_STICKY
|
return START_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
@@ -115,8 +115,10 @@ class DownloadService : Service() {
|
|||||||
* We must call stopSelf() within a few seconds to avoid a crash.
|
* We must call stopSelf() within a few seconds to avoid a crash.
|
||||||
*/
|
*/
|
||||||
override fun onTimeout(startId: Int, fgsType: Int) {
|
override fun onTimeout(startId: Int, fgsType: Int) {
|
||||||
|
// Log the timeout for debugging
|
||||||
android.util.Log.w("DownloadService", "Foreground service timeout reached (6 hours limit). Stopping service.")
|
android.util.Log.w("DownloadService", "Foreground service timeout reached (6 hours limit). Stopping service.")
|
||||||
|
|
||||||
|
// Gracefully stop the service
|
||||||
stopForegroundService()
|
stopForegroundService()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,13 +139,14 @@ 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)
|
acquire(60 * 60 * 1000L) // 1 hour max
|
||||||
}
|
}
|
||||||
|
|
||||||
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.4.1-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.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.3.20" apply false
|
id("org.jetbrains.kotlin.android") version "2.2.21" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "SpotiFLAC Source",
|
|
||||||
"identifier": "com.zarzet.spotiflac.source",
|
|
||||||
"subtitle": "FLAC Downloader for iOS",
|
|
||||||
"apps": [
|
|
||||||
{
|
|
||||||
"name": "SpotiFLAC",
|
|
||||||
"bundleIdentifier": "com.zarzet.spotiflac",
|
|
||||||
"developerName": "zarzet",
|
|
||||||
"version": "3.9.0",
|
|
||||||
"versionDate": "2026-03-25",
|
|
||||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.9.0/SpotiFLAC-v3.9.0-ios-unsigned.ipa",
|
|
||||||
"localizedDescription": "Mobile version of SpotiFLAC written in Flutter. Download Tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
|
|
||||||
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
|
||||||
"size": 34477323
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
+3
-1
@@ -22,7 +22,7 @@ body = """
|
|||||||
{% if commit.github.pr_number %} \
|
{% if commit.github.pr_number %} \
|
||||||
([#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}))\
|
([#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}))\
|
||||||
{% endif %}\
|
{% endif %}\
|
||||||
{%- if commit.github.username and commit.github.username != "zarzet" %} by [@{{ commit.github.username }}](https://github.com/{{ commit.github.username }}){%- endif %}
|
{%- if commit.github.username %} by [@{{ commit.github.username }}](https://github.com/{{ commit.github.username }}){%- endif %}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
@@ -58,6 +58,8 @@ split_commits = false
|
|||||||
|
|
||||||
# Regex for preprocessing the commit messages
|
# Regex for preprocessing the commit messages
|
||||||
commit_preprocessors = [
|
commit_preprocessors = [
|
||||||
|
# Remove PR number from message (we add it back via GitHub integration)
|
||||||
|
{ pattern = '\(#(\d+)\)', replace = '' },
|
||||||
# Strip conventional commit prefix for cleaner messages
|
# Strip conventional commit prefix for cleaner messages
|
||||||
# (group header already shows the type)
|
# (group header already shows the type)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,609 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/binary"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"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":
|
|
||||||
// APE track format can be "3" or "3/12"
|
|
||||||
trackNum, _ := strconv.Atoi(strings.Split(value, "/")[0])
|
|
||||||
metadata.TrackNumber = trackNum
|
|
||||||
case "DISC", "DISCNUMBER":
|
|
||||||
discNum, _ := strconv.Atoi(strings.Split(value, "/")[0])
|
|
||||||
metadata.DiscNumber = discNum
|
|
||||||
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", strconv.Itoa(metadata.TrackNumber))
|
|
||||||
}
|
|
||||||
if metadata.DiscNumber > 0 {
|
|
||||||
addItem("Disc", strconv.Itoa(metadata.DiscNumber))
|
|
||||||
}
|
|
||||||
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": "YEAR",
|
|
||||||
"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. "Year" 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["track_number"]; 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
|
|
||||||
}
|
|
||||||
@@ -28,11 +28,6 @@ type AudioMetadata struct {
|
|||||||
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 {
|
||||||
@@ -316,17 +311,6 @@ 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
|
||||||
@@ -354,6 +338,7 @@ 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])
|
||||||
}
|
}
|
||||||
@@ -388,23 +373,27 @@ 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:
|
case 1, 2: // UTF-16 variants use double-null terminator
|
||||||
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:
|
default: // ISO-8859-1 or UTF-8
|
||||||
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:]
|
||||||
@@ -417,30 +406,33 @@ 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:]
|
rest := data[4:] // skip 3-byte language code
|
||||||
|
|
||||||
var text []byte
|
var text []byte
|
||||||
switch encoding {
|
switch encoding {
|
||||||
case 1, 2:
|
case 1, 2: // UTF-16 variants use double-null terminator
|
||||||
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:
|
default: // ISO-8859-1 or UTF-8
|
||||||
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:]
|
||||||
@@ -459,6 +451,8 @@ 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 "", ""
|
||||||
@@ -469,7 +463,7 @@ func extractUserTextFrame(data []byte) (string, string) {
|
|||||||
|
|
||||||
var descRaw, valueRaw []byte
|
var descRaw, valueRaw []byte
|
||||||
switch encoding {
|
switch encoding {
|
||||||
case 1, 2:
|
case 1, 2: // UTF-16 variants
|
||||||
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]
|
||||||
@@ -477,7 +471,7 @@ func extractUserTextFrame(data []byte) (string, string) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default:
|
default: // ISO-8859-1 or UTF-8
|
||||||
idx := bytes.IndexByte(payload, 0)
|
idx := bytes.IndexByte(payload, 0)
|
||||||
if idx >= 0 {
|
if idx >= 0 {
|
||||||
descRaw = payload[:idx]
|
descRaw = payload[:idx]
|
||||||
@@ -504,13 +498,7 @@ 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
|
case "lyrics", "lyric", "unsyncedlyrics", "unsynced lyrics", "lrc":
|
||||||
"lyrics",
|
|
||||||
"lyric",
|
|
||||||
"unsyncedlyrics",
|
|
||||||
"unsynced lyrics",
|
|
||||||
"uslt",
|
|
||||||
"lrc":
|
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -671,6 +659,7 @@ 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++ {
|
||||||
@@ -697,6 +686,8 @@ 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},
|
||||||
@@ -707,12 +698,15 @@ 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 {
|
||||||
@@ -720,11 +714,14 @@ 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
|
||||||
@@ -740,6 +737,7 @@ 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)
|
||||||
@@ -749,6 +747,7 @@ 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" {
|
||||||
@@ -767,6 +766,7 @@ 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,9 +778,11 @@ 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 {
|
||||||
@@ -788,6 +790,7 @@ 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))
|
||||||
@@ -971,9 +974,8 @@ 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
|
||||||
@@ -1002,6 +1004,8 @@ 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
|
||||||
@@ -1024,9 +1028,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
|||||||
case "TITLE":
|
case "TITLE":
|
||||||
metadata.Title = value
|
metadata.Title = value
|
||||||
case "ARTIST":
|
case "ARTIST":
|
||||||
artistValues = append(artistValues, value)
|
metadata.Artist = value
|
||||||
case "ALBUMARTIST", "ALBUM_ARTIST", "ALBUM ARTIST":
|
case "ALBUMARTIST", "ALBUM_ARTIST", "ALBUM ARTIST":
|
||||||
albumArtistValues = append(albumArtistValues, value)
|
metadata.AlbumArtist = value
|
||||||
case "ALBUM":
|
case "ALBUM":
|
||||||
metadata.Album = value
|
metadata.Album = value
|
||||||
case "DATE", "YEAR":
|
case "DATE", "YEAR":
|
||||||
@@ -1054,23 +1058,8 @@ 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) {
|
||||||
@@ -1119,6 +1108,7 @@ 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
|
||||||
@@ -1128,6 +1118,7 @@ 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
|
||||||
@@ -1145,9 +1136,11 @@ 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
|
||||||
@@ -1159,7 +1152,10 @@ 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
|
||||||
@@ -1183,6 +1179,7 @@ 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 {
|
||||||
@@ -1200,6 +1197,7 @@ 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
|
||||||
@@ -1259,6 +1257,7 @@ 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 {
|
||||||
@@ -1289,6 +1288,7 @@ 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)
|
||||||
@@ -1566,14 +1566,7 @@ func base64StdDecode(dst, src []byte) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func extractAnyCoverArt(filePath string) ([]byte, string, error) {
|
func extractAnyCoverArt(filePath string) ([]byte, string, error) {
|
||||||
return extractAnyCoverArtWithHint(filePath, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, string, error) {
|
|
||||||
ext := strings.ToLower(filepath.Ext(filePath))
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
if ext == "" {
|
|
||||||
ext = strings.ToLower(filepath.Ext(displayNameHint))
|
|
||||||
}
|
|
||||||
|
|
||||||
switch ext {
|
switch ext {
|
||||||
case ".flac":
|
case ".flac":
|
||||||
@@ -1594,19 +1587,7 @@ func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, strin
|
|||||||
return extractOggCoverArt(filePath)
|
return extractOggCoverArt(filePath)
|
||||||
|
|
||||||
case ".m4a":
|
case ".m4a":
|
||||||
data, err := extractCoverFromM4A(filePath)
|
return nil, "", fmt.Errorf("M4A cover extraction not yet supported")
|
||||||
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)
|
||||||
@@ -1614,28 +1595,10 @@ func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SaveCoverToCache(filePath, cacheDir string) (string, error) {
|
func SaveCoverToCache(filePath, cacheDir string) (string, error) {
|
||||||
return SaveCoverToCacheWithHintAndKey(filePath, "", cacheDir, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
||||||
@@ -1648,7 +1611,7 @@ func SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, coverCa
|
|||||||
return pngPath, nil
|
return pngPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
imageData, mimeType, err := extractAnyCoverArtWithHint(filePath, displayNameHint)
|
imageData, mimeType, err := extractAnyCoverArt(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+5
-34
@@ -17,8 +17,6 @@ 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)
|
||||||
@@ -42,6 +40,7 @@ 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)")
|
||||||
}
|
}
|
||||||
@@ -87,22 +86,16 @@ 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,6 +104,7 @@ func upgradeDeezerCover(coverURL string) string {
|
|||||||
return coverURL
|
return coverURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Replace any size pattern with 1800x1800
|
||||||
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
|
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
|
||||||
if upgraded != coverURL {
|
if upgraded != coverURL {
|
||||||
GoLog("[Cover] Deezer: upgraded to 1800x1800")
|
GoLog("[Cover] Deezer: upgraded to 1800x1800")
|
||||||
@@ -118,35 +112,12 @@ 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 {
|
||||||
|
|||||||
+75
-55
@@ -11,7 +11,9 @@ 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"`
|
||||||
@@ -23,16 +25,19 @@ 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"`
|
||||||
@@ -43,6 +48,7 @@ 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"`
|
||||||
@@ -58,6 +64,7 @@ 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 {
|
||||||
@@ -75,6 +82,7 @@ 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)
|
||||||
@@ -82,6 +90,7 @@ 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 {
|
||||||
@@ -105,6 +114,7 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PERFORMER
|
||||||
if strings.HasPrefix(upper, "PERFORMER ") {
|
if strings.HasPrefix(upper, "PERFORMER ") {
|
||||||
value := unquoteCue(line[len("PERFORMER "):])
|
value := unquoteCue(line[len("PERFORMER "):])
|
||||||
if currentTrack != nil {
|
if currentTrack != nil {
|
||||||
@@ -115,6 +125,7 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TITLE
|
||||||
if strings.HasPrefix(upper, "TITLE ") {
|
if strings.HasPrefix(upper, "TITLE ") {
|
||||||
value := unquoteCue(line[len("TITLE "):])
|
value := unquoteCue(line[len("TITLE "):])
|
||||||
if currentTrack != nil {
|
if currentTrack != nil {
|
||||||
@@ -125,15 +136,21 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FILE
|
||||||
if strings.HasPrefix(upper, "FILE ") {
|
if strings.HasPrefix(upper, "FILE ") {
|
||||||
rest := line[len("FILE "):]
|
rest := line[len("FILE "):]
|
||||||
|
// Extract filename and type
|
||||||
|
// Format: FILE "filename.flac" WAVE
|
||||||
|
// or: FILE filename.flac WAVE
|
||||||
fname, ftype := parseCueFileLine(rest)
|
fname, ftype := parseCueFileLine(rest)
|
||||||
sheet.FileName = fname
|
sheet.FileName = fname
|
||||||
sheet.FileType = ftype
|
sheet.FileType = ftype
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACK
|
||||||
if strings.HasPrefix(upper, "TRACK ") {
|
if strings.HasPrefix(upper, "TRACK ") {
|
||||||
|
// Save previous track
|
||||||
if currentTrack != nil {
|
if currentTrack != nil {
|
||||||
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
||||||
}
|
}
|
||||||
@@ -151,6 +168,7 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// INDEX
|
||||||
if strings.HasPrefix(upper, "INDEX ") && currentTrack != nil {
|
if strings.HasPrefix(upper, "INDEX ") && currentTrack != nil {
|
||||||
parts := strings.Fields(line)
|
parts := strings.Fields(line)
|
||||||
if len(parts) >= 3 {
|
if len(parts) >= 3 {
|
||||||
@@ -166,11 +184,13 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ISRC
|
||||||
if strings.HasPrefix(upper, "ISRC ") && currentTrack != nil {
|
if strings.HasPrefix(upper, "ISRC ") && currentTrack != nil {
|
||||||
currentTrack.ISRC = strings.TrimSpace(line[len("ISRC "):])
|
currentTrack.ISRC = strings.TrimSpace(line[len("ISRC "):])
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SONGWRITER (used as composer sometimes)
|
||||||
if strings.HasPrefix(upper, "SONGWRITER ") {
|
if strings.HasPrefix(upper, "SONGWRITER ") {
|
||||||
value := unquoteCue(line[len("SONGWRITER "):])
|
value := unquoteCue(line[len("SONGWRITER "):])
|
||||||
if currentTrack != nil {
|
if currentTrack != nil {
|
||||||
@@ -182,6 +202,7 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -197,6 +218,7 @@ 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 {
|
||||||
@@ -210,6 +232,7 @@ 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"
|
||||||
@@ -220,6 +243,7 @@ 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 {
|
||||||
@@ -228,12 +252,14 @@ 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]
|
||||||
@@ -243,6 +269,7 @@ 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]
|
||||||
@@ -255,14 +282,18 @@ 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 {
|
||||||
@@ -270,12 +301,14 @@ 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)
|
||||||
@@ -284,6 +317,7 @@ 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{
|
||||||
@@ -308,9 +342,13 @@ 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)
|
||||||
@@ -338,9 +376,11 @@ 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 {
|
||||||
@@ -362,6 +402,11 @@ 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 {
|
||||||
@@ -381,69 +426,41 @@ 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)
|
return scanCueFileForLibraryInternal(cuePath, "", "", 0, scanTime)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
audioPath, err := resolveCueAudioPathForLibrary(cuePath, sheet, "")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return scanCueSheetForLibrary(cuePath, sheet, audioPath, "", 0, "", scanTime)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ScanCueFileForLibraryExt is like ScanCueFileForLibrary but with extra parameters
|
||||||
|
// 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(
|
return scanCueFileForLibraryInternal(cuePath, audioDir, virtualPathPrefix, fileModTime, scanTime)
|
||||||
cuePath,
|
|
||||||
audioDir,
|
|
||||||
virtualPathPrefix,
|
|
||||||
fileModTime,
|
|
||||||
"",
|
|
||||||
scanTime,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ScanCueFileForLibraryExtWithCoverCacheKey(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, coverCacheKey, scanTime string) ([]LibraryScanResult, error) {
|
func scanCueFileForLibraryInternal(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
|
||||||
sheet, err := ParseCueFile(cuePath)
|
sheet, err := ParseCueFile(cuePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
audioPath, err := resolveCueAudioPathForLibrary(cuePath, sheet, audioDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return scanCueSheetForLibrary(
|
|
||||||
cuePath,
|
|
||||||
sheet,
|
|
||||||
audioPath,
|
|
||||||
virtualPathPrefix,
|
|
||||||
fileModTime,
|
|
||||||
coverCacheKey,
|
|
||||||
scanTime,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveCueAudioPathForLibrary(cuePath string, sheet *CueSheet, audioDir string) (string, error) {
|
// Resolve audio file — optionally in an overridden directory
|
||||||
if sheet == nil {
|
|
||||||
return "", fmt.Errorf("cue sheet is nil for %s", cuePath)
|
|
||||||
}
|
|
||||||
resolveBase := cuePath
|
resolveBase := cuePath
|
||||||
if audioDir != "" {
|
if audioDir != "" {
|
||||||
resolveBase = filepath.Join(audioDir, filepath.Base(cuePath))
|
resolveBase = filepath.Join(audioDir, filepath.Base(cuePath))
|
||||||
}
|
}
|
||||||
audioPath := ResolveCueAudioPath(resolveBase, sheet.FileName)
|
audioPath := ResolveCueAudioPath(resolveBase, sheet.FileName)
|
||||||
if audioPath == "" {
|
if audioPath == "" {
|
||||||
return "", fmt.Errorf("audio file not found for cue: %s (referenced: %s)", cuePath, sheet.FileName)
|
return nil, fmt.Errorf("audio file not found for cue: %s (referenced: %s)", cuePath, sheet.FileName)
|
||||||
}
|
|
||||||
return audioPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualPathPrefix string, fileModTime int64, coverCacheKey, scanTime string) ([]LibraryScanResult, error) {
|
|
||||||
if sheet == nil {
|
|
||||||
return nil, fmt.Errorf("cue sheet is nil for %s", cuePath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try to get quality info from the audio file
|
||||||
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))
|
||||||
@@ -465,27 +482,25 @@ 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 := SaveCoverToCacheWithHintAndKey(
|
cp, err := SaveCoverToCache(audioPath, coverCacheDir)
|
||||||
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 {
|
||||||
@@ -513,6 +528,7 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
|
|||||||
album = "Unknown Album"
|
album = "Unknown Album"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate duration for this track
|
||||||
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
|
||||||
@@ -524,8 +540,12 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
|
|||||||
duration = int(totalDurationSec - track.StartTime)
|
duration = int(totalDurationSec - track.StartTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use a unique ID based on pathBase + track number
|
||||||
id := generateLibraryID(fmt.Sprintf("%s#track%d", pathBase, track.Number))
|
id := generateLibraryID(fmt.Sprintf("%s#track%d", pathBase, track.Number))
|
||||||
|
|
||||||
|
// Use a virtual file path that includes the track number to ensure
|
||||||
|
// 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{
|
||||||
|
|||||||
+12
-82
@@ -196,22 +196,15 @@ type deezerAlbumSimple struct {
|
|||||||
RecordType string `json:"record_type"`
|
RecordType string `json:"record_type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// deezerTrackArtistDisplay returns the display artist string for a track,
|
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
||||||
// preferring the Contributors list (comma-joined) when available, falling
|
artistName := track.Artist.Name
|
||||||
// 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
|
||||||
}
|
}
|
||||||
return strings.Join(names, ", ")
|
artistName = 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 == "" {
|
||||||
@@ -263,7 +256,6 @@ type deezerAlbumFull struct {
|
|||||||
NbTracks int `json:"nb_tracks"`
|
NbTracks int `json:"nb_tracks"`
|
||||||
RecordType string `json:"record_type"`
|
RecordType string `json:"record_type"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
Copyright string `json:"copyright"`
|
|
||||||
Genres struct {
|
Genres struct {
|
||||||
Data []deezerGenre `json:"data"`
|
Data []deezerGenre `json:"data"`
|
||||||
} `json:"genres"`
|
} `json:"genres"`
|
||||||
@@ -648,7 +640,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: deezerTrackArtistDisplay(track),
|
Artists: track.Artist.Name,
|
||||||
Name: track.Title,
|
Name: track.Title,
|
||||||
AlbumName: album.Title,
|
AlbumName: album.Title,
|
||||||
AlbumArtist: artistName,
|
AlbumArtist: artistName,
|
||||||
@@ -748,10 +740,6 @@ 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{
|
||||||
@@ -771,63 +759,6 @@ 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 == "" {
|
||||||
@@ -960,7 +891,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: deezerTrackArtistDisplay(track),
|
Artists: track.Artist.Name,
|
||||||
Name: track.Title,
|
Name: track.Title,
|
||||||
AlbumName: track.Album.Title,
|
AlbumName: track.Album.Title,
|
||||||
AlbumArtist: track.Artist.Name,
|
AlbumArtist: track.Artist.Name,
|
||||||
@@ -1153,9 +1084,8 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AlbumExtendedMetadata struct {
|
type AlbumExtendedMetadata struct {
|
||||||
Genre string
|
Genre string
|
||||||
Label string
|
Label string
|
||||||
Copyright string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
|
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
|
||||||
@@ -1186,9 +1116,8 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
|
|||||||
}
|
}
|
||||||
|
|
||||||
result := &AlbumExtendedMetadata{
|
result := &AlbumExtendedMetadata{
|
||||||
Genre: strings.Join(genres, ", "),
|
Genre: strings.Join(genres, ", "),
|
||||||
Label: album.Label,
|
Label: album.Label,
|
||||||
Copyright: album.Copyright,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
@@ -1200,7 +1129,7 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
|
|||||||
c.maybeCleanupCachesLocked(now)
|
c.maybeCleanupCachesLocked(now)
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s, Copyright: %s\n", result.Genre, result.Label, result.Copyright)
|
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -1249,7 +1178,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))
|
delay := deezerRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -1262,6 +1191,7 @@ 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") ||
|
||||||
|
|||||||
+196
-79
@@ -14,7 +14,14 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const deezerMusicDLURL = "https://api.zarz.moe/v1/dzr"
|
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 {
|
type DeezerDownloadResult struct {
|
||||||
FilePath string
|
FilePath string
|
||||||
@@ -30,6 +37,41 @@ type DeezerDownloadResult struct {
|
|||||||
LyricsLRC 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 {
|
func isLikelySpotifyTrackID(value string) bool {
|
||||||
if len(value) != 22 {
|
if len(value) != 22 {
|
||||||
return false
|
return false
|
||||||
@@ -46,6 +88,113 @@ func isLikelySpotifyTrackID(value string) bool {
|
|||||||
return true
|
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) {
|
func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
||||||
deezerID := strings.TrimSpace(req.DeezerID)
|
deezerID := strings.TrimSpace(req.DeezerID)
|
||||||
if deezerID == "" {
|
if deezerID == "" {
|
||||||
@@ -54,46 +203,29 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if deezerID != "" {
|
if deezerID != "" {
|
||||||
trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID)
|
return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil
|
||||||
if err := verifyDeezerTrack(req, deezerID, false); err != nil {
|
|
||||||
GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err)
|
|
||||||
// Don't reject direct IDs from request payload — they're presumably correct.
|
|
||||||
}
|
|
||||||
return trackURL, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try resolving Deezer ID from Spotify ID via SongLink
|
||||||
spotifyID := strings.TrimSpace(req.SpotifyID)
|
spotifyID := strings.TrimSpace(req.SpotifyID)
|
||||||
if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) {
|
if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) {
|
||||||
songlink := NewSongLinkClient()
|
songlink := NewSongLinkClient()
|
||||||
availability, err := songlink.CheckTrackAvailability(spotifyID, "")
|
availability, err := songlink.CheckTrackAvailability(spotifyID, "")
|
||||||
if err == nil && availability.Deezer && availability.DeezerURL != "" {
|
if err == nil && availability.Deezer && availability.DeezerURL != "" {
|
||||||
resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
|
return availability.DeezerURL, nil
|
||||||
if resolvedID != "" {
|
|
||||||
if verifyErr := verifyDeezerTrack(req, resolvedID, true); verifyErr != nil {
|
|
||||||
GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr)
|
|
||||||
// Fall through to ISRC search instead of using wrong track.
|
|
||||||
} else {
|
|
||||||
return availability.DeezerURL, nil
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return availability.DeezerURL, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try resolving from ISRC
|
||||||
isrc := strings.TrimSpace(req.ISRC)
|
isrc := strings.TrimSpace(req.ISRC)
|
||||||
if isrc != "" {
|
if isrc != "" {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
track, err := GetDeezerClient().SearchByISRC(ctx, isrc)
|
track, err := GetDeezerClient().SearchByISRC(ctx, isrc)
|
||||||
if err == nil && track != nil {
|
if err == nil && track != nil {
|
||||||
resolvedID := songLinkExtractDeezerTrackID(track)
|
deezerID = songLinkExtractDeezerTrackID(track)
|
||||||
if resolvedID != "" {
|
if deezerID != "" {
|
||||||
if verifyErr := verifyDeezerTrack(req, resolvedID, false); verifyErr != nil {
|
return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,28 +233,6 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
|||||||
return "", fmt.Errorf("could not resolve Deezer track URL")
|
return "", fmt.Errorf("could not resolve Deezer track URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
func verifyDeezerTrack(req DownloadRequest, deezerID string, skipNameVerification bool) error {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
|
||||||
defer cancel()
|
|
||||||
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
|
|
||||||
if err != nil {
|
|
||||||
return nil // Can't verify — don't block the download.
|
|
||||||
}
|
|
||||||
resolved := resolvedTrackInfo{
|
|
||||||
Title: trackResp.Track.Name,
|
|
||||||
ArtistName: trackResp.Track.Artists,
|
|
||||||
ISRC: trackResp.Track.ISRC,
|
|
||||||
Duration: trackResp.Track.DurationMS / 1000,
|
|
||||||
SkipNameVerification: skipNameVerification,
|
|
||||||
}
|
|
||||||
if !trackMatchesRequest(req, resolved, "Deezer") {
|
|
||||||
return fmt.Errorf("expected '%s - %s', got '%s - %s'",
|
|
||||||
req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
|
|
||||||
}
|
|
||||||
GoLog("[Deezer] Track %s verified: '%s - %s' ✓\n", deezerID, resolved.ArtistName, resolved.Title)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type deezerMusicDLRequest struct {
|
type deezerMusicDLRequest struct {
|
||||||
Platform string `json:"platform"`
|
Platform string `json:"platform"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
@@ -143,6 +253,7 @@ func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, err
|
|||||||
return "", fmt.Errorf("failed to create MusicDL request: %w", err)
|
return "", fmt.Errorf("failed to create MusicDL request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Debug-Key", getQobuzDebugKey())
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
@@ -169,6 +280,7 @@ func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, err
|
|||||||
return "", fmt.Errorf("MusicDL error: %s", errMsg)
|
return "", fmt.Errorf("MusicDL error: %s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try various response fields for download URL
|
||||||
for _, key := range []string{"download_url", "url", "link"} {
|
for _, key := range []string{"download_url", "url", "link"} {
|
||||||
if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" {
|
if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||||
return strings.TrimSpace(urlVal), nil
|
return strings.TrimSpace(urlVal), nil
|
||||||
@@ -282,6 +394,11 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
spotifyURL, err := resolveSpotifyURLForYoinkify(req)
|
||||||
|
if err != nil {
|
||||||
|
return DeezerDownloadResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
"artist": req.ArtistName,
|
"artist": req.ArtistName,
|
||||||
@@ -327,29 +444,30 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
|
|||||||
)
|
)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Try MusicDL first (better quality), fallback to Yoinkify
|
||||||
|
var downloadErr error
|
||||||
deezerTrackURL, deezerURLErr := resolveDeezerTrackURL(req)
|
deezerTrackURL, deezerURLErr := resolveDeezerTrackURL(req)
|
||||||
if deezerURLErr != nil {
|
if deezerURLErr == nil {
|
||||||
return DeezerDownloadResult{}, fmt.Errorf(
|
GoLog("[Deezer] Trying MusicDL for: %s\n", deezerTrackURL)
|
||||||
"deezer download failed: could not resolve Deezer URL: %w",
|
downloadErr = deezerClient.DownloadFromMusicDL(deezerTrackURL, outputPath, req.OutputFD, req.ItemID)
|
||||||
deezerURLErr,
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
GoLog("[Deezer] Trying MusicDL for: %s\n", deezerTrackURL)
|
if downloadErr != nil || deezerURLErr != nil {
|
||||||
downloadErr := deezerClient.DownloadFromMusicDL(
|
downloadErr = deezerClient.DownloadFromYoinkify(spotifyURL, outputPath, req.OutputFD, req.ItemID)
|
||||||
deezerTrackURL,
|
if downloadErr != nil {
|
||||||
outputPath,
|
if errors.Is(downloadErr, ErrDownloadCancelled) {
|
||||||
req.OutputFD,
|
return DeezerDownloadResult{}, ErrDownloadCancelled
|
||||||
req.ItemID,
|
}
|
||||||
)
|
return DeezerDownloadResult{}, fmt.Errorf("deezer download failed (MusicDL + Yoinkify): %w", downloadErr)
|
||||||
if downloadErr != nil {
|
|
||||||
if errors.Is(downloadErr, ErrDownloadCancelled) {
|
|
||||||
return DeezerDownloadResult{}, ErrDownloadCancelled
|
|
||||||
}
|
}
|
||||||
return DeezerDownloadResult{}, fmt.Errorf(
|
|
||||||
"deezer download failed via MusicDL: %w",
|
|
||||||
downloadErr,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<-parallelDone
|
<-parallelDone
|
||||||
@@ -360,19 +478,18 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, 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,
|
||||||
ArtistTagMode: req.ArtistTagMode,
|
Date: req.ReleaseDate,
|
||||||
Date: req.ReleaseDate,
|
TrackNumber: req.TrackNumber,
|
||||||
TrackNumber: req.TrackNumber,
|
TotalTracks: req.TotalTracks,
|
||||||
TotalTracks: req.TotalTracks,
|
DiscNumber: req.DiscNumber,
|
||||||
DiscNumber: req.DiscNumber,
|
ISRC: req.ISRC,
|
||||||
ISRC: req.ISRC,
|
Genre: req.Genre,
|
||||||
Genre: req.Genre,
|
Label: req.Label,
|
||||||
Label: req.Label,
|
Copyright: req.Copyright,
|
||||||
Copyright: req.Copyright,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var coverData []byte
|
var coverData []byte
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ 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()
|
||||||
@@ -33,11 +34,14 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
|
|||||||
return idx
|
return idx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Slow path: need to build index
|
||||||
|
// Use per-directory mutex to prevent multiple goroutines from building simultaneously
|
||||||
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()
|
||||||
|
|||||||
+432
-1052
File diff suppressed because it is too large
Load Diff
@@ -1,59 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestBuildDeezerExtendedMetadataResultHandlesNil(t *testing.T) {
|
|
||||||
result := buildDeezerExtendedMetadataResult(nil)
|
|
||||||
|
|
||||||
if result["genre"] != "" {
|
|
||||||
t.Fatalf("expected empty genre, got %q", result["genre"])
|
|
||||||
}
|
|
||||||
if result["label"] != "" {
|
|
||||||
t.Fatalf("expected empty label, got %q", result["label"])
|
|
||||||
}
|
|
||||||
if result["copyright"] != "" {
|
|
||||||
t.Fatalf("expected empty copyright, got %q", result["copyright"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildDeezerExtendedMetadataResultIncludesCopyright(t *testing.T) {
|
|
||||||
result := buildDeezerExtendedMetadataResult(&AlbumExtendedMetadata{
|
|
||||||
Genre: "Rock",
|
|
||||||
Label: "EMI",
|
|
||||||
Copyright: "(C) Queen",
|
|
||||||
})
|
|
||||||
|
|
||||||
if result["genre"] != "Rock" {
|
|
||||||
t.Fatalf("unexpected genre: %q", result["genre"])
|
|
||||||
}
|
|
||||||
if result["label"] != "EMI" {
|
|
||||||
t.Fatalf("unexpected label: %q", result["label"])
|
|
||||||
}
|
|
||||||
if result["copyright"] != "(C) Queen" {
|
|
||||||
t.Fatalf("unexpected copyright: %q", result["copyright"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildDeezerISRCSearchResultAddsCompatibilityIDs(t *testing.T) {
|
|
||||||
result := buildDeezerISRCSearchResult(&TrackMetadata{
|
|
||||||
SpotifyID: "deezer:3135556",
|
|
||||||
Name: "Love Of My Life",
|
|
||||||
Artists: "Queen",
|
|
||||||
AlbumName: "A Night at the Opera",
|
|
||||||
ISRC: "GBUM71029604",
|
|
||||||
ReleaseDate: "1975-11-21",
|
|
||||||
})
|
|
||||||
|
|
||||||
if result["spotify_id"] != "deezer:3135556" {
|
|
||||||
t.Fatalf("unexpected spotify_id: %v", result["spotify_id"])
|
|
||||||
}
|
|
||||||
if result["id"] != "3135556" {
|
|
||||||
t.Fatalf("unexpected id: %v", result["id"])
|
|
||||||
}
|
|
||||||
if result["track_id"] != "3135556" {
|
|
||||||
t.Fatalf("unexpected track_id: %v", result["track_id"])
|
|
||||||
}
|
|
||||||
if result["success"] != true {
|
|
||||||
t.Fatalf("expected success=true, got %v", result["success"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
|
|
||||||
req := DownloadRequest{
|
|
||||||
TrackName: "Bonus Track",
|
|
||||||
ArtistName: "Artist",
|
|
||||||
AlbumName: "Album (Deluxe)",
|
|
||||||
AlbumArtist: "Artist",
|
|
||||||
ReleaseDate: "2024-01-01",
|
|
||||||
TrackNumber: 14,
|
|
||||||
DiscNumber: 1,
|
|
||||||
ISRC: "REQ123",
|
|
||||||
CoverURL: "https://example.com/cover.jpg",
|
|
||||||
Genre: "Pop",
|
|
||||||
Label: "Label",
|
|
||||||
Copyright: "Copyright",
|
|
||||||
}
|
|
||||||
|
|
||||||
result := DownloadResult{
|
|
||||||
Title: "Bonus Track",
|
|
||||||
Artist: "Artist",
|
|
||||||
Album: "Album",
|
|
||||||
ReleaseDate: "2023-12-01",
|
|
||||||
TrackNumber: 2,
|
|
||||||
DiscNumber: 9,
|
|
||||||
ISRC: "RES456",
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := buildDownloadSuccessResponse(
|
|
||||||
req,
|
|
||||||
result,
|
|
||||||
"tidal",
|
|
||||||
"ok",
|
|
||||||
"/tmp/test.flac",
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
|
|
||||||
if resp.Album != req.AlbumName {
|
|
||||||
t.Fatalf("album = %q, want %q", resp.Album, req.AlbumName)
|
|
||||||
}
|
|
||||||
if resp.ReleaseDate != req.ReleaseDate {
|
|
||||||
t.Fatalf("release date = %q, want %q", resp.ReleaseDate, req.ReleaseDate)
|
|
||||||
}
|
|
||||||
if resp.TrackNumber != req.TrackNumber {
|
|
||||||
t.Fatalf("track number = %d, want %d", resp.TrackNumber, req.TrackNumber)
|
|
||||||
}
|
|
||||||
if resp.DiscNumber != req.DiscNumber {
|
|
||||||
t.Fatalf("disc number = %d, want %d", resp.DiscNumber, req.DiscNumber)
|
|
||||||
}
|
|
||||||
if resp.Artist != result.Artist {
|
|
||||||
t.Fatalf("artist = %q, want provider artist %q", resp.Artist, result.Artist)
|
|
||||||
}
|
|
||||||
if resp.ISRC != result.ISRC {
|
|
||||||
t.Fatalf("isrc = %q, want provider isrc %q", resp.ISRC, result.ISRC)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPreferredReleaseMetadataPrefersRequestValues(t *testing.T) {
|
|
||||||
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
|
|
||||||
DownloadRequest{
|
|
||||||
AlbumName: "Album (Deluxe Edition)",
|
|
||||||
ReleaseDate: "2024-01-01",
|
|
||||||
TrackNumber: 13,
|
|
||||||
DiscNumber: 2,
|
|
||||||
},
|
|
||||||
"Album",
|
|
||||||
"2023-01-01",
|
|
||||||
3,
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
|
|
||||||
if album != "Album (Deluxe Edition)" {
|
|
||||||
t.Fatalf("album = %q", album)
|
|
||||||
}
|
|
||||||
if releaseDate != "2024-01-01" {
|
|
||||||
t.Fatalf("release date = %q", releaseDate)
|
|
||||||
}
|
|
||||||
if trackNumber != 13 {
|
|
||||||
t.Fatalf("track number = %d", trackNumber)
|
|
||||||
}
|
|
||||||
if discNumber != 2 {
|
|
||||||
t.Fatalf("disc number = %d", discNumber)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
|
|
||||||
req := DownloadRequest{
|
|
||||||
TrackName: "Track",
|
|
||||||
ArtistName: "Artist",
|
|
||||||
AlbumName: "Album",
|
|
||||||
AlbumArtist: "Artist",
|
|
||||||
}
|
|
||||||
|
|
||||||
result := DownloadResult{
|
|
||||||
Title: "Track",
|
|
||||||
Artist: "Artist",
|
|
||||||
Album: "Album",
|
|
||||||
CoverURL: "https://cdn.qobuz.test/cover.jpg",
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := buildDownloadSuccessResponse(
|
|
||||||
req,
|
|
||||||
result,
|
|
||||||
"qobuz",
|
|
||||||
"ok",
|
|
||||||
"/tmp/test.flac",
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
|
|
||||||
if resp.CoverURL != result.CoverURL {
|
|
||||||
t.Fatalf("cover url = %q, want %q", resp.CoverURL, result.CoverURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMissing(t *testing.T) {
|
|
||||||
req := reEnrichRequest{
|
|
||||||
SpotifyID: "spotify-track-id",
|
|
||||||
AlbumName: "Original Album",
|
|
||||||
ReleaseDate: "2024-01-01",
|
|
||||||
ISRC: "REQ123",
|
|
||||||
}
|
|
||||||
|
|
||||||
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
|
|
||||||
AlbumName: "Resolved Album",
|
|
||||||
ReleaseDate: "",
|
|
||||||
ISRC: "",
|
|
||||||
})
|
|
||||||
|
|
||||||
if req.ReleaseDate != "2024-01-01" {
|
|
||||||
t.Fatalf("release date = %q, want existing value preserved", req.ReleaseDate)
|
|
||||||
}
|
|
||||||
if req.AlbumName != "Resolved Album" {
|
|
||||||
t.Fatalf("album = %q, want updated album", req.AlbumName)
|
|
||||||
}
|
|
||||||
if req.ISRC != "REQ123" {
|
|
||||||
t.Fatalf("isrc = %q, want existing value preserved", req.ISRC)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
|
|
||||||
req := reEnrichRequest{
|
|
||||||
TrackName: "Song Title",
|
|
||||||
ArtistName: "Artist Name",
|
|
||||||
AlbumName: "Album Name",
|
|
||||||
ReleaseDate: "",
|
|
||||||
DurationMs: 180000,
|
|
||||||
}
|
|
||||||
|
|
||||||
tracks := []ExtTrackMetadata{
|
|
||||||
{
|
|
||||||
ID: "first",
|
|
||||||
Name: "Song Title",
|
|
||||||
Artists: "Artist Name",
|
|
||||||
AlbumName: "Album Name",
|
|
||||||
DurationMS: 180000,
|
|
||||||
ReleaseDate: "",
|
|
||||||
ProviderID: "spotify",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "second",
|
|
||||||
Name: "Song Title",
|
|
||||||
Artists: "Artist Name",
|
|
||||||
AlbumName: "Album Name",
|
|
||||||
DurationMS: 180000,
|
|
||||||
ReleaseDate: "2024-03-09",
|
|
||||||
ProviderID: "deezer",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
best := selectBestReEnrichTrack(req, tracks)
|
|
||||||
if best == nil {
|
|
||||||
t.Fatal("expected a selected track")
|
|
||||||
}
|
|
||||||
if best.ID != "second" {
|
|
||||||
t.Fatalf("selected track = %q, want candidate with release date", best.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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, "")
|
|
||||||
|
|
||||||
// Title and Artist are never written by re-enrich (they are search keys
|
|
||||||
// preserved as-is from the file).
|
|
||||||
if _, exists := metadata["TITLE"]; exists {
|
|
||||||
t.Fatalf("TITLE should not be in metadata: %#v", metadata)
|
|
||||||
}
|
|
||||||
if _, exists := metadata["ARTIST"]; exists {
|
|
||||||
t.Fatalf("ARTIST should not be in metadata: %#v", metadata)
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+119
-247
@@ -44,76 +44,16 @@ func compareVersions(v1, v2 string) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type LoadedExtension struct {
|
type LoadedExtension struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Manifest *ExtensionManifest `json:"manifest"`
|
Manifest *ExtensionManifest `json:"manifest"`
|
||||||
VM *goja.Runtime `json:"-"`
|
VM *goja.Runtime `json:"-"`
|
||||||
VMMu sync.Mutex `json:"-"`
|
VMMu sync.Mutex `json:"-"`
|
||||||
runtime *ExtensionRuntime
|
runtime *ExtensionRuntime
|
||||||
initialized bool
|
Enabled bool `json:"enabled"`
|
||||||
Enabled bool `json:"enabled"`
|
Error string `json:"error,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
DataDir string `json:"data_dir"`
|
||||||
DataDir string `json:"data_dir"`
|
SourceDir string `json:"source_dir"`
|
||||||
SourceDir string `json:"source_dir"`
|
IconPath string `json:"icon_path"`
|
||||||
IconPath string `json:"icon_path"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func getExtensionInitSettings(extensionID string) map[string]interface{} {
|
|
||||||
settings := GetExtensionSettingsStore().GetAll(extensionID)
|
|
||||||
if len(settings) == 0 {
|
|
||||||
return settings
|
|
||||||
}
|
|
||||||
|
|
||||||
filtered := make(map[string]interface{}, len(settings))
|
|
||||||
for key, value := range settings {
|
|
||||||
if strings.HasPrefix(key, "_") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
filtered[key] = value
|
|
||||||
}
|
|
||||||
return filtered
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureRuntimeReadyLocked(ext *LoadedExtension, applyStoredSettings bool) error {
|
|
||||||
if ext.VM == nil || ext.runtime == nil {
|
|
||||||
if err := initializeVMLocked(ext); err != nil {
|
|
||||||
ext.Error = err.Error()
|
|
||||||
ext.Enabled = false
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if applyStoredSettings && !ext.initialized {
|
|
||||||
settings := getExtensionInitSettings(ext.ID)
|
|
||||||
if len(settings) > 0 {
|
|
||||||
if err := initializeExtensionWithSettingsLocked(ext, settings); err != nil {
|
|
||||||
teardownVMLocked(ext)
|
|
||||||
ext.Error = err.Error()
|
|
||||||
ext.Enabled = false
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ext.initialized = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ext.Error = ""
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ext *LoadedExtension) ensureRuntimeReady() error {
|
|
||||||
ext.VMMu.Lock()
|
|
||||||
defer ext.VMMu.Unlock()
|
|
||||||
|
|
||||||
return ensureRuntimeReadyLocked(ext, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ext *LoadedExtension) lockReadyVM() (*goja.Runtime, error) {
|
|
||||||
ext.VMMu.Lock()
|
|
||||||
if err := ensureRuntimeReadyLocked(ext, true); err != nil {
|
|
||||||
ext.VMMu.Unlock()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return ext.VM, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExtensionManager struct {
|
type ExtensionManager struct {
|
||||||
@@ -211,6 +151,7 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
if exists {
|
if exists {
|
||||||
versionCompare := compareVersions(manifest.Version, existingVersion)
|
versionCompare := compareVersions(manifest.Version, existingVersion)
|
||||||
if versionCompare > 0 {
|
if versionCompare > 0 {
|
||||||
|
// This is an upgrade - call UpgradeExtension
|
||||||
return m.UpgradeExtension(filePath)
|
return m.UpgradeExtension(filePath)
|
||||||
} else if versionCompare == 0 {
|
} else if versionCompare == 0 {
|
||||||
return nil, fmt.Errorf("Extension '%s' v%s is already installed", existingDisplayName, existingVersion)
|
return nil, fmt.Errorf("Extension '%s' v%s is already installed", existingDisplayName, existingVersion)
|
||||||
@@ -280,10 +221,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
SourceDir: extDir,
|
SourceDir: extDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateExtensionLoad(ext); err != nil {
|
if err := m.initializeVM(ext); err != nil {
|
||||||
ext.Error = err.Error()
|
ext.Error = err.Error()
|
||||||
ext.Enabled = false
|
ext.Enabled = false
|
||||||
GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err)
|
GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.extensions[manifest.Name] = ext
|
m.extensions[manifest.Name] = ext
|
||||||
@@ -292,10 +233,7 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
return ext, nil
|
return ext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func initializeVMLocked(ext *LoadedExtension) error {
|
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||||
ext.VM = nil
|
|
||||||
ext.runtime = nil
|
|
||||||
ext.initialized = false
|
|
||||||
vm := goja.New()
|
vm := goja.New()
|
||||||
ext.VM = vm
|
ext.VM = vm
|
||||||
|
|
||||||
@@ -342,136 +280,6 @@ func initializeVMLocked(ext *LoadedExtension) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
|
||||||
ext.VMMu.Lock()
|
|
||||||
defer ext.VMMu.Unlock()
|
|
||||||
return initializeVMLocked(ext)
|
|
||||||
}
|
|
||||||
|
|
||||||
func initializeExtensionWithSettingsLocked(
|
|
||||||
ext *LoadedExtension,
|
|
||||||
settings map[string]interface{},
|
|
||||||
) error {
|
|
||||||
if ext.VM == nil {
|
|
||||||
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
|
||||||
}
|
|
||||||
|
|
||||||
settingsJSON, err := json.Marshal(settings)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to save settings")
|
|
||||||
}
|
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
|
||||||
(function() {
|
|
||||||
var settings = %s;
|
|
||||||
if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
|
|
||||||
try {
|
|
||||||
extension.initialize(settings);
|
|
||||||
return { success: true };
|
|
||||||
} catch (e) {
|
|
||||||
return { success: false, error: e.toString() };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { success: true, message: 'no initialize function' };
|
|
||||||
})()
|
|
||||||
`, string(settingsJSON))
|
|
||||||
|
|
||||||
result, err := ext.VM.RunString(script)
|
|
||||||
if err != nil {
|
|
||||||
ext.Error = fmt.Sprintf("initialize failed: %v", err)
|
|
||||||
ext.Enabled = false
|
|
||||||
GoLog("[Extension] Initialize error for %s: %v\n", ext.ID, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if result != nil && !goja.IsUndefined(result) {
|
|
||||||
exported := result.Export()
|
|
||||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
|
||||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
|
||||||
errMsg := "unknown error"
|
|
||||||
if e, ok := resultMap["error"].(string); ok {
|
|
||||||
errMsg = e
|
|
||||||
}
|
|
||||||
ext.Error = errMsg
|
|
||||||
ext.Enabled = false
|
|
||||||
GoLog("[Extension] Initialize failed for %s: %s\n", ext.ID, errMsg)
|
|
||||||
return fmt.Errorf("initialize failed: %s", errMsg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ext.initialized = true
|
|
||||||
GoLog("[Extension] Initialized %s\n", ext.ID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runCleanupLocked(ext *LoadedExtension) error {
|
|
||||||
if ext.VM != nil {
|
|
||||||
script := `
|
|
||||||
(function() {
|
|
||||||
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
|
||||||
try {
|
|
||||||
extension.cleanup();
|
|
||||||
return { success: true };
|
|
||||||
} catch (e) {
|
|
||||||
return { success: false, error: e.toString() };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { success: true, message: 'no cleanup function' };
|
|
||||||
})()
|
|
||||||
`
|
|
||||||
|
|
||||||
result, err := ext.VM.RunString(script)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if result != nil && !goja.IsUndefined(result) {
|
|
||||||
exported := result.Export()
|
|
||||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
|
||||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
|
||||||
errMsg := "unknown error"
|
|
||||||
if e, ok := resultMap["error"].(string); ok {
|
|
||||||
errMsg = e
|
|
||||||
}
|
|
||||||
return fmt.Errorf("cleanup failed: %s", errMsg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if result != nil && !goja.IsUndefined(result) && !goja.IsNull(result) {
|
|
||||||
GoLog("[Extension] Cleanup called for %s\n", ext.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func teardownVMLocked(ext *LoadedExtension) {
|
|
||||||
if err := runCleanupLocked(ext); err != nil {
|
|
||||||
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
|
|
||||||
}
|
|
||||||
if ext.runtime != nil {
|
|
||||||
if err := ext.runtime.flushStorageNow(); err != nil {
|
|
||||||
GoLog("[Extension] Failed to flush storage for %s: %v\n", ext.ID, err)
|
|
||||||
}
|
|
||||||
ext.runtime.closeStorageFlusher()
|
|
||||||
}
|
|
||||||
ext.runtime = nil
|
|
||||||
ext.VM = nil
|
|
||||||
ext.initialized = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateExtensionLoad(ext *LoadedExtension) error {
|
|
||||||
ext.VMMu.Lock()
|
|
||||||
defer ext.VMMu.Unlock()
|
|
||||||
|
|
||||||
if err := initializeVMLocked(ext); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
teardownVMLocked(ext)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
@@ -481,9 +289,21 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
|||||||
return fmt.Errorf("Extension not found")
|
return fmt.Errorf("Extension not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
ext.VMMu.Lock()
|
if ext.VM != nil {
|
||||||
teardownVMLocked(ext)
|
cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null")
|
||||||
ext.VMMu.Unlock()
|
if err != nil {
|
||||||
|
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)
|
||||||
@@ -522,21 +342,7 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
|
|||||||
return fmt.Errorf("Extension not found")
|
return fmt.Errorf("Extension not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
if enabled {
|
ext.Enabled = 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()
|
||||||
@@ -623,6 +429,7 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
|||||||
SourceDir: dirPath,
|
SourceDir: dirPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore enabled state from settings store
|
||||||
store := GetExtensionSettingsStore()
|
store := GetExtensionSettingsStore()
|
||||||
if enabledVal, err := store.Get(manifest.Name, "_enabled"); err == nil {
|
if enabledVal, err := store.Get(manifest.Name, "_enabled"); err == nil {
|
||||||
if enabled, ok := enabledVal.(bool); ok {
|
if enabled, ok := enabledVal.(bool); ok {
|
||||||
@@ -631,10 +438,10 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateExtensionLoad(ext); err != nil {
|
if err := m.initializeVM(ext); err != nil {
|
||||||
ext.Error = err.Error()
|
ext.Error = err.Error()
|
||||||
ext.Enabled = false
|
ext.Enabled = false
|
||||||
GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err)
|
GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.extensions[manifest.Name] = ext
|
m.extensions[manifest.Name] = ext
|
||||||
@@ -785,14 +592,10 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
SourceDir: extDir,
|
SourceDir: extDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
if wasEnabled {
|
if err := m.initializeVM(ext); err != nil {
|
||||||
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 validate upgraded extension %s: %v\n", newManifest.Name, err)
|
GoLog("[Extension] Failed to initialize VM for %s: %v\n", newManifest.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -908,7 +711,6 @@ 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"`
|
||||||
@@ -966,7 +768,6 @@ 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,
|
||||||
@@ -991,13 +792,56 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
|
|||||||
return fmt.Errorf("Extension not found")
|
return fmt.Errorf("Extension not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
ext.VMMu.Lock()
|
if ext.VM == nil {
|
||||||
defer ext.VMMu.Unlock()
|
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
||||||
|
}
|
||||||
|
|
||||||
if err := ensureRuntimeReadyLocked(ext, false); err != nil {
|
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", 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 {
|
||||||
@@ -1012,12 +856,41 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
|||||||
if ext.VM == nil {
|
if ext.VM == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
ext.VMMu.Lock()
|
|
||||||
defer ext.VMMu.Unlock()
|
script := `
|
||||||
if err := runCleanupLocked(ext); err != nil {
|
(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 {
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -1046,14 +919,13 @@ 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() {
|
||||||
@@ -1073,7 +945,7 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
|
|||||||
})()
|
})()
|
||||||
`, actionName, actionName, actionName)
|
`, actionName, actionName, actionName)
|
||||||
|
|
||||||
result, err := RunWithTimeoutAndRecover(vm, script, DefaultJSTimeout)
|
result, err := RunWithTimeoutAndRecover(ext.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,7 +115,6 @@ 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"`
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ type ExtArtistMetadata struct {
|
|||||||
HeaderImage string `json:"header_image,omitempty"`
|
HeaderImage string `json:"header_image,omitempty"`
|
||||||
Listeners int `json:"listeners,omitempty"`
|
Listeners int `json:"listeners,omitempty"`
|
||||||
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
|
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
|
||||||
Releases []ExtAlbumMetadata `json:"releases,omitempty"`
|
|
||||||
TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"`
|
TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"`
|
||||||
ProviderID string `json:"provider_id"`
|
ProviderID string `json:"provider_id"`
|
||||||
}
|
}
|
||||||
@@ -125,15 +124,6 @@ func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ExtensionProviderWrapper) lockReadyVM() error {
|
|
||||||
vm, err := p.extension.lockReadyVM()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
p.vm = vm
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) {
|
func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) {
|
||||||
if !p.extension.Manifest.IsMetadataProvider() {
|
if !p.extension.Manifest.IsMetadataProvider() {
|
||||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||||
@@ -142,9 +132,8 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
if err := p.lockReadyVM(); err != nil {
|
|
||||||
return nil, err
|
p.extension.VMMu.Lock()
|
||||||
}
|
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -202,9 +191,8 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
if err := p.lockReadyVM(); err != nil {
|
|
||||||
return nil, err
|
p.extension.VMMu.Lock()
|
||||||
}
|
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -251,9 +239,8 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
if err := p.lockReadyVM(); err != nil {
|
|
||||||
return nil, err
|
p.extension.VMMu.Lock()
|
||||||
}
|
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -303,9 +290,8 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
if err := p.lockReadyVM(); err != nil {
|
|
||||||
return nil, err
|
p.extension.VMMu.Lock()
|
||||||
}
|
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -341,12 +327,6 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
|
|||||||
}
|
}
|
||||||
|
|
||||||
artist.ProviderID = p.extension.ID
|
artist.ProviderID = p.extension.ID
|
||||||
for i := range artist.Releases {
|
|
||||||
artist.Releases[i].ProviderID = p.extension.ID
|
|
||||||
for j := range artist.Releases[i].Tracks {
|
|
||||||
artist.Releases[i].Tracks[j].ProviderID = p.extension.ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &artist, nil
|
return &artist, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,10 +338,8 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return track, nil
|
return track, nil
|
||||||
}
|
}
|
||||||
if err := p.lockReadyVM(); err != nil {
|
|
||||||
GoLog("[Extension] EnrichTrack init error for %s: %v\n", p.extension.ID, err)
|
p.extension.VMMu.Lock()
|
||||||
return track, nil
|
|
||||||
}
|
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
trackJSON, err := json.Marshal(track)
|
trackJSON, err := json.Marshal(track)
|
||||||
@@ -420,9 +398,8 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
if err := p.lockReadyVM(); err != nil {
|
|
||||||
return nil, err
|
p.extension.VMMu.Lock()
|
||||||
}
|
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -468,9 +445,8 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
if err := p.lockReadyVM(); err != nil {
|
|
||||||
return nil, err
|
p.extension.VMMu.Lock()
|
||||||
}
|
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -508,9 +484,9 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
|
|||||||
return &urlResult, nil
|
return &urlResult, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const ExtDownloadTimeout = DownloadTimeout
|
const ExtDownloadTimeout = 5 * time.Minute
|
||||||
|
|
||||||
func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath, itemID string, onProgress func(percent int)) (*ExtDownloadResult, error) {
|
func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, onProgress func(percent int)) (*ExtDownloadResult, error) {
|
||||||
if !p.extension.Manifest.IsDownloadProvider() {
|
if !p.extension.Manifest.IsDownloadProvider() {
|
||||||
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
||||||
}
|
}
|
||||||
@@ -518,18 +494,9 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath, itemID
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
if err := p.lockReadyVM(); err != nil {
|
|
||||||
return &ExtDownloadResult{
|
p.extension.VMMu.Lock()
|
||||||
Success: false,
|
|
||||||
ErrorMessage: err.Error(),
|
|
||||||
ErrorType: "init_error",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
if p.extension.runtime != nil {
|
|
||||||
p.extension.runtime.setActiveDownloadItemID(itemID)
|
|
||||||
defer p.extension.runtime.clearActiveDownloadItemID()
|
|
||||||
}
|
|
||||||
|
|
||||||
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
|
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) > 0 {
|
if len(call.Arguments) > 0 {
|
||||||
@@ -633,30 +600,8 @@ func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) (
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
providerByID := make(map[string]*ExtensionProviderWrapper, len(providers))
|
|
||||||
orderedProviders := make([]*ExtensionProviderWrapper, 0, len(providers))
|
|
||||||
for _, provider := range providers {
|
|
||||||
providerByID[provider.extension.ID] = provider
|
|
||||||
}
|
|
||||||
for _, providerID := range GetMetadataProviderPriority() {
|
|
||||||
if provider := providerByID[providerID]; provider != nil {
|
|
||||||
orderedProviders = append(orderedProviders, provider)
|
|
||||||
delete(providerByID, providerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(providerByID) > 0 {
|
|
||||||
remainingIDs := make([]string, 0, len(providerByID))
|
|
||||||
for providerID := range providerByID {
|
|
||||||
remainingIDs = append(remainingIDs, providerID)
|
|
||||||
}
|
|
||||||
sort.Strings(remainingIDs)
|
|
||||||
for _, providerID := range remainingIDs {
|
|
||||||
orderedProviders = append(orderedProviders, providerByID[providerID])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var allTracks []ExtTrackMetadata
|
var allTracks []ExtTrackMetadata
|
||||||
for _, provider := range orderedProviders {
|
for _, provider := range providers {
|
||||||
result, err := provider.SearchTracks(query, limit)
|
result, err := provider.SearchTracks(query, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Extension] Search error from %s: %v\n", provider.extension.ID, err)
|
GoLog("[Extension] Search error from %s: %v\n", provider.extension.ID, err)
|
||||||
@@ -676,8 +621,6 @@ var providerPriorityMu sync.RWMutex
|
|||||||
var metadataProviderPriority []string
|
var metadataProviderPriority []string
|
||||||
var metadataProviderPriorityMu sync.RWMutex
|
var metadataProviderPriorityMu sync.RWMutex
|
||||||
|
|
||||||
var searchBuiltInMetadataTracksFunc = searchBuiltInMetadataTracks
|
|
||||||
|
|
||||||
func SetProviderPriority(providerIDs []string) {
|
func SetProviderPriority(providerIDs []string) {
|
||||||
providerPriorityMu.Lock()
|
providerPriorityMu.Lock()
|
||||||
defer providerPriorityMu.Unlock()
|
defer providerPriorityMu.Unlock()
|
||||||
@@ -702,7 +645,7 @@ func SetMetadataProviderPriority(providerIDs []string) {
|
|||||||
metadataProviderPriorityMu.Lock()
|
metadataProviderPriorityMu.Lock()
|
||||||
defer metadataProviderPriorityMu.Unlock()
|
defer metadataProviderPriorityMu.Unlock()
|
||||||
|
|
||||||
sanitized := make([]string, 0, len(providerIDs)+3)
|
sanitized := make([]string, 0, len(providerIDs)+1)
|
||||||
seen := map[string]struct{}{}
|
seen := map[string]struct{}{}
|
||||||
for _, providerID := range providerIDs {
|
for _, providerID := range providerIDs {
|
||||||
providerID = strings.TrimSpace(providerID)
|
providerID = strings.TrimSpace(providerID)
|
||||||
@@ -715,12 +658,8 @@ func SetMetadataProviderPriority(providerIDs []string) {
|
|||||||
seen[providerID] = struct{}{}
|
seen[providerID] = struct{}{}
|
||||||
sanitized = append(sanitized, providerID)
|
sanitized = append(sanitized, providerID)
|
||||||
}
|
}
|
||||||
for _, providerID := range []string{"deezer", "qobuz", "tidal"} {
|
if _, exists := seen["deezer"]; !exists {
|
||||||
if _, exists := seen[providerID]; exists {
|
sanitized = append([]string{"deezer"}, sanitized...)
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[providerID] = struct{}{}
|
|
||||||
sanitized = append(sanitized, providerID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
metadataProviderPriority = sanitized
|
metadataProviderPriority = sanitized
|
||||||
@@ -732,7 +671,7 @@ func GetMetadataProviderPriority() []string {
|
|||||||
defer metadataProviderPriorityMu.RUnlock()
|
defer metadataProviderPriorityMu.RUnlock()
|
||||||
|
|
||||||
if len(metadataProviderPriority) == 0 {
|
if len(metadataProviderPriority) == 0 {
|
||||||
return []string{"deezer", "qobuz", "tidal"}
|
return []string{"deezer"}
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]string, len(metadataProviderPriority))
|
result := make([]string, len(metadataProviderPriority))
|
||||||
@@ -749,165 +688,6 @@ func isBuiltInProvider(providerID string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeBuiltInMetadataTrack(track TrackMetadata, providerID string) ExtTrackMetadata {
|
|
||||||
deezerID := ""
|
|
||||||
tidalID := ""
|
|
||||||
qobuzID := ""
|
|
||||||
prefixedID := strings.TrimSpace(track.SpotifyID)
|
|
||||||
|
|
||||||
switch providerID {
|
|
||||||
case "deezer":
|
|
||||||
deezerID = strings.TrimPrefix(prefixedID, "deezer:")
|
|
||||||
case "tidal":
|
|
||||||
tidalID = strings.TrimPrefix(prefixedID, "tidal:")
|
|
||||||
case "qobuz":
|
|
||||||
qobuzID = strings.TrimPrefix(prefixedID, "qobuz:")
|
|
||||||
}
|
|
||||||
|
|
||||||
return ExtTrackMetadata{
|
|
||||||
ID: prefixedID,
|
|
||||||
Name: track.Name,
|
|
||||||
Artists: track.Artists,
|
|
||||||
AlbumName: track.AlbumName,
|
|
||||||
AlbumArtist: track.AlbumArtist,
|
|
||||||
DurationMS: track.DurationMS,
|
|
||||||
CoverURL: track.Images,
|
|
||||||
Images: track.Images,
|
|
||||||
ReleaseDate: track.ReleaseDate,
|
|
||||||
TrackNumber: track.TrackNumber,
|
|
||||||
DiscNumber: track.DiscNumber,
|
|
||||||
ISRC: track.ISRC,
|
|
||||||
ProviderID: providerID,
|
|
||||||
SpotifyID: prefixedID,
|
|
||||||
DeezerID: deezerID,
|
|
||||||
TidalID: tidalID,
|
|
||||||
QobuzID: qobuzID,
|
|
||||||
AlbumType: track.AlbumType,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func metadataTrackDedupKey(track ExtTrackMetadata) string {
|
|
||||||
if isrc := strings.TrimSpace(track.ISRC); isrc != "" {
|
|
||||||
return "isrc:" + strings.ToUpper(isrc)
|
|
||||||
}
|
|
||||||
if spotifyID := strings.TrimSpace(track.SpotifyID); spotifyID != "" {
|
|
||||||
return "spotify:" + spotifyID
|
|
||||||
}
|
|
||||||
if providerID := strings.TrimSpace(track.ProviderID); providerID != "" && strings.TrimSpace(track.ID) != "" {
|
|
||||||
return providerID + ":" + strings.TrimSpace(track.ID)
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(track.Name) + "|" + strings.TrimSpace(track.Artists)
|
|
||||||
}
|
|
||||||
|
|
||||||
func searchBuiltInMetadataTracks(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
|
|
||||||
switch providerID {
|
|
||||||
case "deezer":
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
results, err := GetDeezerClient().SearchAll(ctx, query, limit, 0, "track")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
tracks := make([]ExtTrackMetadata, 0, len(results.Tracks))
|
|
||||||
for _, track := range results.Tracks {
|
|
||||||
tracks = append(tracks, normalizeBuiltInMetadataTrack(track, "deezer"))
|
|
||||||
}
|
|
||||||
return tracks, nil
|
|
||||||
case "qobuz":
|
|
||||||
return NewQobuzDownloader().SearchTracks(query, limit)
|
|
||||||
case "tidal":
|
|
||||||
return NewTidalDownloader().SearchTracks(query, limit)
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unsupported built-in metadata provider: %s", providerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *ExtensionManager) SearchTracksWithMetadataProviders(query string, limit int, includeExtensions bool) ([]ExtTrackMetadata, error) {
|
|
||||||
priority := GetMetadataProviderPriority()
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 20
|
|
||||||
}
|
|
||||||
|
|
||||||
extensionProviders := make(map[string]*ExtensionProviderWrapper)
|
|
||||||
if includeExtensions {
|
|
||||||
for _, provider := range m.GetMetadataProviders() {
|
|
||||||
extensionProviders[provider.extension.ID] = provider
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
orderedProviderIDs := make([]string, 0, len(priority)+len(extensionProviders))
|
|
||||||
seenProviderIDs := make(map[string]struct{}, len(priority)+len(extensionProviders))
|
|
||||||
for _, providerID := range priority {
|
|
||||||
providerID = strings.TrimSpace(providerID)
|
|
||||||
if providerID == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
orderedProviderIDs = append(orderedProviderIDs, providerID)
|
|
||||||
seenProviderIDs[providerID] = struct{}{}
|
|
||||||
}
|
|
||||||
if includeExtensions {
|
|
||||||
remainingIDs := make([]string, 0, len(extensionProviders))
|
|
||||||
for providerID := range extensionProviders {
|
|
||||||
if _, exists := seenProviderIDs[providerID]; exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
remainingIDs = append(remainingIDs, providerID)
|
|
||||||
}
|
|
||||||
sort.Strings(remainingIDs)
|
|
||||||
orderedProviderIDs = append(orderedProviderIDs, remainingIDs...)
|
|
||||||
}
|
|
||||||
|
|
||||||
tracks := make([]ExtTrackMetadata, 0, limit)
|
|
||||||
seenTracks := make(map[string]struct{})
|
|
||||||
for _, providerID := range orderedProviderIDs {
|
|
||||||
var (
|
|
||||||
providerTracks []ExtTrackMetadata
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
|
|
||||||
if isBuiltInProvider(providerID) {
|
|
||||||
providerTracks, err = searchBuiltInMetadataTracksFunc(providerID, query, limit)
|
|
||||||
} else {
|
|
||||||
if !includeExtensions {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
provider := extensionProviders[providerID]
|
|
||||||
if provider == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var result *ExtSearchResult
|
|
||||||
result, err = provider.SearchTracks(query, limit)
|
|
||||||
if result != nil {
|
|
||||||
providerTracks = result.Tracks
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
GoLog("[MetadataSearch] Search error from %s: %v\n", providerID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, track := range providerTracks {
|
|
||||||
key := metadataTrackDedupKey(track)
|
|
||||||
if key == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, exists := seenTracks[key]; exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seenTracks[key] = struct{}{}
|
|
||||||
tracks = append(tracks, track)
|
|
||||||
if len(tracks) >= limit {
|
|
||||||
return tracks, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tracks, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) {
|
func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) {
|
||||||
priority := GetProviderPriority()
|
priority := GetProviderPriority()
|
||||||
extManager := GetExtensionManager()
|
extManager := GetExtensionManager()
|
||||||
@@ -1003,24 +783,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
if enrichedTrack.Artists != "" {
|
if enrichedTrack.Artists != "" {
|
||||||
req.ArtistName = enrichedTrack.Artists
|
req.ArtistName = enrichedTrack.Artists
|
||||||
}
|
}
|
||||||
if enrichedTrack.AlbumName != "" && req.AlbumName == "" {
|
|
||||||
GoLog("[DownloadWithExtensionFallback] AlbumName from enrichment: %s\n", enrichedTrack.AlbumName)
|
|
||||||
req.AlbumName = enrichedTrack.AlbumName
|
|
||||||
}
|
|
||||||
if enrichedTrack.AlbumArtist != "" && req.AlbumArtist == "" {
|
|
||||||
req.AlbumArtist = enrichedTrack.AlbumArtist
|
|
||||||
}
|
|
||||||
if enrichedTrack.DurationMS > 0 && req.DurationMS == 0 {
|
|
||||||
GoLog("[DownloadWithExtensionFallback] DurationMS from enrichment: %d\n", enrichedTrack.DurationMS)
|
|
||||||
req.DurationMS = enrichedTrack.DurationMS
|
|
||||||
}
|
|
||||||
if enrichedTrack.CoverURL != "" && req.CoverURL == "" {
|
|
||||||
req.CoverURL = enrichedTrack.CoverURL
|
|
||||||
}
|
|
||||||
if enrichedTrack.ID != "" && req.SpotifyID == "" {
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Track ID from enrichment: %s\n", enrichedTrack.ID)
|
|
||||||
req.SpotifyID = enrichedTrack.ID
|
|
||||||
}
|
|
||||||
if enrichedTrack.Label != "" && req.Label == "" {
|
if enrichedTrack.Label != "" && req.Label == "" {
|
||||||
GoLog("[DownloadWithExtensionFallback] Label from enrichment: %s\n", enrichedTrack.Label)
|
GoLog("[DownloadWithExtensionFallback] Label from enrichment: %s\n", enrichedTrack.Label)
|
||||||
req.Label = enrichedTrack.Label
|
req.Label = enrichedTrack.Label
|
||||||
@@ -1037,81 +799,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
GoLog("[DownloadWithExtensionFallback] ReleaseDate from enrichment: %s\n", enrichedTrack.ReleaseDate)
|
GoLog("[DownloadWithExtensionFallback] ReleaseDate from enrichment: %s\n", enrichedTrack.ReleaseDate)
|
||||||
req.ReleaseDate = enrichedTrack.ReleaseDate
|
req.ReleaseDate = enrichedTrack.ReleaseDate
|
||||||
}
|
}
|
||||||
if enrichedTrack.TrackNumber > 0 && req.TrackNumber == 0 {
|
|
||||||
GoLog("[DownloadWithExtensionFallback] TrackNumber from enrichment: %d\n", enrichedTrack.TrackNumber)
|
|
||||||
req.TrackNumber = enrichedTrack.TrackNumber
|
|
||||||
}
|
|
||||||
if enrichedTrack.DiscNumber > 0 && req.DiscNumber == 0 {
|
|
||||||
GoLog("[DownloadWithExtensionFallback] DiscNumber from enrichment: %d\n", enrichedTrack.DiscNumber)
|
|
||||||
req.DiscNumber = enrichedTrack.DiscNumber
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Source != "" && !isBuiltInProvider(strings.ToLower(req.Source)) &&
|
|
||||||
req.TrackName != "" && req.ArtistName != "" &&
|
|
||||||
(req.AlbumName == "" || req.ReleaseDate == "" || req.ISRC == "") {
|
|
||||||
|
|
||||||
searchQuery := req.TrackName + " " + req.ArtistName
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Metadata incomplete, searching providers for: %s\n", searchQuery)
|
|
||||||
|
|
||||||
tracks, searchErr := extManager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
|
|
||||||
if searchErr == nil && len(tracks) > 0 {
|
|
||||||
track := tracks[0]
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Metadata match (%s): %s - %s (album: %s, date: %s, isrc: %s)\n",
|
|
||||||
track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate, track.ISRC)
|
|
||||||
|
|
||||||
if track.AlbumName != "" && req.AlbumName == "" {
|
|
||||||
req.AlbumName = track.AlbumName
|
|
||||||
}
|
|
||||||
if track.AlbumArtist != "" && req.AlbumArtist == "" {
|
|
||||||
req.AlbumArtist = track.AlbumArtist
|
|
||||||
}
|
|
||||||
if track.ReleaseDate != "" && req.ReleaseDate == "" {
|
|
||||||
req.ReleaseDate = track.ReleaseDate
|
|
||||||
}
|
|
||||||
if track.ISRC != "" && req.ISRC == "" {
|
|
||||||
req.ISRC = track.ISRC
|
|
||||||
}
|
|
||||||
if track.TrackNumber > 0 && req.TrackNumber == 0 {
|
|
||||||
req.TrackNumber = track.TrackNumber
|
|
||||||
}
|
|
||||||
if track.DiscNumber > 0 && req.DiscNumber == 0 {
|
|
||||||
req.DiscNumber = track.DiscNumber
|
|
||||||
}
|
|
||||||
if track.CoverURL != "" && req.CoverURL == "" {
|
|
||||||
req.CoverURL = track.CoverURL
|
|
||||||
}
|
|
||||||
if track.Genre != "" && req.Genre == "" {
|
|
||||||
req.Genre = track.Genre
|
|
||||||
}
|
|
||||||
if track.Label != "" && req.Label == "" {
|
|
||||||
req.Label = track.Label
|
|
||||||
}
|
|
||||||
if track.Copyright != "" && req.Copyright == "" {
|
|
||||||
req.Copyright = track.Copyright
|
|
||||||
}
|
|
||||||
} else if searchErr != nil {
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Metadata provider search failed (non-fatal): %v\n", searchErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.ISRC != "" &&
|
|
||||||
(req.Genre == "" || req.Label == "" || req.Copyright == "") {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
extMeta, err := GetDeezerClient().GetExtendedMetadataByISRC(ctx, req.ISRC)
|
|
||||||
cancel()
|
|
||||||
if err == nil && extMeta != nil {
|
|
||||||
if req.Genre == "" && extMeta.Genre != "" {
|
|
||||||
req.Genre = extMeta.Genre
|
|
||||||
}
|
|
||||||
if req.Label == "" && extMeta.Label != "" {
|
|
||||||
req.Label = extMeta.Label
|
|
||||||
}
|
|
||||||
if req.Copyright == "" && extMeta.Copyright != "" {
|
|
||||||
req.Copyright = extMeta.Copyright
|
|
||||||
}
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Extended metadata from Deezer: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1136,7 +823,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
StartItemProgress(req.ItemID)
|
StartItemProgress(req.ItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := provider.Download(trackID, req.Quality, outputPath, req.ItemID, func(percent int) {
|
result, err := provider.Download(trackID, req.Quality, outputPath, func(percent int) {
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
normalized := float64(percent) / 100.0
|
normalized := float64(percent) / 100.0
|
||||||
if normalized < 0 {
|
if normalized < 0 {
|
||||||
@@ -1209,28 +896,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.AlbumName != "" && resp.Album == "" {
|
|
||||||
resp.Album = req.AlbumName
|
|
||||||
}
|
|
||||||
if req.AlbumArtist != "" && resp.AlbumArtist == "" {
|
|
||||||
resp.AlbumArtist = req.AlbumArtist
|
|
||||||
}
|
|
||||||
if req.ReleaseDate != "" && resp.ReleaseDate == "" {
|
|
||||||
resp.ReleaseDate = req.ReleaseDate
|
|
||||||
}
|
|
||||||
if req.ISRC != "" && resp.ISRC == "" {
|
|
||||||
resp.ISRC = req.ISRC
|
|
||||||
}
|
|
||||||
if req.TrackNumber > 0 && resp.TrackNumber == 0 {
|
|
||||||
resp.TrackNumber = req.TrackNumber
|
|
||||||
}
|
|
||||||
if req.DiscNumber > 0 && resp.DiscNumber == 0 {
|
|
||||||
resp.DiscNumber = req.DiscNumber
|
|
||||||
}
|
|
||||||
if req.CoverURL != "" && resp.CoverURL == "" {
|
|
||||||
resp.CoverURL = req.CoverURL
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1281,8 +946,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
|
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
|
||||||
|
|
||||||
if isBuiltInProvider(providerIDNormalized) {
|
if isBuiltInProvider(providerIDNormalized) {
|
||||||
if (req.Genre == "" || req.Label == "" || req.Copyright == "") &&
|
if (req.Genre == "" || req.Label == "") && req.ISRC != "" {
|
||||||
req.ISRC != "" {
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
|
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
deezerClient := GetDeezerClient()
|
deezerClient := GetDeezerClient()
|
||||||
@@ -1297,10 +961,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
req.Label = extMeta.Label
|
req.Label = extMeta.Label
|
||||||
GoLog("[DownloadWithExtensionFallback] Label from Deezer: %s\n", req.Label)
|
GoLog("[DownloadWithExtensionFallback] Label from Deezer: %s\n", req.Label)
|
||||||
}
|
}
|
||||||
if req.Copyright == "" && extMeta.Copyright != "" {
|
|
||||||
req.Copyright = extMeta.Copyright
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Copyright from Deezer: %s\n", req.Copyright)
|
|
||||||
}
|
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
GoLog("[DownloadWithExtensionFallback] Failed to get extended metadata from Deezer: %v\n", err)
|
GoLog("[DownloadWithExtensionFallback] Failed to get extended metadata from Deezer: %v\n", err)
|
||||||
}
|
}
|
||||||
@@ -1362,7 +1022,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
StartItemProgress(req.ItemID)
|
StartItemProgress(req.ItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := provider.Download(availability.TrackID, req.Quality, outputPath, req.ItemID, func(percent int) {
|
result, err := provider.Download(availability.TrackID, req.Quality, outputPath, func(percent int) {
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
normalized := float64(percent) / 100.0
|
normalized := float64(percent) / 100.0
|
||||||
if normalized < 0 {
|
if normalized < 0 {
|
||||||
@@ -1435,28 +1095,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.AlbumName != "" && resp.Album == "" {
|
|
||||||
resp.Album = req.AlbumName
|
|
||||||
}
|
|
||||||
if req.AlbumArtist != "" && resp.AlbumArtist == "" {
|
|
||||||
resp.AlbumArtist = req.AlbumArtist
|
|
||||||
}
|
|
||||||
if req.ReleaseDate != "" && resp.ReleaseDate == "" {
|
|
||||||
resp.ReleaseDate = req.ReleaseDate
|
|
||||||
}
|
|
||||||
if req.ISRC != "" && resp.ISRC == "" {
|
|
||||||
resp.ISRC = req.ISRC
|
|
||||||
}
|
|
||||||
if req.TrackNumber > 0 && resp.TrackNumber == 0 {
|
|
||||||
resp.TrackNumber = req.TrackNumber
|
|
||||||
}
|
|
||||||
if req.DiscNumber > 0 && resp.DiscNumber == 0 {
|
|
||||||
resp.DiscNumber = req.DiscNumber
|
|
||||||
}
|
|
||||||
if req.CoverURL != "" && resp.CoverURL == "" {
|
|
||||||
resp.CoverURL = req.CoverURL
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1530,7 +1168,6 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
|||||||
TrackNumber: qobuzResult.TrackNumber,
|
TrackNumber: qobuzResult.TrackNumber,
|
||||||
DiscNumber: qobuzResult.DiscNumber,
|
DiscNumber: qobuzResult.DiscNumber,
|
||||||
ISRC: qobuzResult.ISRC,
|
ISRC: qobuzResult.ISRC,
|
||||||
CoverURL: qobuzResult.CoverURL,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = qobuzErr
|
err = qobuzErr
|
||||||
@@ -1573,7 +1210,6 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
|||||||
TrackNumber: result.TrackNumber,
|
TrackNumber: result.TrackNumber,
|
||||||
DiscNumber: result.DiscNumber,
|
DiscNumber: result.DiscNumber,
|
||||||
ISRC: result.ISRC,
|
ISRC: result.ISRC,
|
||||||
CoverURL: result.CoverURL,
|
|
||||||
Genre: req.Genre,
|
Genre: req.Genre,
|
||||||
Label: req.Label,
|
Label: req.Label,
|
||||||
Copyright: req.Copyright,
|
Copyright: req.Copyright,
|
||||||
@@ -1633,6 +1269,7 @@ func buildOutputPathForExtension(req DownloadRequest, ext *LoadedExtension) stri
|
|||||||
return buildOutputPath(req)
|
return buildOutputPath(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SAF mode: use extension's data dir as writable temp location
|
||||||
tempDir := filepath.Join(ext.DataDir, "downloads")
|
tempDir := filepath.Join(ext.DataDir, "downloads")
|
||||||
os.MkdirAll(tempDir, 0755)
|
os.MkdirAll(tempDir, 0755)
|
||||||
AddAllowedDownloadDir(tempDir)
|
AddAllowedDownloadDir(tempDir)
|
||||||
@@ -1675,9 +1312,8 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
if err := p.lockReadyVM(); err != nil {
|
|
||||||
return nil, err
|
p.extension.VMMu.Lock()
|
||||||
}
|
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
if options == nil {
|
if options == nil {
|
||||||
@@ -1757,9 +1393,8 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
if err := p.lockReadyVM(); err != nil {
|
|
||||||
return nil, err
|
p.extension.VMMu.Lock()
|
||||||
}
|
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -1814,12 +1449,6 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
|
|||||||
handleResult.Artist.Albums[i].Tracks[j].ProviderID = p.extension.ID
|
handleResult.Artist.Albums[i].Tracks[j].ProviderID = p.extension.ID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for i := range handleResult.Artist.Releases {
|
|
||||||
handleResult.Artist.Releases[i].ProviderID = p.extension.ID
|
|
||||||
for j := range handleResult.Artist.Releases[i].Tracks {
|
|
||||||
handleResult.Artist.Releases[i].Tracks[j].ProviderID = p.extension.ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for i := range handleResult.Artist.TopTracks {
|
for i := range handleResult.Artist.TopTracks {
|
||||||
handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID
|
handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID
|
||||||
}
|
}
|
||||||
@@ -1843,9 +1472,8 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
if err := p.lockReadyVM(); err != nil {
|
|
||||||
return nil, err
|
p.extension.VMMu.Lock()
|
||||||
}
|
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
sourceJSON, _ := json.Marshal(sourceTrack)
|
sourceJSON, _ := json.Marshal(sourceTrack)
|
||||||
@@ -1914,9 +1542,8 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
if err := p.lockReadyVM(); err != nil {
|
|
||||||
return &PostProcessResult{Success: false, Error: err.Error()}, nil
|
p.extension.VMMu.Lock()
|
||||||
}
|
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
metadataJSON, _ := json.Marshal(metadata)
|
metadataJSON, _ := json.Marshal(metadata)
|
||||||
@@ -1977,9 +1604,8 @@ func (p *ExtensionProviderWrapper) PostProcessV2(input PostProcessInput, metadat
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
if err := p.lockReadyVM(); err != nil {
|
|
||||||
return &PostProcessResult{Success: false, Error: err.Error()}, nil
|
p.extension.VMMu.Lock()
|
||||||
}
|
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
metadataJSON, _ := json.Marshal(metadata)
|
metadataJSON, _ := json.Marshal(metadata)
|
||||||
@@ -2236,9 +1862,8 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
if err := p.lockReadyVM(); err != nil {
|
|
||||||
return nil, err
|
p.extension.VMMu.Lock()
|
||||||
}
|
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
// Use global variables to avoid JS injection issues with special characters in track/artist names
|
// Use global variables to avoid JS injection issues with special characters in track/artist names
|
||||||
@@ -2290,6 +1915,7 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
|
|||||||
return nil, fmt.Errorf("failed to parse lyrics result: %w", err)
|
return nil, fmt.Errorf("failed to parse lyrics result: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert ExtLyricsResult to LyricsResponse
|
||||||
response := &LyricsResponse{
|
response := &LyricsResponse{
|
||||||
SyncType: extResult.SyncType,
|
SyncType: extResult.SyncType,
|
||||||
Instrumental: extResult.Instrumental,
|
Instrumental: extResult.Instrumental,
|
||||||
@@ -2310,6 +1936,7 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the extension provided plainLyrics but no lines, parse them as unsynced
|
||||||
if len(response.Lines) == 0 && response.PlainLyrics != "" && !response.Instrumental {
|
if len(response.Lines) == 0 && response.PlainLyrics != "" && !response.Instrumental {
|
||||||
response.SyncType = "UNSYNCED"
|
response.SyncType = "UNSYNCED"
|
||||||
for _, line := range strings.Split(response.PlainLyrics, "\n") {
|
for _, line := range strings.Split(response.PlainLyrics, "\n") {
|
||||||
@@ -2337,6 +1964,7 @@ func (m *ExtensionManager) GetLyricsProviders() []*ExtensionProviderWrapper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep a deterministic order so provider selection is stable across runs.
|
||||||
sort.Slice(providers, func(i, j int) bool {
|
sort.Slice(providers, func(i, j int) bool {
|
||||||
return providers[i].extension.ID < providers[j].extension.ID
|
return providers[i].extension.ID < providers[j].extension.ID
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
|
|
||||||
original := GetMetadataProviderPriority()
|
|
||||||
defer SetMetadataProviderPriority(original)
|
|
||||||
|
|
||||||
SetMetadataProviderPriority([]string{"tidal"})
|
|
||||||
got := GetMetadataProviderPriority()
|
|
||||||
want := []string{"tidal", "deezer", "qobuz"}
|
|
||||||
if len(got) != len(want) {
|
|
||||||
t.Fatalf("unexpected priority length: got %v want %v", got, want)
|
|
||||||
}
|
|
||||||
for i := range want {
|
|
||||||
if got[i] != want[i] {
|
|
||||||
t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
|
|
||||||
originalPriority := GetMetadataProviderPriority()
|
|
||||||
originalSearch := searchBuiltInMetadataTracksFunc
|
|
||||||
defer func() {
|
|
||||||
SetMetadataProviderPriority(originalPriority)
|
|
||||||
searchBuiltInMetadataTracksFunc = originalSearch
|
|
||||||
}()
|
|
||||||
|
|
||||||
SetMetadataProviderPriority([]string{"qobuz", "tidal", "deezer"})
|
|
||||||
|
|
||||||
var calls []string
|
|
||||||
searchBuiltInMetadataTracksFunc = func(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
|
|
||||||
calls = append(calls, providerID)
|
|
||||||
switch providerID {
|
|
||||||
case "qobuz":
|
|
||||||
return []ExtTrackMetadata{
|
|
||||||
{ProviderID: "qobuz", SpotifyID: "qobuz:1", ISRC: "AAA111", Name: "First"},
|
|
||||||
}, nil
|
|
||||||
case "tidal":
|
|
||||||
return []ExtTrackMetadata{
|
|
||||||
{ProviderID: "tidal", SpotifyID: "tidal:2", ISRC: "AAA111", Name: "Duplicate"},
|
|
||||||
{ProviderID: "tidal", SpotifyID: "tidal:3", ISRC: "BBB222", Name: "Second"},
|
|
||||||
}, nil
|
|
||||||
case "deezer":
|
|
||||||
return []ExtTrackMetadata{
|
|
||||||
{ProviderID: "deezer", SpotifyID: "deezer:4", ISRC: "CCC333", Name: "Third"},
|
|
||||||
}, nil
|
|
||||||
default:
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
manager := GetExtensionManager()
|
|
||||||
tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
|
|
||||||
}
|
|
||||||
if len(tracks) != 3 {
|
|
||||||
t.Fatalf("unexpected track count: got %d want 3", len(tracks))
|
|
||||||
}
|
|
||||||
if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" || tracks[2].ProviderID != "deezer" {
|
|
||||||
t.Fatalf("unexpected track provider order: %+v", tracks)
|
|
||||||
}
|
|
||||||
if len(calls) != 3 || calls[0] != "qobuz" || calls[1] != "tidal" || calls[2] != "deezer" {
|
|
||||||
t.Fatalf("unexpected provider call order: %v", calls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -81,17 +81,13 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ExtensionRuntime struct {
|
type ExtensionRuntime struct {
|
||||||
extensionID string
|
extensionID string
|
||||||
manifest *ExtensionManifest
|
manifest *ExtensionManifest
|
||||||
settings map[string]interface{}
|
settings map[string]interface{}
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
downloadClient *http.Client
|
cookieJar http.CookieJar
|
||||||
cookieJar http.CookieJar
|
dataDir string
|
||||||
dataDir string
|
vm *goja.Runtime
|
||||||
vm *goja.Runtime
|
|
||||||
|
|
||||||
activeDownloadMu sync.RWMutex
|
|
||||||
activeDownloadItemID string
|
|
||||||
|
|
||||||
storageMu sync.RWMutex
|
storageMu sync.RWMutex
|
||||||
storageCache map[string]interface{}
|
storageCache map[string]interface{}
|
||||||
@@ -136,38 +132,13 @@ 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: timeout,
|
Timeout: 30 * time.Second,
|
||||||
Jar: jar,
|
Jar: jar,
|
||||||
}
|
}
|
||||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||||
@@ -194,7 +165,9 @@ func newExtensionHTTPClient(ext *LoadedExtension, jar http.CookieJar, timeout ti
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return client
|
runtime.httpClient = client
|
||||||
|
|
||||||
|
return runtime
|
||||||
}
|
}
|
||||||
|
|
||||||
type RedirectBlockedError struct {
|
type RedirectBlockedError struct {
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ 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
|
||||||
@@ -225,6 +226,7 @@ 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[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +283,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{}{
|
||||||
@@ -385,6 +388,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{}{
|
||||||
|
|||||||
@@ -174,12 +174,7 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
||||||
client := r.downloadClient
|
resp, err := r.httpClient.Do(req)
|
||||||
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,
|
||||||
@@ -205,22 +200,13 @@ 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 := progressWriter.Write(buf[0:nr])
|
nw, ew := out.Write(buf[0:nr])
|
||||||
if nw < 0 || nr < nw {
|
if nw < 0 || nr < nw {
|
||||||
nw = 0
|
nw = 0
|
||||||
if ew == nil {
|
if ew == nil {
|
||||||
@@ -229,12 +215,6 @@ 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),
|
||||||
|
|||||||
@@ -118,7 +118,6 @@ 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,
|
||||||
})
|
})
|
||||||
@@ -215,7 +214,6 @@ 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,
|
||||||
})
|
})
|
||||||
@@ -324,7 +322,6 @@ 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,
|
||||||
})
|
})
|
||||||
@@ -449,7 +446,6 @@ 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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// These polyfills make porting browser/Node.js libraries easier
|
||||||
|
// without compromising sandbox security.
|
||||||
|
|
||||||
|
// Returns a Promise-like object with json(), text() methods.
|
||||||
func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
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")
|
||||||
@@ -34,6 +38,7 @@ 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:
|
||||||
@@ -105,7 +110,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", resp.Request.URL.String())
|
responseObj.Set("url", urlStr)
|
||||||
|
|
||||||
bodyString := string(body)
|
bodyString := string(body)
|
||||||
|
|
||||||
@@ -192,6 +197,7 @@ 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})
|
||||||
}
|
}
|
||||||
@@ -416,6 +422,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') {
|
||||||
|
|||||||
+49
-146
@@ -8,7 +8,6 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -21,7 +20,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 +40,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 +50,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 +96,8 @@ type storeExtensionResponse struct {
|
|||||||
HasUpdate bool `json:"has_update"`
|
HasUpdate bool `json:"has_update"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *storeExtension) toResponse() storeExtensionResponse {
|
func (e *StoreExtension) ToResponse() StoreExtensionResponse {
|
||||||
resp := storeExtensionResponse{
|
return StoreExtensionResponse{
|
||||||
ID: e.ID,
|
ID: e.ID,
|
||||||
Name: e.Name,
|
Name: e.Name,
|
||||||
DisplayName: e.getDisplayName(),
|
DisplayName: e.getDisplayName(),
|
||||||
@@ -108,85 +107,55 @@ 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 (
|
||||||
globalExtensionStore *extensionStore
|
extensionStore *ExtensionStore
|
||||||
extensionStoreMu sync.Mutex
|
extensionStoreMu sync.Mutex
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
cacheTTL = 30 * time.Minute
|
defaultRegistryURL = "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Extension/main/registry.json"
|
||||||
cacheFileName = "store_cache.json"
|
cacheTTL = 30 * time.Minute
|
||||||
|
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 globalExtensionStore == nil {
|
if extensionStore == nil {
|
||||||
globalExtensionStore = &extensionStore{
|
extensionStore = &ExtensionStore{
|
||||||
registryURL: "",
|
registryURL: defaultRegistryURL,
|
||||||
cacheDir: cacheDir,
|
cacheDir: cacheDir,
|
||||||
cacheTTL: cacheTTL,
|
cacheTTL: cacheTTL,
|
||||||
}
|
}
|
||||||
globalExtensionStore.loadDiskCache()
|
extensionStore.loadDiskCache()
|
||||||
}
|
}
|
||||||
return globalExtensionStore
|
return extensionStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *extensionStore) setRegistryURL(registryURL string) {
|
func GetExtensionStore() *ExtensionStore {
|
||||||
s.cacheMu.Lock()
|
|
||||||
defer s.cacheMu.Unlock()
|
|
||||||
|
|
||||||
if s.registryURL == registryURL {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.registryURL = registryURL
|
|
||||||
s.cache = nil
|
|
||||||
s.cacheTime = time.Time{}
|
|
||||||
|
|
||||||
if s.cacheDir != "" {
|
|
||||||
cachePath := filepath.Join(s.cacheDir, cacheFileName)
|
|
||||||
os.Remove(cachePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
LogInfo("ExtensionStore", "Registry URL updated to: %s", registryURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *extensionStore) getRegistryURL() string {
|
|
||||||
s.cacheMu.RLock()
|
|
||||||
defer s.cacheMu.RUnlock()
|
|
||||||
return s.registryURL
|
|
||||||
}
|
|
||||||
|
|
||||||
func getExtensionStore() *extensionStore {
|
|
||||||
extensionStoreMu.Lock()
|
extensionStoreMu.Lock()
|
||||||
defer extensionStoreMu.Unlock()
|
defer extensionStoreMu.Unlock()
|
||||||
return globalExtensionStore
|
return extensionStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *extensionStore) loadDiskCache() {
|
func (s *ExtensionStore) loadDiskCache() {
|
||||||
if s.cacheDir == "" {
|
if s.cacheDir == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -198,7 +167,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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,13 +180,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,
|
||||||
@@ -233,14 +202,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()
|
||||||
|
|
||||||
if s.registryURL == "" {
|
|
||||||
return nil, fmt.Errorf("no registry URL configured. Please add a repository URL first")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !forceRefresh && s.cache != nil && time.Since(s.cacheTime) < s.cacheTTL {
|
if !forceRefresh && s.cache != nil && time.Since(s.cacheTime) < s.cacheTTL {
|
||||||
LogDebug("ExtensionStore", "Using cached registry (%d extensions)", len(s.cache.Extensions))
|
LogDebug("ExtensionStore", "Using cached registry (%d extensions)", len(s.cache.Extensions))
|
||||||
return s.cache, nil
|
return s.cache, nil
|
||||||
@@ -276,7 +241,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,8 +254,8 @@ func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error
|
|||||||
return ®istry, nil
|
return ®istry, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExtensionResponse, error) {
|
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
|
||||||
registry, err := s.fetchRegistry(forceRefresh)
|
registry, err := s.FetchRegistry(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -304,32 +269,29 @@ func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExte
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LogDebug("ExtensionStore", "Building store response for %d registry extensions (%d installed)", len(registry.Extensions), len(installed))
|
result := make([]StoreExtensionResponse, len(registry.Extensions))
|
||||||
|
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 = append(result, resp)
|
result[i] = 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
|
||||||
@@ -374,68 +336,6 @@ func (s *extensionStore) downloadExtension(extensionID string, destPath string)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveRegistryURL(input string) (string, error) {
|
|
||||||
input = strings.TrimSpace(input)
|
|
||||||
if input == "" {
|
|
||||||
return "", fmt.Errorf("registry URL is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(input, "raw.githubusercontent.com") {
|
|
||||||
return input, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const ghPrefix = "https://github.com/"
|
|
||||||
if !strings.HasPrefix(input, ghPrefix) {
|
|
||||||
const ghPrefixHTTP = "http://github.com/"
|
|
||||||
if strings.HasPrefix(input, ghPrefixHTTP) {
|
|
||||||
input = "https://github.com/" + input[len(ghPrefixHTTP):]
|
|
||||||
} else {
|
|
||||||
return input, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
path := input[len(ghPrefix):]
|
|
||||||
parts := strings.SplitN(path, "/", 3) // owner, repo, [rest]
|
|
||||||
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
|
|
||||||
return "", fmt.Errorf("invalid GitHub URL: expected github.com/<owner>/<repo>")
|
|
||||||
}
|
|
||||||
owner := parts[0]
|
|
||||||
repo := strings.TrimSuffix(parts[1], ".git")
|
|
||||||
|
|
||||||
branch := resolveGitHubDefaultBranch(owner, repo)
|
|
||||||
|
|
||||||
resolved := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/registry.json", owner, repo, branch)
|
|
||||||
LogInfo("ExtensionStore", "Resolved %s → %s (branch: %s)", input, resolved, branch)
|
|
||||||
return resolved, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveGitHubDefaultBranch(owner, repo string) string {
|
|
||||||
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repo)
|
|
||||||
client := NewHTTPClientWithTimeout(10 * time.Second)
|
|
||||||
|
|
||||||
resp, err := client.Get(apiURL)
|
|
||||||
if err != nil {
|
|
||||||
LogWarn("ExtensionStore", "GitHub API request failed for %s/%s: %v – falling back to main", owner, repo, err)
|
|
||||||
return "main"
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
LogWarn("ExtensionStore", "GitHub API returned %d for %s/%s – falling back to main", resp.StatusCode, owner, repo)
|
|
||||||
return "main"
|
|
||||||
}
|
|
||||||
|
|
||||||
var info struct {
|
|
||||||
DefaultBranch string `json:"default_branch"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil || info.DefaultBranch == "" {
|
|
||||||
LogWarn("ExtensionStore", "Could not parse default_branch for %s/%s – falling back to main", owner, repo)
|
|
||||||
return "main"
|
|
||||||
}
|
|
||||||
|
|
||||||
return info.DefaultBranch
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireHTTPSURL(rawURL string, context string) error {
|
func requireHTTPSURL(rawURL string, context string) error {
|
||||||
if rawURL == "" {
|
if rawURL == "" {
|
||||||
return fmt.Errorf("%s URL is empty", context)
|
return fmt.Errorf("%s URL is empty", context)
|
||||||
@@ -450,7 +350,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,
|
||||||
@@ -460,8 +360,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(false)
|
extensions, err := s.GetExtensionsWithStatus()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -470,19 +370,22 @@ func (s *extensionStore) searchExtensions(query string, category string) ([]stor
|
|||||||
return extensions, nil
|
return extensions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]storeExtensionResponse, 0, len(extensions))
|
var result []StoreExtensionResponse
|
||||||
queryLower := toLower(query)
|
queryLower := toLower(query)
|
||||||
|
|
||||||
for _, ext := range extensions {
|
for _, ext := range extensions {
|
||||||
|
// Filter by category
|
||||||
if category != "" && ext.Category != category {
|
if category != "" && ext.Category != category {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter by query
|
||||||
if query != "" {
|
if query != "" {
|
||||||
if !containsIgnoreCase(ext.Name, queryLower) &&
|
if !containsIgnoreCase(ext.Name, queryLower) &&
|
||||||
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
||||||
!containsIgnoreCase(ext.Description, queryLower) &&
|
!containsIgnoreCase(ext.Description, queryLower) &&
|
||||||
!containsIgnoreCase(ext.Author, queryLower) {
|
!containsIgnoreCase(ext.Author, queryLower) {
|
||||||
|
// Check tags
|
||||||
found := false
|
found := false
|
||||||
for _, tag := range ext.Tags {
|
for _, tag := range ext.Tags {
|
||||||
if containsIgnoreCase(tag, queryLower) {
|
if containsIgnoreCase(tag, queryLower) {
|
||||||
@@ -502,7 +405,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()
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -73,11 +69,6 @@ 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 {
|
||||||
@@ -87,10 +78,7 @@ 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(60 * time.Second):
|
case <-time.After(1 * 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,
|
||||||
@@ -104,9 +92,8 @@ 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)
|
||||||
|
|
||||||
if vm != nil {
|
// Clear any interrupt state so VM can be reused
|
||||||
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.8
|
toolchain go1.25.7
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
|
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5
|
||||||
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-20260312152759-81488f6aeb60
|
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864
|
||||||
golang.org/x/net v0.52.0
|
golang.org/x/net v0.50.0
|
||||||
golang.org/x/text v0.35.0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
github.com/andybalholm/brotli v1.0.6 // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
|
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
||||||
github.com/klauspost/compress v1.18.5 // indirect
|
github.com/klauspost/compress v1.17.4 // indirect
|
||||||
golang.org/x/crypto v0.49.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/mod v0.34.0 // indirect
|
golang.org/x/mod v0.33.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/tools v0.43.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
|
golang.org/x/tools v0.42.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
+46
-30
@@ -1,51 +1,67 @@
|
|||||||
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.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
|
||||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||||
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.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk=
|
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
||||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||||
|
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw=
|
||||||
|
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/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.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
github.com/go-sourcemap/sourcemap v2.1.3+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-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
|
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
|
||||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
|
||||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
||||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||||
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=
|
||||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60 h1:MOzyaj0wu2xneBkzkg9LHNYjDBB4W5vP043A2SYQRPA=
|
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4=
|
||||||
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60/go.mod h1:th6VJvzjMbrYF8SduQY5rpD0HG0GleGxjadkqSxFs3k=
|
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
|
||||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af h1:VqXrZNyqFISxo0rNDFZQlRDRIp7RXSJDeh/LbrK+W1k=
|
||||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af/go.mod h1:tbwefIr7RlQD1OpZ0KEZ9nux/uiihAOGdafgZfJkmII=
|
||||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4=
|
||||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/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=
|
||||||
|
|||||||
+16
-5
@@ -31,7 +31,7 @@ func getRandomUserAgent() string {
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
DefaultTimeout = 60 * time.Second
|
DefaultTimeout = 60 * time.Second
|
||||||
DownloadTimeout = 24 * time.Hour
|
DownloadTimeout = 120 * time.Second
|
||||||
SongLinkTimeout = 30 * time.Second
|
SongLinkTimeout = 30 * time.Second
|
||||||
DefaultMaxRetries = 3
|
DefaultMaxRetries = 3
|
||||||
DefaultRetryDelay = 1 * time.Second
|
DefaultRetryDelay = 1 * time.Second
|
||||||
@@ -66,6 +66,9 @@ 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,
|
||||||
@@ -101,6 +104,8 @@ 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),
|
||||||
@@ -224,6 +229,7 @@ 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)
|
||||||
@@ -233,6 +239,7 @@ 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
|
||||||
@@ -293,11 +300,14 @@ 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",
|
||||||
@@ -336,12 +346,11 @@ func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Dur
|
|||||||
return min(nextDelay, config.MaxDelay)
|
return min(nextDelay, config.MaxDelay)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns 0 if the header is missing or invalid so callers can keep their
|
// Returns 60 seconds as default if header is missing or invalid
|
||||||
// normal exponential backoff instead of stalling for an arbitrary minute.
|
|
||||||
func getRetryAfterDuration(resp *http.Response) time.Duration {
|
func getRetryAfterDuration(resp *http.Response) time.Duration {
|
||||||
retryAfter := resp.Header.Get("Retry-After")
|
retryAfter := resp.Header.Get("Retry-After")
|
||||||
if retryAfter == "" {
|
if retryAfter == "" {
|
||||||
return 0
|
return 60 * time.Second // Default wait time
|
||||||
}
|
}
|
||||||
|
|
||||||
if seconds, err := strconv.Atoi(retryAfter); err == nil {
|
if seconds, err := strconv.Atoi(retryAfter); err == nil {
|
||||||
@@ -355,7 +364,7 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0
|
return 60 * time.Second // Default
|
||||||
}
|
}
|
||||||
|
|
||||||
func ReadResponseBody(resp *http.Response) ([]byte, error) {
|
func ReadResponseBody(resp *http.Response) ([]byte, error) {
|
||||||
@@ -508,6 +517,7 @@ 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 {
|
||||||
@@ -542,6 +552,7 @@ 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,10 +6,17 @@ 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,6 +16,8 @@ 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
|
||||||
@@ -96,15 +98,21 @@ 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()
|
||||||
@@ -135,6 +143,7 @@ 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,
|
||||||
@@ -145,6 +154,7 @@ 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,6 +10,8 @@ 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
|
||||||
}
|
}
|
||||||
@@ -53,6 +55,7 @@ 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()
|
||||||
|
|
||||||
@@ -106,6 +109,7 @@ 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)
|
||||||
|
|
||||||
|
|||||||
+109
-277
@@ -1,38 +1,35 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
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"`
|
DiscNumber int `json:"discNumber,omitempty"`
|
||||||
Duration int `json:"duration,omitempty"`
|
Duration int `json:"duration,omitempty"`
|
||||||
ReleaseDate string `json:"releaseDate,omitempty"`
|
ReleaseDate string `json:"releaseDate,omitempty"`
|
||||||
BitDepth int `json:"bitDepth,omitempty"`
|
BitDepth int `json:"bitDepth,omitempty"`
|
||||||
SampleRate int `json:"sampleRate,omitempty"`
|
SampleRate int `json:"sampleRate,omitempty"`
|
||||||
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis)
|
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis)
|
||||||
Genre string `json:"genre,omitempty"`
|
Genre string `json:"genre,omitempty"`
|
||||||
Format string `json:"format,omitempty"`
|
Format string `json:"format,omitempty"`
|
||||||
MetadataFromFilename bool `json:"metadataFromFilename,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type LibraryScanProgress struct {
|
type LibraryScanProgress struct {
|
||||||
@@ -66,9 +63,6 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,11 +71,6 @@ type libraryAudioFileInfo struct {
|
|||||||
modTime int64
|
modTime int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type scannedCueFileInfo struct {
|
|
||||||
sheet *CueSheet
|
|
||||||
audioPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) {
|
func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) {
|
||||||
var files []libraryAudioFileInfo
|
var files []libraryAudioFileInfo
|
||||||
|
|
||||||
@@ -155,7 +144,12 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
return "[]", err
|
return "[]", err
|
||||||
}
|
}
|
||||||
|
|
||||||
totalFiles := len(audioFileInfos)
|
audioFiles := make([]string, 0, len(audioFileInfos))
|
||||||
|
for _, fileInfo := range audioFileInfos {
|
||||||
|
audioFiles = append(audioFiles, fileInfo.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalFiles := len(audioFiles)
|
||||||
libraryScanProgressMu.Lock()
|
libraryScanProgressMu.Lock()
|
||||||
libraryScanProgress.TotalFiles = totalFiles
|
libraryScanProgress.TotalFiles = totalFiles
|
||||||
libraryScanProgressMu.Unlock()
|
libraryScanProgressMu.Unlock()
|
||||||
@@ -173,29 +167,24 @@ 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)
|
|
||||||
|
|
||||||
for _, fileInfo := range audioFileInfos {
|
// First pass: scan .cue files to collect referenced audio paths
|
||||||
filePath := fileInfo.path
|
for _, filePath := range audioFiles {
|
||||||
ext := strings.ToLower(filepath.Ext(filePath))
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
if ext == ".cue" {
|
if ext == ".cue" {
|
||||||
sheet, err := ParseCueFile(filePath)
|
sheet, err := ParseCueFile(filePath)
|
||||||
if err == nil && sheet.FileName != "" {
|
if err == nil && sheet.FileName != "" {
|
||||||
audioPath := ResolveCueAudioPath(filePath, sheet.FileName)
|
audioPath := ResolveCueAudioPath(filePath, sheet.FileName)
|
||||||
if audioPath != "" {
|
if audioPath != "" {
|
||||||
parsedCueFiles[filePath] = scannedCueFileInfo{
|
|
||||||
sheet: sheet,
|
|
||||||
audioPath: audioPath,
|
|
||||||
}
|
|
||||||
cueReferencedAudioFiles[audioPath] = true
|
cueReferencedAudioFiles[audioPath] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, fileInfo := range audioFileInfos {
|
for i, filePath := range audioFiles {
|
||||||
filePath := fileInfo.path
|
|
||||||
select {
|
select {
|
||||||
case <-cancelCh:
|
case <-cancelCh:
|
||||||
return "[]", fmt.Errorf("scan cancelled")
|
return "[]", fmt.Errorf("scan cancelled")
|
||||||
@@ -210,22 +199,9 @@ 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
|
cueResults, err := ScanCueFileForLibrary(filePath, scanTime)
|
||||||
cueInfo, ok := parsedCueFiles[filePath]
|
|
||||||
if ok {
|
|
||||||
cueResults, err = scanCueSheetForLibrary(
|
|
||||||
filePath,
|
|
||||||
cueInfo.sheet,
|
|
||||||
cueInfo.audioPath,
|
|
||||||
"",
|
|
||||||
fileInfo.modTime,
|
|
||||||
"",
|
|
||||||
scanTime,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
cueResults, err = ScanCueFileForLibrary(filePath, scanTime)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorCount++
|
errorCount++
|
||||||
GoLog("[LibraryScan] Error scanning cue %s: %v\n", filePath, err)
|
GoLog("[LibraryScan] Error scanning cue %s: %v\n", filePath, err)
|
||||||
@@ -236,12 +212,14 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip audio files that are referenced by a .cue sheet
|
||||||
|
// (they will be represented by the cue sheet's track entries instead)
|
||||||
if cueReferencedAudioFiles[filePath] {
|
if cueReferencedAudioFiles[filePath] {
|
||||||
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
|
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := scanAudioFileWithKnownModTime(filePath, scanTime, fileInfo.modTime)
|
result, err := scanAudioFile(filePath, scanTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorCount++
|
errorCount++
|
||||||
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
|
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
|
||||||
@@ -267,19 +245,7 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
||||||
return scanAudioFileWithKnownModTimeAndDisplayName(filePath, "", scanTime, 0)
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
}
|
|
||||||
|
|
||||||
func scanAudioFileWithKnownModTime(filePath, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
|
||||||
return scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, "", "", scanTime, knownModTime)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
result := &LibraryScanResult{
|
result := &LibraryScanResult{
|
||||||
ID: generateLibraryID(filePath),
|
ID: generateLibraryID(filePath),
|
||||||
@@ -288,22 +254,15 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ
|
|||||||
Format: strings.TrimPrefix(ext, "."),
|
Format: strings.TrimPrefix(ext, "."),
|
||||||
}
|
}
|
||||||
|
|
||||||
if knownModTime > 0 {
|
if info, err := os.Stat(filePath); err == nil {
|
||||||
result.FileModTime = knownModTime
|
|
||||||
} else if info, err := os.Stat(filePath); err == nil {
|
|
||||||
result.FileModTime = info.ModTime().UnixMilli()
|
result.FileModTime = info.ModTime().UnixMilli()
|
||||||
}
|
}
|
||||||
|
|
||||||
libraryCoverCacheMu.RLock()
|
libraryCoverCacheMu.RLock()
|
||||||
coverCacheDir := libraryCoverCacheDir
|
coverCacheDir := libraryCoverCacheDir
|
||||||
libraryCoverCacheMu.RUnlock()
|
libraryCoverCacheMu.RUnlock()
|
||||||
if coverCacheDir != "" {
|
if coverCacheDir != "" && ext != ".m4a" {
|
||||||
coverPath, err := SaveCoverToCacheWithHintAndKey(
|
coverPath, err := SaveCoverToCache(filePath, coverCacheDir)
|
||||||
filePath,
|
|
||||||
displayNameHint,
|
|
||||||
coverCacheDir,
|
|
||||||
coverCacheKey,
|
|
||||||
)
|
|
||||||
if err == nil && coverPath != "" {
|
if err == nil && coverPath != "" {
|
||||||
result.CoverPath = coverPath
|
result.CoverPath = coverPath
|
||||||
}
|
}
|
||||||
@@ -311,39 +270,21 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ
|
|||||||
|
|
||||||
switch ext {
|
switch ext {
|
||||||
case ".flac":
|
case ".flac":
|
||||||
return scanFLACFile(filePath, result, displayNameHint)
|
return scanFLACFile(filePath, result)
|
||||||
case ".m4a":
|
case ".m4a":
|
||||||
return scanM4AFile(filePath, result, displayNameHint)
|
return scanM4AFile(filePath, result)
|
||||||
case ".mp3":
|
case ".mp3":
|
||||||
return scanMP3File(filePath, result, displayNameHint)
|
return scanMP3File(filePath, result)
|
||||||
case ".opus", ".ogg":
|
case ".opus", ".ogg":
|
||||||
return scanOggFile(filePath, result, displayNameHint)
|
return scanOggFile(filePath, result)
|
||||||
case ".ape", ".wv", ".mpc":
|
|
||||||
return scanAPEFile(filePath, result, displayNameHint)
|
|
||||||
default:
|
default:
|
||||||
return scanFromFilename(filePath, displayNameHint, result)
|
return scanFromFilename(filePath, result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveLibraryAudioExt(filePath, displayNameHint string) string {
|
func applyDefaultLibraryMetadata(filePath string, result *LibraryScanResult) {
|
||||||
ext := strings.ToLower(filepath.Ext(filePath))
|
|
||||||
if ext != "" {
|
|
||||||
return ext
|
|
||||||
}
|
|
||||||
return strings.ToLower(filepath.Ext(displayNameHint))
|
|
||||||
}
|
|
||||||
|
|
||||||
func libraryDisplayNameOrPath(filePath, displayNameHint string) string {
|
|
||||||
if displayNameHint != "" {
|
|
||||||
return displayNameHint
|
|
||||||
}
|
|
||||||
return filePath
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyDefaultLibraryMetadata(filePath, displayNameHint string, result *LibraryScanResult) {
|
|
||||||
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
|
|
||||||
if result.TrackName == "" {
|
if result.TrackName == "" {
|
||||||
result.TrackName = strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
|
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||||
}
|
}
|
||||||
if result.ArtistName == "" {
|
if result.ArtistName == "" {
|
||||||
result.ArtistName = "Unknown Artist"
|
result.ArtistName = "Unknown Artist"
|
||||||
@@ -353,10 +294,10 @@ func applyDefaultLibraryMetadata(filePath, displayNameHint string, result *Libra
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||||
metadata, err := ReadMetadata(filePath)
|
metadata, err := ReadMetadata(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return scanFromFilename(filePath, displayNameHint, result)
|
return scanFromFilename(filePath, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.TrackName = metadata.Title
|
result.TrackName = metadata.Title
|
||||||
@@ -378,48 +319,26 @@ func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
applyDefaultLibraryMetadata(filePath, result)
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
func scanM4AFile(filePath string, result *LibraryScanResult) (*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.DiscNumber = metadata.DiscNumber
|
|
||||||
result.ReleaseDate = metadata.Date
|
|
||||||
if result.ReleaseDate == "" {
|
|
||||||
result.ReleaseDate = metadata.Year
|
|
||||||
}
|
|
||||||
result.Genre = metadata.Genre
|
|
||||||
}
|
|
||||||
|
|
||||||
quality, err := GetM4AQuality(filePath)
|
quality, err := GetM4AQuality(filePath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
result.BitDepth = quality.BitDepth
|
result.BitDepth = quality.BitDepth
|
||||||
result.SampleRate = quality.SampleRate
|
result.SampleRate = quality.SampleRate
|
||||||
}
|
}
|
||||||
|
|
||||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
return scanFromFilename(filePath, result)
|
||||||
return result, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||||
metadata, err := ReadID3Tags(filePath)
|
metadata, err := ReadID3Tags(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
|
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
|
||||||
return scanFromFilename(filePath, displayNameHint, result)
|
return scanFromFilename(filePath, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.TrackName = metadata.Title
|
result.TrackName = metadata.Title
|
||||||
@@ -446,16 +365,16 @@ func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
applyDefaultLibraryMetadata(filePath, result)
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||||
metadata, err := ReadOggVorbisComments(filePath)
|
metadata, err := ReadOggVorbisComments(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[LibraryScan] Ogg/Opus read error for %s: %v\n", filePath, err)
|
GoLog("[LibraryScan] Ogg/Opus read error for %s: %v\n", filePath, err)
|
||||||
return scanFromFilename(filePath, displayNameHint, result)
|
return scanFromFilename(filePath, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.TrackName = metadata.Title
|
result.TrackName = metadata.Title
|
||||||
@@ -478,46 +397,13 @@ func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
applyDefaultLibraryMetadata(filePath, result)
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanAPEFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||||
tag, err := ReadAPETags(filePath)
|
filename := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(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.DiscNumber = metadata.DiscNumber
|
|
||||||
result.Genre = metadata.Genre
|
|
||||||
if metadata.Date != "" {
|
|
||||||
result.ReleaseDate = metadata.Date
|
|
||||||
} else {
|
|
||||||
result.ReleaseDate = metadata.Year
|
|
||||||
}
|
|
||||||
|
|
||||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
|
||||||
result.MetadataFromFilename = true
|
|
||||||
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
|
|
||||||
filename := strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
|
|
||||||
|
|
||||||
parts := strings.SplitN(filename, " - ", 2)
|
parts := strings.SplitN(filename, " - ", 2)
|
||||||
if len(parts) == 2 {
|
if len(parts) == 2 {
|
||||||
@@ -540,7 +426,7 @@ func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResul
|
|||||||
|
|
||||||
dir := filepath.Dir(filePath)
|
dir := filepath.Dir(filePath)
|
||||||
result.AlbumName = filepath.Base(dir)
|
result.AlbumName = filepath.Base(dir)
|
||||||
if result.AlbumName == "." || result.AlbumName == "" || result.AlbumName == "fd" || result.AlbumName == "self" {
|
if result.AlbumName == "." || result.AlbumName == "" {
|
||||||
result.AlbumName = "Unknown Album"
|
result.AlbumName = "Unknown Album"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -587,22 +473,8 @@ func CancelLibraryScan() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ReadAudioMetadata(filePath string) (string, error) {
|
func ReadAudioMetadata(filePath string) (string, error) {
|
||||||
return ReadAudioMetadataWithDisplayName(filePath, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
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 := scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(
|
result, err := scanAudioFile(filePath, scanTime)
|
||||||
filePath,
|
|
||||||
displayNameHint,
|
|
||||||
coverCacheKey,
|
|
||||||
scanTime,
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -615,43 +487,10 @@ func ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayNameHint,
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) {
|
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
|
||||||
existingFiles := make(map[string]int64)
|
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
|
||||||
if snapshotPath == "" {
|
// Only files that are new or have changed modification time will be scanned
|
||||||
return existingFiles, nil
|
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
|
||||||
}
|
|
||||||
|
|
||||||
file, err := os.Open(snapshotPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(file)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parts := strings.SplitN(line, "\t", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
modTime, err := strconv.ParseInt(parts[0], 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
existingFiles[parts[1]] = modTime
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return existingFiles, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFiles map[string]int64) (string, error) {
|
|
||||||
if folderPath == "" {
|
if folderPath == "" {
|
||||||
return "{}", fmt.Errorf("folder path is empty")
|
return "{}", fmt.Errorf("folder path is empty")
|
||||||
}
|
}
|
||||||
@@ -664,6 +503,13 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
return "{}", fmt.Errorf("path is not a folder: %s", folderPath)
|
return "{}", fmt.Errorf("path is not a folder: %s", folderPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
existingFiles := make(map[string]int64)
|
||||||
|
if existingFilesJSON != "" && existingFilesJSON != "{}" {
|
||||||
|
if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil {
|
||||||
|
GoLog("[LibraryScan] Warning: failed to parse existing files JSON: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles))
|
GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles))
|
||||||
|
|
||||||
libraryScanProgressMu.Lock()
|
libraryScanProgressMu.Lock()
|
||||||
@@ -692,27 +538,44 @@ 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)
|
|
||||||
for existingPath, modTime := range existingFiles {
|
// Build a set of existing CUE virtual path base files for incremental matching.
|
||||||
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
|
// CUE tracks are stored with virtual paths like "/path/album.cue#track01".
|
||||||
baseCuePath := existingPath[:idx]
|
// We need to match these against the actual .cue file's modTime.
|
||||||
if _, exists := existingCueTrackModTimes[baseCuePath]; !exists {
|
cueBaseModTimes := make(map[string]int64) // base cue path -> modTime from disk
|
||||||
existingCueTrackModTimes[baseCuePath] = modTime
|
for _, f := range currentFiles {
|
||||||
}
|
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
|
||||||
|
cueBaseModTimes[f.path] = f.modTime
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, f := range currentFiles {
|
for _, f := range currentFiles {
|
||||||
existingModTime, exists := existingFiles[f.path]
|
existingModTime, exists := existingFiles[f.path]
|
||||||
if !exists {
|
if !exists {
|
||||||
|
// For .cue files, also check if any virtual path entries exist
|
||||||
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
|
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
|
||||||
if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks {
|
hasCueTracks := false
|
||||||
if f.modTime == cueTrackModTime {
|
for existingPath := range existingFiles {
|
||||||
skippedCount++
|
if strings.HasPrefix(existingPath, f.path+"#track") {
|
||||||
} else {
|
hasCueTracks = true
|
||||||
filesToScan = append(filesToScan, f)
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasCueTracks {
|
||||||
|
// CUE file exists in DB via virtual paths; check if modTime changed
|
||||||
|
// Use modTime from any virtual path (they all share the same .cue modTime)
|
||||||
|
for existingPath, modTime := range existingFiles {
|
||||||
|
if strings.HasPrefix(existingPath, f.path+"#track") {
|
||||||
|
if f.modTime == modTime {
|
||||||
|
skippedCount++
|
||||||
|
} else {
|
||||||
|
filesToScan = append(filesToScan, f)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -727,11 +590,14 @@ 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
|
continue // Base .cue file still exists, not deleted
|
||||||
}
|
}
|
||||||
|
// 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)
|
||||||
@@ -762,8 +628,8 @@ 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)
|
|
||||||
for _, f := range filesToScan {
|
for _, f := range filesToScan {
|
||||||
ext := strings.ToLower(filepath.Ext(f.path))
|
ext := strings.ToLower(filepath.Ext(f.path))
|
||||||
if ext == ".cue" {
|
if ext == ".cue" {
|
||||||
@@ -771,10 +637,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
if err == nil && sheet.FileName != "" {
|
if err == nil && sheet.FileName != "" {
|
||||||
audioPath := ResolveCueAudioPath(f.path, sheet.FileName)
|
audioPath := ResolveCueAudioPath(f.path, sheet.FileName)
|
||||||
if audioPath != "" {
|
if audioPath != "" {
|
||||||
parsedCueFiles[f.path] = scannedCueFileInfo{
|
|
||||||
sheet: sheet,
|
|
||||||
audioPath: audioPath,
|
|
||||||
}
|
|
||||||
cueReferencedAudioFilesInc[audioPath] = true
|
cueReferencedAudioFilesInc[audioPath] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -796,22 +658,9 @@ 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
|
cueResults, err := ScanCueFileForLibrary(f.path, scanTime)
|
||||||
cueInfo, ok := parsedCueFiles[f.path]
|
|
||||||
if ok {
|
|
||||||
cueResults, err = scanCueSheetForLibrary(
|
|
||||||
f.path,
|
|
||||||
cueInfo.sheet,
|
|
||||||
cueInfo.audioPath,
|
|
||||||
"",
|
|
||||||
f.modTime,
|
|
||||||
"",
|
|
||||||
scanTime,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
cueResults, err = ScanCueFileForLibrary(f.path, scanTime)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorCount++
|
errorCount++
|
||||||
GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err)
|
GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err)
|
||||||
@@ -821,11 +670,12 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip audio files referenced by .cue sheets
|
||||||
if cueReferencedAudioFilesInc[f.path] {
|
if cueReferencedAudioFilesInc[f.path] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := scanAudioFileWithKnownModTime(f.path, scanTime, f.modTime)
|
result, err := scanAudioFile(f.path, scanTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorCount++
|
errorCount++
|
||||||
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
|
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
|
||||||
@@ -859,21 +709,3 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
|
|
||||||
existingFiles := make(map[string]int64)
|
|
||||||
if existingFilesJSON != "" && existingFilesJSON != "{}" {
|
|
||||||
if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil {
|
|
||||||
GoLog("[LibraryScan] Warning: failed to parse existing files JSON: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath string) (string, error) {
|
|
||||||
existingFiles, err := loadExistingFilesSnapshot(snapshotPath)
|
|
||||||
if err != nil {
|
|
||||||
return "{}", fmt.Errorf("failed to load incremental snapshot: %w", err)
|
|
||||||
}
|
|
||||||
return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+15
-1
@@ -25,6 +25,7 @@ type LogBuffer struct {
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
defaultLogBufferSize = 500
|
defaultLogBufferSize = 500
|
||||||
|
maxLogMessageLength = 500
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -51,12 +52,20 @@ func GetLogBuffer() *LogBuffer {
|
|||||||
globalLogBuffer = &LogBuffer{
|
globalLogBuffer = &LogBuffer{
|
||||||
entries: make([]LogEntry, 0, defaultLogBufferSize),
|
entries: make([]LogEntry, 0, defaultLogBufferSize),
|
||||||
maxSize: defaultLogBufferSize,
|
maxSize: defaultLogBufferSize,
|
||||||
loggingEnabled: false,
|
loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
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()
|
||||||
@@ -78,6 +87,7 @@ 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"),
|
||||||
@@ -145,10 +155,13 @@ 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"
|
||||||
|
|
||||||
@@ -160,6 +173,7 @@ 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"
|
||||||
|
|||||||
+217
-17
@@ -20,7 +20,9 @@ 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"
|
||||||
@@ -28,8 +30,11 @@ 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,
|
||||||
@@ -41,6 +46,12 @@ 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"`
|
||||||
@@ -60,6 +71,8 @@ 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()
|
||||||
@@ -69,7 +82,9 @@ 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,
|
||||||
@@ -89,6 +104,7 @@ 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()
|
||||||
@@ -102,13 +118,15 @@ 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": true, "description": "NetEase Cloud Music lyrics via Paxsenix"},
|
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": false, "description": "NetEase Cloud Music (good for Asian songs)"},
|
||||||
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics via Paxsenix"},
|
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Largest lyrics database (multi-language)"},
|
||||||
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics via Paxsenix"},
|
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Word-by-word synced lyrics"},
|
||||||
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics via Paxsenix"},
|
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics (good for Chinese songs)"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +139,7 @@ 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)
|
||||||
|
|
||||||
@@ -136,6 +155,7 @@ 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()
|
||||||
@@ -233,6 +253,18 @@ 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"`
|
||||||
@@ -340,6 +372,172 @@ 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
|
||||||
@@ -364,18 +562,6 @@ 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
|
||||||
@@ -438,6 +624,7 @@ 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)
|
||||||
|
|
||||||
@@ -445,6 +632,9 @@ 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)
|
||||||
|
|
||||||
@@ -526,16 +716,19 @@ 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) {
|
||||||
@@ -544,6 +737,7 @@ 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) {
|
||||||
@@ -552,6 +746,7 @@ 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) {
|
||||||
@@ -559,6 +754,7 @@ 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)
|
||||||
@@ -676,6 +872,8 @@ 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, "{") {
|
||||||
@@ -747,7 +945,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:Implemented by SpotiFLAC-Mobile using Paxsenix API]\n")
|
builder.WriteString("[by:SpotiFLAC-Mobile]\n")
|
||||||
builder.WriteString("\n")
|
builder.WriteString("\n")
|
||||||
|
|
||||||
if lyrics.SyncType == "LINE_SYNCED" {
|
if lyrics.SyncType == "LINE_SYNCED" {
|
||||||
@@ -800,6 +998,8 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
+134
-64
@@ -4,25 +4,124 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
type appleMusicSearchResult struct {
|
// Apple Music token manager — singleton with mutex for thread safety
|
||||||
ID string `json:"id"`
|
type appleTokenManager struct {
|
||||||
SongName string `json:"songName"`
|
mu sync.Mutex
|
||||||
ArtistName string `json:"artistName"`
|
token string
|
||||||
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
|
||||||
@@ -50,70 +149,32 @@ func NewAppleMusicClient() *AppleMusicClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectBestAppleMusicSearchResult(results []appleMusicSearchResult, trackName, artistName string, durationSec float64) *appleMusicSearchResult {
|
// SearchSong searches for a song on Apple Music and returns its ID.
|
||||||
if len(results) == 0 {
|
func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, error) {
|
||||||
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("https://lyrics.paxsenix.org/apple-music/search?q=%s", encodedQuery)
|
searchURL := fmt.Sprintf(
|
||||||
|
"https://amp-api.music.apple.com/v1/catalog/us/search?term=%s&types=songs&limit=5&l=en-US&platform=web&format[resources]=map&include[songs]=artists&extend=artistUrl",
|
||||||
|
encodedQuery,
|
||||||
|
)
|
||||||
|
|
||||||
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")
|
||||||
|
|
||||||
@@ -123,23 +184,28 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec
|
|||||||
}
|
}
|
||||||
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 []appleMusicSearchResult
|
var searchResp appleMusicSearchResponse
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
best := selectBestAppleMusicSearchResult(searchResp, trackName, artistName, durationSec)
|
if searchResp.Results.Songs == nil || len(searchResp.Results.Songs.Data) == 0 {
|
||||||
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 strings.TrimSpace(best.ID), nil
|
return searchResp.Results.Songs.Data[0].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)
|
||||||
|
|
||||||
@@ -247,13 +313,14 @@ 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, durationSec)
|
songID, err := c.SearchSong(trackName, artistName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -266,8 +333,10 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +350,7 @@ 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,143 +45,144 @@ type musixmatchLyricsResponse struct {
|
|||||||
func NewMusixmatchClient() *MusixmatchClient {
|
func NewMusixmatchClient() *MusixmatchClient {
|
||||||
return &MusixmatchClient{
|
return &MusixmatchClient{
|
||||||
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||||
baseURL: "https://lyrics.paxsenix.org/musixmatch/lyrics",
|
baseURL: "http://158.180.60.95",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, durationSec float64, lyricsType, language string) (string, error) {
|
// searchAndGetLyrics searches for a song and retrieves its lyrics in one call.
|
||||||
|
// The Musixmatch proxy returns both search result and lyrics in a single response.
|
||||||
|
func (c *MusixmatchClient) searchAndGetLyrics(trackName, artistName string) (*musixmatchSearchResponse, error) {
|
||||||
if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" {
|
if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" {
|
||||||
return "", fmt.Errorf("empty track or artist name")
|
return nil, fmt.Errorf("empty track or artist name")
|
||||||
}
|
}
|
||||||
|
|
||||||
params := url.Values{}
|
encodedArtist := url.QueryEscape(artistName)
|
||||||
params.Set("t", trackName)
|
encodedTrack := url.QueryEscape(trackName)
|
||||||
params.Set("a", artistName)
|
|
||||||
params.Set("type", lyricsType)
|
fullURL := fmt.Sprintf("%s/v2/full?artist=%s&track=%s", c.baseURL, encodedArtist, encodedTrack)
|
||||||
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 "", fmt.Errorf("failed to create request: %w", err)
|
return nil, 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 "", fmt.Errorf("musixmatch request failed: %w", err)
|
return nil, fmt.Errorf("musixmatch search 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 {
|
||||||
trimmed := strings.TrimSpace(string(body))
|
return nil, fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode)
|
||||||
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 lrcPayload string
|
var result musixmatchSearchResponse
|
||||||
if err := json.Unmarshal(body, &lrcPayload); err == nil {
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
lrcPayload = strings.TrimSpace(lrcPayload)
|
return nil, fmt.Errorf("failed to decode musixmatch response: %w", err)
|
||||||
if lrcPayload == "" {
|
|
||||||
return "", fmt.Errorf("empty musixmatch lyrics payload")
|
|
||||||
}
|
|
||||||
return lrcPayload, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
trimmed := strings.TrimSpace(string(body))
|
return &result, nil
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *MusixmatchClient) FetchLyricsInLanguage(trackName, artistName string, durationSec float64, language string) (*LyricsResponse, error) {
|
// FetchLyricsInLanguage retrieves lyrics from Musixmatch for a specific language code.
|
||||||
|
func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string) (*LyricsResponse, error) {
|
||||||
lang := strings.ToLower(strings.TrimSpace(language))
|
lang := strings.ToLower(strings.TrimSpace(language))
|
||||||
if lang == "" {
|
if songID <= 0 || lang == "" {
|
||||||
return nil, fmt.Errorf("invalid language")
|
return nil, fmt.Errorf("invalid song id or language")
|
||||||
}
|
}
|
||||||
|
|
||||||
lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "translate", lang)
|
fullURL := fmt.Sprintf("%s/v2/full?id=%d&lang=%s", c.baseURL, songID, url.QueryEscape(lang))
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", fullURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("musixmatch language fetch failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("musixmatch language endpoint returned HTTP %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
lines := parseSyncedLyrics(lrcText)
|
var result musixmatchSearchResponse
|
||||||
if len(lines) > 0 {
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
return &LyricsResponse{
|
return nil, fmt.Errorf("failed to decode musixmatch language response: %w", err)
|
||||||
Lines: lines,
|
|
||||||
SyncType: "LINE_SYNCED",
|
|
||||||
PlainLyrics: plainLyricsFromTimedLines(lines),
|
|
||||||
Provider: "Musixmatch",
|
|
||||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
plainLines := plainTextLyricsLines(lrcText)
|
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
||||||
if len(plainLines) > 0 {
|
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
||||||
return &LyricsResponse{
|
if len(lines) > 0 {
|
||||||
Lines: plainLines,
|
return &LyricsResponse{
|
||||||
SyncType: "UNSYNCED",
|
Lines: lines,
|
||||||
PlainLyrics: lrcText,
|
SyncType: "LINE_SYNCED",
|
||||||
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) {
|
||||||
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" {
|
result, err := c.searchAndGetLyrics(trackName, artistName)
|
||||||
localized, localizedErr := c.FetchLyricsInLanguage(trackName, artistName, durationSec, preferred)
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" && result.ID > 0 {
|
||||||
|
localized, localizedErr := c.FetchLyricsInLanguage(result.ID, preferred)
|
||||||
if 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "word", "")
|
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
||||||
if err != nil {
|
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
||||||
return nil, err
|
if len(lines) > 0 {
|
||||||
|
return &LyricsResponse{
|
||||||
|
Lines: lines,
|
||||||
|
SyncType: "LINE_SYNCED",
|
||||||
|
Provider: "Musixmatch",
|
||||||
|
Source: "Musixmatch",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lines := parseSyncedLyrics(lrcText)
|
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
||||||
if len(lines) > 0 {
|
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
|
||||||
return &LyricsResponse{
|
|
||||||
Lines: lines,
|
|
||||||
SyncType: "LINE_SYNCED",
|
|
||||||
PlainLyrics: plainLyricsFromTimedLines(lines),
|
|
||||||
Provider: "Musixmatch",
|
|
||||||
Source: "Musixmatch",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
plainLines := plainTextLyricsLines(lrcText)
|
if len(lines) > 0 {
|
||||||
if len(plainLines) > 0 {
|
return &LyricsResponse{
|
||||||
return &LyricsResponse{
|
Lines: lines,
|
||||||
Lines: plainLines,
|
SyncType: "UNSYNCED",
|
||||||
SyncType: "UNSYNCED",
|
PlainLyrics: result.UnsyncedLyrics.Lyrics,
|
||||||
PlainLyrics: lrcText,
|
Provider: "Musixmatch",
|
||||||
Provider: "Musixmatch",
|
Source: "Musixmatch",
|
||||||
Source: "Musixmatch",
|
}, nil
|
||||||
}, nil
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("no lyrics found on musixmatch")
|
return nil, fmt.Errorf("no lyrics found on musixmatch")
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ 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
|
||||||
}
|
}
|
||||||
@@ -50,15 +52,19 @@ 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 := "https://lyrics.paxsenix.org/netease/search"
|
searchURL := "http://music.163.com/api/search/pc"
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Set("q", query)
|
params.Set("s", query)
|
||||||
|
params.Set("type", "1")
|
||||||
|
params.Set("limit", "1")
|
||||||
|
params.Set("offset", "0")
|
||||||
|
|
||||||
fullURL := searchURL + "?" + params.Encode()
|
fullURL := searchURL + "?" + params.Encode()
|
||||||
|
|
||||||
@@ -94,10 +100,14 @@ 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 := "https://lyrics.paxsenix.org/netease/lyrics"
|
lyricsURL := "http://music.163.com/api/song/lyric"
|
||||||
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()
|
||||||
|
|
||||||
@@ -143,6 +153,7 @@ 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,
|
||||||
@@ -162,6 +173,7 @@ 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,29 +1,45 @@
|
|||||||
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 qqLyricsMetadataRequest struct {
|
type qqMusicSearchResponse struct {
|
||||||
Artist []string `json:"artist"`
|
Data struct {
|
||||||
Album string `json:"album,omitempty"`
|
Song struct {
|
||||||
SongID int64 `json:"songid,omitempty"`
|
List []struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Duration int64 `json:"duration,omitempty"`
|
Singer []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"singer"`
|
||||||
|
Album struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"album"`
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
} `json:"list"`
|
||||||
|
} `json:"song"`
|
||||||
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type qqLyricsMetadataResponse struct {
|
// QQ Music lyrics request payload for paxsenix proxy
|
||||||
Lyrics []paxLyrics `json:"lyrics"`
|
type qqLyricsPayload struct {
|
||||||
|
Artist []string `json:"artist"`
|
||||||
|
Album string `json:"album"`
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewQQMusicClient() *QQMusicClient {
|
func NewQQMusicClient() *QQMusicClient {
|
||||||
@@ -32,28 +48,79 @@ func NewQQMusicClient() *QQMusicClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, durationSec float64) (string, error) {
|
// searchSong searches QQ Music and returns the song info needed for lyrics fetch.
|
||||||
payload := qqLyricsMetadataRequest{
|
func (c *QQMusicClient) searchSong(trackName, artistName string) (*qqLyricsPayload, error) {
|
||||||
Artist: []string{artistName},
|
query := trackName + " " + artistName
|
||||||
Title: trackName,
|
if strings.TrimSpace(query) == "" {
|
||||||
}
|
return nil, fmt.Errorf("empty search query")
|
||||||
if durationSec > 0 {
|
|
||||||
payload.Duration = int64(math.Round(durationSec))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lyricsURL := "https://lyrics.paxsenix.org/qq/lyrics-metadata"
|
searchURL := "https://c.y.qq.com/soso/fcgi-bin/client_search_cp"
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("format", "json")
|
||||||
|
params.Set("inCharset", "utf8")
|
||||||
|
params.Set("outCharset", "utf8")
|
||||||
|
params.Set("platform", "yqq.json")
|
||||||
|
params.Set("new_json", "1")
|
||||||
|
params.Set("w", query)
|
||||||
|
|
||||||
|
fullURL := searchURL + "?" + params.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", fullURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("qqmusic search failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("qqmusic search returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchResp qqMusicSearchResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode qqmusic response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(searchResp.Data.Song.List) == 0 {
|
||||||
|
return nil, fmt.Errorf("no songs found on qqmusic")
|
||||||
|
}
|
||||||
|
|
||||||
|
song := searchResp.Data.Song.List[0]
|
||||||
|
|
||||||
|
var artists []string
|
||||||
|
for _, singer := range song.Singer {
|
||||||
|
artists = append(artists, singer.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &qqLyricsPayload{
|
||||||
|
Artist: artists,
|
||||||
|
Album: song.Album.Name,
|
||||||
|
ID: song.ID,
|
||||||
|
Title: song.Title,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchLyricsByPayload fetches lyrics from the paxsenix proxy using QQ Music song info.
|
||||||
|
func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string, error) {
|
||||||
|
lyricsURL := "https://paxsenix.alwaysdata.net/getQQLyrics.php"
|
||||||
|
|
||||||
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, strings.NewReader(string(payloadBytes)))
|
req, err := http.NewRequest("POST", lyricsURL, bytes.NewReader(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)
|
||||||
@@ -79,24 +146,19 @@ func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, dura
|
|||||||
return bodyStr, nil
|
return bodyStr, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatQQLyricsMetadataToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
|
// FetchLyrics searches QQ Music and returns parsed LyricsResponse.
|
||||||
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) {
|
||||||
rawLyrics, err := c.fetchLyricsByMetadata(trackName, artistName, durationSec)
|
payload, err := c.searchSong(trackName, artistName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rawLyrics, err := c.fetchLyricsByPayload(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -104,13 +166,11 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
lrcText, err := formatQQLyricsMetadataToLRC(rawLyrics, multiPersonWordByWord)
|
// Try to parse as pax format (word-by-word or line)
|
||||||
|
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if fallback, fallbackErr := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord); fallbackErr == nil {
|
// If pax parsing fails, try to use as direct LRC text
|
||||||
lrcText = fallback
|
lrcText = rawLyrics
|
||||||
} else {
|
|
||||||
lrcText = rawLyrics
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lines := parseSyncedLyrics(lrcText)
|
lines := parseSyncedLyrics(lrcText)
|
||||||
|
|||||||
+167
-798
File diff suppressed because it is too large
Load Diff
@@ -1,67 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
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"`
|
|
||||||
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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
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"`
|
|
||||||
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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
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"`
|
|
||||||
}
|
|
||||||
+4
-31
@@ -34,16 +34,10 @@ var (
|
|||||||
downloadDir string
|
downloadDir string
|
||||||
downloadDirMu sync.RWMutex
|
downloadDirMu sync.RWMutex
|
||||||
|
|
||||||
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
||||||
multiMu sync.RWMutex
|
multiMu sync.RWMutex
|
||||||
multiProgressDirty = true
|
|
||||||
cachedMultiProgress = "{\"items\":{}}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func markMultiProgressDirtyLocked() {
|
|
||||||
multiProgressDirty = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func getProgress() DownloadProgress {
|
func getProgress() DownloadProgress {
|
||||||
multiMu.RLock()
|
multiMu.RLock()
|
||||||
defer multiMu.RUnlock()
|
defer multiMu.RUnlock()
|
||||||
@@ -64,25 +58,13 @@ func getProgress() DownloadProgress {
|
|||||||
|
|
||||||
func GetMultiProgress() string {
|
func GetMultiProgress() string {
|
||||||
multiMu.RLock()
|
multiMu.RLock()
|
||||||
if !multiProgressDirty {
|
defer multiMu.RUnlock()
|
||||||
cached := cachedMultiProgress
|
|
||||||
multiMu.RUnlock()
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
multiMu.RUnlock()
|
|
||||||
|
|
||||||
multiMu.Lock()
|
|
||||||
defer multiMu.Unlock()
|
|
||||||
if !multiProgressDirty {
|
|
||||||
return cachedMultiProgress
|
|
||||||
}
|
|
||||||
jsonBytes, err := json.Marshal(multiProgress)
|
jsonBytes, err := json.Marshal(multiProgress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "{\"items\":{}}"
|
return "{\"items\":{}}"
|
||||||
}
|
}
|
||||||
cachedMultiProgress = string(jsonBytes)
|
return string(jsonBytes)
|
||||||
multiProgressDirty = false
|
|
||||||
return cachedMultiProgress
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetItemProgress(itemID string) string {
|
func GetItemProgress(itemID string) string {
|
||||||
@@ -108,7 +90,6 @@ func StartItemProgress(itemID string) {
|
|||||||
IsDownloading: true,
|
IsDownloading: true,
|
||||||
Status: "downloading",
|
Status: "downloading",
|
||||||
}
|
}
|
||||||
markMultiProgressDirtyLocked()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetItemBytesTotal(itemID string, total int64) {
|
func SetItemBytesTotal(itemID string, total int64) {
|
||||||
@@ -117,7 +98,6 @@ func SetItemBytesTotal(itemID string, total int64) {
|
|||||||
|
|
||||||
if item, ok := multiProgress.Items[itemID]; ok {
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
item.BytesTotal = total
|
item.BytesTotal = total
|
||||||
markMultiProgressDirtyLocked()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +110,6 @@ func SetItemBytesReceived(itemID string, received int64) {
|
|||||||
if item.BytesTotal > 0 {
|
if item.BytesTotal > 0 {
|
||||||
item.Progress = float64(received) / float64(item.BytesTotal)
|
item.Progress = float64(received) / float64(item.BytesTotal)
|
||||||
}
|
}
|
||||||
markMultiProgressDirtyLocked()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +123,6 @@ func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps floa
|
|||||||
if item.BytesTotal > 0 {
|
if item.BytesTotal > 0 {
|
||||||
item.Progress = float64(received) / float64(item.BytesTotal)
|
item.Progress = float64(received) / float64(item.BytesTotal)
|
||||||
}
|
}
|
||||||
markMultiProgressDirtyLocked()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +134,6 @@ func CompleteItemProgress(itemID string) {
|
|||||||
item.Progress = 1.0
|
item.Progress = 1.0
|
||||||
item.IsDownloading = false
|
item.IsDownloading = false
|
||||||
item.Status = "completed"
|
item.Status = "completed"
|
||||||
markMultiProgressDirtyLocked()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +149,6 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal
|
|||||||
if bytesTotal > 0 {
|
if bytesTotal > 0 {
|
||||||
item.BytesTotal = bytesTotal
|
item.BytesTotal = bytesTotal
|
||||||
}
|
}
|
||||||
markMultiProgressDirtyLocked()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +159,6 @@ func SetItemFinalizing(itemID string) {
|
|||||||
if item, ok := multiProgress.Items[itemID]; ok {
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
item.Progress = 1.0
|
item.Progress = 1.0
|
||||||
item.Status = "finalizing"
|
item.Status = "finalizing"
|
||||||
markMultiProgressDirtyLocked()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +167,6 @@ func RemoveItemProgress(itemID string) {
|
|||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
delete(multiProgress.Items, itemID)
|
delete(multiProgress.Items, itemID)
|
||||||
markMultiProgressDirtyLocked()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ClearAllItemProgress() {
|
func ClearAllItemProgress() {
|
||||||
@@ -200,7 +174,6 @@ func ClearAllItemProgress() {
|
|||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
multiProgress.Items = make(map[string]*ItemProgress)
|
multiProgress.Items = make(map[string]*ItemProgress)
|
||||||
markMultiProgressDirtyLocked()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setDownloadDir(path string) error {
|
func setDownloadDir(path string) error {
|
||||||
|
|||||||
+87
-1423
File diff suppressed because it is too large
Load Diff
+11
-396
@@ -1,113 +1,6 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import "testing"
|
||||||
"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) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
wantType string
|
|
||||||
wantID string
|
|
||||||
expectErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "store album url",
|
|
||||||
input: "https://www.qobuz.com/us-en/album/harry-styles-harry-styles/0886446451985",
|
|
||||||
wantType: "album",
|
|
||||||
wantID: "0886446451985",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "store playlist url",
|
|
||||||
input: "https://www.qobuz.com/us-en/playlists/new-releases/2049430",
|
|
||||||
wantType: "playlist",
|
|
||||||
wantID: "2049430",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "store artist url",
|
|
||||||
input: "https://www.qobuz.com/us-en/interpreter/harry-styles/729886",
|
|
||||||
wantType: "artist",
|
|
||||||
wantID: "729886",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "play track url",
|
|
||||||
input: "https://play.qobuz.com/track/40681594",
|
|
||||||
wantType: "track",
|
|
||||||
wantID: "40681594",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "custom scheme playlist url",
|
|
||||||
input: "qobuzapp://playlist/2049430",
|
|
||||||
wantType: "playlist",
|
|
||||||
wantID: "2049430",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unsupported url",
|
|
||||||
input: "https://example.com/not-qobuz",
|
|
||||||
expectErr: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
gotType, gotID, err := parseQobuzURL(test.input)
|
|
||||||
if test.expectErr {
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("expected error, got none")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if gotType != test.wantType || gotID != test.wantID {
|
|
||||||
t.Fatalf("parseQobuzURL(%q) = (%q, %q), want (%q, %q)", test.input, gotType, gotID, test.wantType, test.wantID)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractQobuzArtistAlbumIDs(t *testing.T) {
|
|
||||||
body := []byte(`
|
|
||||||
<div class="product__item">
|
|
||||||
<button data-itemtype="album" data-itemId="yrpbt0lwm3g0y"></button>
|
|
||||||
</div>
|
|
||||||
<div class="product__item">
|
|
||||||
<button data-itemtype="album" data-itemId="yrpbt0lwm3g0y"></button>
|
|
||||||
</div>
|
|
||||||
<div class="product__item">
|
|
||||||
<button data-itemtype="album" data-itemId="0886446451985"></button>
|
|
||||||
</div>
|
|
||||||
`)
|
|
||||||
|
|
||||||
matches := qobuzArtistAlbumIDRegex.FindAllSubmatch(body, -1)
|
|
||||||
if len(matches) != 3 {
|
|
||||||
t.Fatalf("expected 3 regex matches, got %d", len(matches))
|
|
||||||
}
|
|
||||||
if string(matches[0][1]) != "yrpbt0lwm3g0y" {
|
|
||||||
t.Fatalf("unexpected first album id: %q", matches[0][1])
|
|
||||||
}
|
|
||||||
if string(matches[2][1]) != "0886446451985" {
|
|
||||||
t.Fatalf("unexpected last album id: %q", matches[2][1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractQobuzDownloadURLFromBody(t *testing.T) {
|
func TestExtractQobuzDownloadURLFromBody(t *testing.T) {
|
||||||
t.Run("reads top-level download_url and quality metadata", func(t *testing.T) {
|
t.Run("reads top-level download_url and quality metadata", func(t *testing.T) {
|
||||||
@@ -201,56 +94,28 @@ func TestNormalizeQobuzQualityCode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildQobuzMusicDLPayloadUsesOpenTrackURL(t *testing.T) {
|
func TestGetQobuzDebugKey(t *testing.T) {
|
||||||
payloadBytes, err := buildQobuzMusicDLPayload(374610875, "7")
|
got := getQobuzDebugKey()
|
||||||
if err != nil {
|
if len(got) != len(qobuzDebugKeyObfuscated) {
|
||||||
t.Fatalf("buildQobuzMusicDLPayload returned error: %v", err)
|
t.Fatalf("unexpected debug key length: %d", len(got))
|
||||||
}
|
}
|
||||||
|
for i := range got {
|
||||||
var payload map[string]any
|
if got[i]^qobuzDebugKeyXORMask != qobuzDebugKeyObfuscated[i] {
|
||||||
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
|
t.Fatalf("unexpected debug key reconstruction at index %d", i)
|
||||||
t.Fatalf("payload is not valid JSON: %v", err)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if got := payload["url"]; got != "https://open.qobuz.com/track/374610875" {
|
|
||||||
t.Fatalf("payload url = %v, want open.qobuz.com track URL", got)
|
|
||||||
}
|
|
||||||
if got := payload["quality"]; got != "hi-res" {
|
|
||||||
t.Fatalf("payload quality = %v, want hi-res", got)
|
|
||||||
}
|
|
||||||
if got := payload["upload_to_r2"]; got != false {
|
|
||||||
t.Fatalf("payload upload_to_r2 = %v, want false", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) {
|
|
||||||
body := []byte(`
|
|
||||||
<button data-itemtype="album" data-itemId="0886446451985"></button>
|
|
||||||
<button data-itemtype="album" data-itemId="0886446451985"></button>
|
|
||||||
<button data-itemtype="album" data-itemId="pvv406bth40ya"></button>
|
|
||||||
`)
|
|
||||||
|
|
||||||
got := extractQobuzAlbumIDsFromArtistHTML(body)
|
|
||||||
if len(got) != 2 {
|
|
||||||
t.Fatalf("expected 2 unique album IDs, got %d (%v)", len(got), got)
|
|
||||||
}
|
|
||||||
if got[0] != "0886446451985" || got[1] != "pvv406bth40ya" {
|
|
||||||
t.Fatalf("unexpected album IDs: %v", got)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQobuzAvailableProviders(t *testing.T) {
|
func TestQobuzAvailableProviders(t *testing.T) {
|
||||||
providers := NewQobuzDownloader().GetAvailableProviders()
|
providers := NewQobuzDownloader().GetAvailableProviders()
|
||||||
if len(providers) != 5 {
|
if len(providers) != 3 {
|
||||||
t.Fatalf("expected 5 Qobuz providers, got %d", len(providers))
|
t.Fatalf("expected 3 Qobuz providers, got %d", len(providers))
|
||||||
}
|
}
|
||||||
|
|
||||||
want := map[string]string{
|
want := map[string]string{
|
||||||
"musicdl": qobuzAPIKindMusicDL,
|
"musicdl": qobuzAPIKindMusicDL,
|
||||||
"dabmusic": qobuzAPIKindStandard,
|
"dabmusic": qobuzAPIKindStandard,
|
||||||
"deeb": qobuzAPIKindStandard,
|
"deeb": qobuzAPIKindStandard,
|
||||||
"qbz": qobuzAPIKindStandard,
|
|
||||||
"squid": qobuzAPIKindStandard,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, provider := range providers {
|
for _, provider := range providers {
|
||||||
@@ -268,253 +133,3 @@ func TestQobuzAvailableProviders(t *testing.T) {
|
|||||||
t.Fatalf("missing providers: %v", want)
|
t.Fatalf("missing providers: %v", want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testQobuzTrack(id int64, title, artist string, duration int) *QobuzTrack {
|
|
||||||
track := &QobuzTrack{
|
|
||||||
ID: id,
|
|
||||||
Title: title,
|
|
||||||
Duration: duration,
|
|
||||||
}
|
|
||||||
track.Performer.Name = artist
|
|
||||||
return track
|
|
||||||
}
|
|
||||||
|
|
||||||
func 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) {
|
|
||||||
origGetTrackByID := qobuzGetTrackByIDFunc
|
|
||||||
origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
|
|
||||||
origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc
|
|
||||||
origSongLinkCheck := songLinkCheckTrackAvailabilityFunc
|
|
||||||
t.Cleanup(func() {
|
|
||||||
qobuzGetTrackByIDFunc = origGetTrackByID
|
|
||||||
qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC
|
|
||||||
qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata
|
|
||||||
songLinkCheckTrackAvailabilityFunc = origSongLinkCheck
|
|
||||||
GetTrackIDCache().Clear()
|
|
||||||
})
|
|
||||||
GetTrackIDCache().Clear()
|
|
||||||
|
|
||||||
qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) {
|
|
||||||
if trackID != 111 {
|
|
||||||
t.Fatalf("unexpected track ID lookup: %d", trackID)
|
|
||||||
}
|
|
||||||
return testQobuzTrack(111, "Aperture", "Harry Styles", 180), nil
|
|
||||||
}
|
|
||||||
qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
|
||||||
if isrc != "TESTISRC1" {
|
|
||||||
t.Fatalf("unexpected ISRC lookup: %q", isrc)
|
|
||||||
}
|
|
||||||
if expectedDurationSec != 180 {
|
|
||||||
t.Fatalf("unexpected duration: %d", expectedDurationSec)
|
|
||||||
}
|
|
||||||
return testQobuzTrack(222, "Taste Back", "Harry Styles", 180), nil
|
|
||||||
}
|
|
||||||
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, _, _ string, _ int) (*QobuzTrack, error) {
|
|
||||||
t.Fatal("metadata fallback should not run when ISRC fallback succeeds")
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
|
||||||
if spotifyTrackID != "spotify-track-id" {
|
|
||||||
t.Fatalf("unexpected spotify ID: %q", spotifyTrackID)
|
|
||||||
}
|
|
||||||
if isrc != "TESTISRC1" {
|
|
||||||
t.Fatalf("unexpected SongLink ISRC: %q", isrc)
|
|
||||||
}
|
|
||||||
return &TrackAvailability{QobuzID: "111"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
req := DownloadRequest{
|
|
||||||
ISRC: "TESTISRC1",
|
|
||||||
SpotifyID: "spotify-track-id",
|
|
||||||
TrackName: "Taste Back",
|
|
||||||
ArtistName: "Harry Styles",
|
|
||||||
DurationMS: 180000,
|
|
||||||
}
|
|
||||||
|
|
||||||
track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if track == nil || track.ID != 222 || track.Title != "Taste Back" {
|
|
||||||
t.Fatalf("unexpected resolved track: %+v", track)
|
|
||||||
}
|
|
||||||
|
|
||||||
cached := GetTrackIDCache().Get(req.ISRC)
|
|
||||||
if cached == nil || cached.QobuzTrackID != 222 {
|
|
||||||
t.Fatalf("expected validated fallback track to be cached, got %+v", cached)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveQobuzTrackForRequestRejectsOdesliMismatch(t *testing.T) {
|
|
||||||
origGetTrackByID := qobuzGetTrackByIDFunc
|
|
||||||
origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
|
|
||||||
origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc
|
|
||||||
origSongLinkCheck := songLinkCheckTrackAvailabilityFunc
|
|
||||||
t.Cleanup(func() {
|
|
||||||
qobuzGetTrackByIDFunc = origGetTrackByID
|
|
||||||
qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC
|
|
||||||
qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata
|
|
||||||
songLinkCheckTrackAvailabilityFunc = origSongLinkCheck
|
|
||||||
})
|
|
||||||
|
|
||||||
qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) {
|
|
||||||
if trackID != 333 {
|
|
||||||
t.Fatalf("unexpected track ID lookup: %d", trackID)
|
|
||||||
}
|
|
||||||
return testQobuzTrack(333, "American Girls", "Harry Styles", 181), nil
|
|
||||||
}
|
|
||||||
qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, _ string, _ int) (*QobuzTrack, error) {
|
|
||||||
t.Fatal("ISRC fallback should not run without an ISRC")
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
|
|
||||||
if trackName != "Taste Back" || artistName != "Harry Styles" || expectedDurationSec != 181 {
|
|
||||||
t.Fatalf("unexpected metadata fallback arguments: %q / %q / %d", trackName, artistName, expectedDurationSec)
|
|
||||||
}
|
|
||||||
return testQobuzTrack(444, "Taste Back", "Harry Styles", 181), nil
|
|
||||||
}
|
|
||||||
songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, _, _ string) (*TrackAvailability, error) {
|
|
||||||
t.Fatal("SongLink should not run when Odesli QobuzID is provided")
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
req := DownloadRequest{
|
|
||||||
QobuzID: "333",
|
|
||||||
TrackName: "Taste Back",
|
|
||||||
ArtistName: "Harry Styles",
|
|
||||||
DurationMS: 181000,
|
|
||||||
}
|
|
||||||
|
|
||||||
track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if track == nil || track.ID != 444 || track.Title != "Taste Back" {
|
|
||||||
t.Fatalf("unexpected resolved track: %+v", track)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveQobuzTrackForRequestUsesPrefixedQobuzIDWithoutSongLink(t *testing.T) {
|
|
||||||
origGetTrackByID := qobuzGetTrackByIDFunc
|
|
||||||
origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
|
|
||||||
origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc
|
|
||||||
origSongLinkCheck := songLinkCheckTrackAvailabilityFunc
|
|
||||||
t.Cleanup(func() {
|
|
||||||
qobuzGetTrackByIDFunc = origGetTrackByID
|
|
||||||
qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC
|
|
||||||
qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata
|
|
||||||
songLinkCheckTrackAvailabilityFunc = origSongLinkCheck
|
|
||||||
})
|
|
||||||
|
|
||||||
qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) {
|
|
||||||
if trackID != 40681594 {
|
|
||||||
t.Fatalf("unexpected track ID lookup: %d", trackID)
|
|
||||||
}
|
|
||||||
return testQobuzTrack(40681594, "Sign of the Times", "Harry Styles", 341), nil
|
|
||||||
}
|
|
||||||
qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, _ string, _ int) (*QobuzTrack, error) {
|
|
||||||
t.Fatal("ISRC fallback should not run when request qobuz id succeeds")
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, _, _ string, _ int) (*QobuzTrack, error) {
|
|
||||||
t.Fatal("metadata fallback should not run when request qobuz id succeeds")
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, _, _ string) (*TrackAvailability, error) {
|
|
||||||
t.Fatal("SongLink should not run when request qobuz id is provided")
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
req := DownloadRequest{
|
|
||||||
QobuzID: "qobuz:40681594",
|
|
||||||
TrackName: "Sign of the Times",
|
|
||||||
ArtistName: "Harry Styles",
|
|
||||||
DurationMS: 341000,
|
|
||||||
}
|
|
||||||
|
|
||||||
track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if track == nil || track.ID != 40681594 {
|
|
||||||
t.Fatalf("unexpected resolved track: %+v", track)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestQobuzTrackMatchesRequest_SongLinkBypassesArtistAndTitle(t *testing.T) {
|
|
||||||
req := DownloadRequest{
|
|
||||||
TrackName: "Ringišpil",
|
|
||||||
ArtistName: "Djordje Balasevic",
|
|
||||||
}
|
|
||||||
|
|
||||||
track := &QobuzTrack{
|
|
||||||
Title: "Different Title",
|
|
||||||
Duration: 0,
|
|
||||||
}
|
|
||||||
track.Performer.Name = "Different Artist"
|
|
||||||
|
|
||||||
if !qobuzTrackMatchesRequest(req, track, "Qobuz", "SongLink Qobuz ID", true) {
|
|
||||||
t.Fatal("expected SongLink Qobuz source to bypass artist/title verification")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+17
-4
@@ -16,13 +16,16 @@ 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",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,15 +40,19 @@ 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",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +82,7 @@ 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",
|
||||||
@@ -112,6 +120,7 @@ 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 {
|
||||||
@@ -120,12 +129,13 @@ func JapaneseToRomaji(text string) string {
|
|||||||
nextRomaji = romaji
|
nextRomaji = romaji
|
||||||
}
|
}
|
||||||
if len(nextRomaji) > 0 {
|
if len(nextRomaji) > 0 {
|
||||||
result.WriteByte(nextRomaji[0])
|
result.WriteByte(nextRomaji[0]) // Double the first consonant
|
||||||
}
|
}
|
||||||
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 {
|
||||||
@@ -140,14 +150,17 @@ 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++
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+364
-233
@@ -1,7 +1,6 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -15,10 +14,6 @@ type SongLinkClient struct {
|
|||||||
client *http.Client
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
type songLinkPlatformLink struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TrackAvailability struct {
|
type TrackAvailability struct {
|
||||||
SpotifyID string `json:"spotify_id"`
|
SpotifyID string `json:"spotify_id"`
|
||||||
Tidal bool `json:"tidal"`
|
Tidal bool `json:"tidal"`
|
||||||
@@ -48,7 +43,6 @@ var (
|
|||||||
songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) {
|
songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) {
|
||||||
return s.CheckAvailabilityFromDeezer(deezerTrackID)
|
return s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
}
|
}
|
||||||
songLinkRetryConfig = DefaultRetryConfig
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewSongLinkClient() *SongLinkClient {
|
func NewSongLinkClient() *SongLinkClient {
|
||||||
@@ -87,210 +81,38 @@ 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"
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveTrackPlatforms resolves a music URL to all platforms.
|
func buildSongLinkURLFromTarget(targetURL string, userCountry string) string {
|
||||||
// Spotify URLs use the resolve API; if that fails, falls back to SongLink.
|
if userCountry == "" {
|
||||||
// All other URLs go directly to SongLink.
|
userCountry = GetSongLinkRegion()
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
return s.songLinkByTargetURL(inputURL)
|
apiURL := fmt.Sprintf("%s?url=%s", songLinkBaseURL(), url.QueryEscape(targetURL))
|
||||||
|
if userCountry != "" {
|
||||||
|
apiURL = fmt.Sprintf("%s&userCountry=%s", apiURL, url.QueryEscape(userCountry))
|
||||||
|
}
|
||||||
|
return apiURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveTrackPlatformsByPlatform resolves using platform + type + id.
|
func buildSongLinkURLByPlatform(platform, entityType, entityID, userCountry string) string {
|
||||||
// Spotify uses the resolve API with SongLink fallback; all other platforms use SongLink directly.
|
if userCountry == "" {
|
||||||
func (s *SongLinkClient) resolveTrackPlatformsByPlatform(platform, entityType, entityID string) (map[string]songLinkPlatformLink, error) {
|
userCountry = GetSongLinkRegion()
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
return s.songLinkByPlatform(platform, entityType, entityID)
|
apiURL := fmt.Sprintf("%s?platform=%s&type=%s&id=%s",
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
||||||
url.QueryEscape(GetSongLinkRegion()))
|
if userCountry != "" {
|
||||||
|
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) {
|
||||||
@@ -308,12 +130,95 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
|
func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
|
||||||
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
||||||
links, err := s.resolveTrackPlatforms(spotifyURL)
|
apiURL := buildSongLinkURLFromTarget(spotifyURL, "")
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("resolve proxy failed for Spotify %s: %w", spotifyTrackID, err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, links), nil
|
|
||||||
|
retryConfig := DefaultRetryConfig()
|
||||||
|
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 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("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{
|
||||||
|
SpotifyID: spotifyTrackID,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
|
availability.Deezer = true
|
||||||
|
availability.DeezerURL = deezerLink.URL
|
||||||
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||||
|
availability.Qobuz = true
|
||||||
|
availability.QobuzURL = qobuzLink.URL
|
||||||
|
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer youtubeMusic URLs — they bypass Cobalt login requirements
|
||||||
|
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||||
|
availability.YouTube = true
|
||||||
|
availability.YouTubeURL = ytMusicLink.URL
|
||||||
|
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to regular youtube if youtubeMusic not available
|
||||||
|
if !availability.YouTube {
|
||||||
|
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||||
|
availability.YouTube = true
|
||||||
|
availability.YouTubeURL = youtubeLink.URL
|
||||||
|
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SongLinkClient) checkTrackAvailabilityFromISRC(isrc string) (*TrackAvailability, error) {
|
func (s *SongLinkClient) checkTrackAvailabilityFromISRC(isrc string) (*TrackAvailability, error) {
|
||||||
@@ -508,6 +413,8 @@ 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 {
|
||||||
@@ -542,17 +449,47 @@ 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)
|
||||||
links, err := s.resolveTrackPlatforms(spotifyURL)
|
apiURL := buildSongLinkURLFromTarget(spotifyURL, "")
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("resolve proxy failed for album %s: %w", spotifyAlbumID, err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retryConfig := DefaultRetryConfig()
|
||||||
|
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 := links["deezer"]; ok && deezerLink.URL != "" {
|
if deezerLink, ok := songLinkResp.LinksByPlatform["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)
|
||||||
@@ -595,19 +532,101 @@ 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)
|
||||||
links, err := s.resolveTrackPlatforms(deezerURL)
|
apiURL := buildSongLinkURLFromTarget(deezerURL, "")
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("resolve failed for Deezer %s: %w", deezerTrackID, err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
availability := buildTrackAvailabilityFromSongLinkLinks("", links)
|
retryConfig := DefaultRetryConfig()
|
||||||
// Ensure Deezer is always marked available since we started from a Deezer URL
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
availability.Deezer = true
|
if err != nil {
|
||||||
availability.DeezerID = deezerTrackID
|
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -619,57 +638,94 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
links, err := s.resolveTrackPlatformsByPlatform(platform, entityType, entityID)
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
|
apiURL := buildSongLinkURLByPlatform(platform, entityType, entityID, "")
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("resolve failed for %s %s: %w", platform, entityID, err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildTrackAvailabilityFromSongLinkLinks("", links), nil
|
retryConfig := DefaultRetryConfig()
|
||||||
}
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
func buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID string, links map[string]songLinkPlatformLink) *TrackAvailability {
|
if resp.StatusCode == 400 {
|
||||||
availability := &TrackAvailability{
|
return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform)
|
||||||
SpotifyID: spotifyTrackID,
|
}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
if availability.SpotifyID == "" {
|
body, err := ReadResponseBody(resp)
|
||||||
if spotifyLink, ok := links["spotify"]; ok && spotifyLink.URL != "" {
|
if err != nil {
|
||||||
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if tidalLink, ok := links["tidal"]; ok && tidalLink.URL != "" {
|
|
||||||
|
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.Tidal = true
|
||||||
availability.TidalURL = tidalLink.URL
|
availability.TidalURL = tidalLink.URL
|
||||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||||
}
|
}
|
||||||
if amazonLink, ok := links["amazonMusic"]; ok && amazonLink.URL != "" {
|
|
||||||
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
availability.Amazon = true
|
availability.Amazon = true
|
||||||
availability.AmazonURL = amazonLink.URL
|
availability.AmazonURL = amazonLink.URL
|
||||||
}
|
}
|
||||||
if qobuzLink, ok := links["qobuz"]; ok && qobuzLink.URL != "" {
|
|
||||||
|
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||||
availability.Qobuz = true
|
availability.Qobuz = true
|
||||||
availability.QobuzURL = qobuzLink.URL
|
availability.QobuzURL = qobuzLink.URL
|
||||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||||
}
|
}
|
||||||
if deezerLink, ok := links["deezer"]; ok && deezerLink.URL != "" {
|
|
||||||
|
if deezerLink, ok := songLinkResp.LinksByPlatform["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)
|
||||||
}
|
}
|
||||||
if ytMusicLink, ok := links["youtubeMusic"]; ok && ytMusicLink.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.YouTube = true
|
||||||
availability.YouTubeURL = ytMusicLink.URL
|
availability.YouTubeURL = ytMusicLink.URL
|
||||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||||
}
|
}
|
||||||
if !availability.YouTube {
|
if !availability.YouTube {
|
||||||
if youtubeLink, ok := links["youtube"]; ok && youtubeLink.URL != "" {
|
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||||
availability.YouTube = true
|
availability.YouTube = true
|
||||||
availability.YouTubeURL = youtubeLink.URL
|
availability.YouTubeURL = youtubeLink.URL
|
||||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return availability
|
return availability, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractSpotifyIDFromURL(spotifyURL string) string {
|
func extractSpotifyIDFromURL(spotifyURL string) string {
|
||||||
@@ -737,10 +793,85 @@ func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) {
|
func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) {
|
||||||
links, err := s.resolveTrackPlatforms(inputURL)
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
|
apiURL := buildSongLinkURLFromTarget(inputURL, "")
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("resolve failed for URL %s: %w", inputURL, err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildTrackAvailabilityFromSongLinkLinks("", links), nil
|
retryConfig := DefaultRetryConfig()
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
|
||||||
|
|
||||||
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
||||||
return fn(req)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetRetryAfterDurationMissingHeaderReturnsZero(t *testing.T) {
|
|
||||||
resp := &http.Response{
|
|
||||||
Header: make(http.Header),
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := getRetryAfterDuration(resp); got != 0 {
|
|
||||||
t.Fatalf("getRetryAfterDuration() = %v, want 0", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckTrackAvailabilityFromSpotifyViaResolveAPI(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":"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"}}`
|
|
||||||
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("testspotifyid", "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CheckTrackAvailability() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if availability.SpotifyID != "testspotifyid" {
|
|
||||||
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testspotifyid")
|
|
||||||
}
|
|
||||||
if !availability.Deezer || availability.DeezerID != "908604612" {
|
|
||||||
t.Fatalf("Deezer availability = %+v, want DeezerID 908604612", availability)
|
|
||||||
}
|
|
||||||
if !availability.Amazon || !availability.Tidal || !availability.Qobuz || !availability.YouTube {
|
|
||||||
t.Fatalf("availability flags = %+v, want Amazon/Tidal/Qobuz/YouTube true", availability)
|
|
||||||
}
|
|
||||||
if availability.YouTubeID != "testvideoid1" {
|
|
||||||
t.Fatalf("YouTubeID = %q, want %q", availability.YouTubeID, "testvideoid1")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckTrackAvailabilityFromSpotifyResolveAPIFailure(t *testing.T) {
|
|
||||||
origRetryConfig := songLinkRetryConfig
|
|
||||||
songLinkRetryConfig = func() RetryConfig {
|
|
||||||
return RetryConfig{MaxRetries: 0, InitialDelay: 0, MaxDelay: 0, BackoffFactor: 1}
|
|
||||||
}
|
|
||||||
defer func() { songLinkRetryConfig = origRetryConfig }()
|
|
||||||
|
|
||||||
var hitSongLink bool
|
|
||||||
|
|
||||||
client := &SongLinkClient{
|
|
||||||
client: &http.Client{
|
|
||||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
||||||
// Resolve proxy returns 500
|
|
||||||
if req.URL.Host == "api.zarz.moe" && req.URL.Path == "/v1/resolve" {
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: 500,
|
|
||||||
Header: make(http.Header),
|
|
||||||
Body: io.NopCloser(strings.NewReader("internal error")),
|
|
||||||
Request: req,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
// 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{
|
|
||||||
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("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 {
|
|
||||||
t.Fatalf("CheckTrackAvailability() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if availability.SpotifyID != "5glgyj6zH0irbNGfukHacv" {
|
|
||||||
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "5glgyj6zH0irbNGfukHacv")
|
|
||||||
}
|
|
||||||
if !availability.Deezer || availability.DeezerID != "2248583177" {
|
|
||||||
t.Fatalf("Deezer availability = %+v, want DeezerID 2248583177", availability)
|
|
||||||
}
|
|
||||||
if !availability.Tidal || availability.TidalID != "290565315" {
|
|
||||||
t.Fatalf("Tidal availability = %+v, want TidalID 290565315", availability)
|
|
||||||
}
|
|
||||||
if availability.Qobuz {
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultSpotFetchAPIBaseURL = "https://spotify.afkarxyz.fun/api"
|
||||||
|
|
||||||
|
// GetSpotifyDataWithAPI fetches Spotify metadata through SpotFetch-compatible API.
|
||||||
|
// This is used as a fallback when direct Spotify API access is blocked/limited.
|
||||||
|
func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL, apiBaseURL string) (interface{}, error) {
|
||||||
|
parsed, err := parseSpotifyURI(spotifyURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid Spotify URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
base := strings.TrimSpace(apiBaseURL)
|
||||||
|
if base == "" {
|
||||||
|
base = DefaultSpotFetchAPIBaseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(base, "/"), parsed.Type, parsed.ID)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create SpotFetch API request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
client := NewHTTPClientWithTimeout(30 * time.Second)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("SpotFetch API request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("SpotFetch API error: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read SpotFetch API response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch parsed.Type {
|
||||||
|
case "track":
|
||||||
|
var trackResp TrackResponse
|
||||||
|
if err := json.Unmarshal(bodyBytes, &trackResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode track response: %w", err)
|
||||||
|
}
|
||||||
|
return trackResp, nil
|
||||||
|
case "album":
|
||||||
|
var albumResp AlbumResponsePayload
|
||||||
|
if err := json.Unmarshal(bodyBytes, &albumResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode album response: %w", err)
|
||||||
|
}
|
||||||
|
return &albumResp, nil
|
||||||
|
case "playlist":
|
||||||
|
var playlistResp PlaylistResponsePayload
|
||||||
|
if err := json.Unmarshal(bodyBytes, &playlistResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode playlist response: %w", err)
|
||||||
|
}
|
||||||
|
return playlistResp, nil
|
||||||
|
case "artist":
|
||||||
|
var artistResp ArtistResponsePayload
|
||||||
|
if err := json.Unmarshal(bodyBytes, &artistResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode artist response: %w", err)
|
||||||
|
}
|
||||||
|
return &artistResp, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+56
-1071
File diff suppressed because it is too large
Load Diff
@@ -1,222 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestParseTidalURL(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
wantType string
|
|
||||||
wantID string
|
|
||||||
expectErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "track url",
|
|
||||||
input: "https://tidal.com/track/77616174",
|
|
||||||
wantType: "track",
|
|
||||||
wantID: "77616174",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "browse album url",
|
|
||||||
input: "https://listen.tidal.com/browse/album/77616169",
|
|
||||||
wantType: "album",
|
|
||||||
wantID: "77616169",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "artist url",
|
|
||||||
input: "https://www.tidal.com/artist/3852143",
|
|
||||||
wantType: "artist",
|
|
||||||
wantID: "3852143",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "playlist url",
|
|
||||||
input: "https://tidal.com/playlist/edf3b7d2-cb42-41d7-93c0-afa2a395521b",
|
|
||||||
wantType: "playlist",
|
|
||||||
wantID: "edf3b7d2-cb42-41d7-93c0-afa2a395521b",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unsupported host",
|
|
||||||
input: "https://example.com/track/123",
|
|
||||||
expectErr: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
gotType, gotID, err := parseTidalURL(test.input)
|
|
||||||
if test.expectErr {
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("expected error, got none")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if gotType != test.wantType || gotID != test.wantID {
|
|
||||||
t.Fatalf("parseTidalURL(%q) = (%q, %q), want (%q, %q)", test.input, gotType, gotID, test.wantType, test.wantID)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseTidalRequestTrackID(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
input string
|
|
||||||
want int64
|
|
||||||
ok bool
|
|
||||||
}{
|
|
||||||
{input: "40681594", want: 40681594, ok: true},
|
|
||||||
{input: "tidal:40681594", want: 40681594, ok: true},
|
|
||||||
{input: " tidal:40681594 ", want: 40681594, ok: true},
|
|
||||||
{input: "", want: 0, ok: false},
|
|
||||||
{input: "tidal:not-a-number", want: 0, ok: false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
got, ok := parseTidalRequestTrackID(test.input)
|
|
||||||
if got != test.want || ok != test.ok {
|
|
||||||
t.Fatalf("parseTidalRequestTrackID(%q) = (%d, %v), want (%d, %v)", test.input, got, ok, test.want, test.ok)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTidalImageURL(t *testing.T) {
|
|
||||||
got := tidalImageURL("fc18a64b-d76b-4582-962a-224cb05193f3", "1280x1280")
|
|
||||||
want := "https://resources.tidal.com/images/fc18a64b/d76b/4582/962a/224cb05193f3/1280x1280.jpg"
|
|
||||||
if got != want {
|
|
||||||
t.Fatalf("tidalImageURL() = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTidalTrackToTrackMetadata(t *testing.T) {
|
|
||||||
track := &TidalTrack{
|
|
||||||
ID: 77616174,
|
|
||||||
Title: "Bruckner: Symphony No. 5",
|
|
||||||
ISRC: "GBUM71507433",
|
|
||||||
Duration: 1172,
|
|
||||||
TrackNumber: 5,
|
|
||||||
VolumeNumber: 1,
|
|
||||||
URL: "http://www.tidal.com/track/77616174",
|
|
||||||
}
|
|
||||||
track.Artist.ID = 3852143
|
|
||||||
track.Artist.Name = "Staatskapelle Berlin"
|
|
||||||
track.Artists = []struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Picture string `json:"picture"`
|
|
||||||
}{
|
|
||||||
{ID: 3852143, Name: "Staatskapelle Berlin", Type: "MAIN"},
|
|
||||||
{ID: 12430, Name: "Daniel Barenboim", Type: "FEATURED"},
|
|
||||||
}
|
|
||||||
track.Album.ID = 77616169
|
|
||||||
track.Album.Title = "Bruckner: Symphonies 4-9"
|
|
||||||
track.Album.Cover = "fc18a64b-d76b-4582-962a-224cb05193f3"
|
|
||||||
track.Album.ReleaseDate = "2016-02-26"
|
|
||||||
|
|
||||||
got := tidalTrackToTrackMetadata(track)
|
|
||||||
if got.SpotifyID != "tidal:77616174" {
|
|
||||||
t.Fatalf("unexpected track ID: %q", got.SpotifyID)
|
|
||||||
}
|
|
||||||
if got.Artists != "Staatskapelle Berlin, Daniel Barenboim" {
|
|
||||||
t.Fatalf("unexpected artists: %q", got.Artists)
|
|
||||||
}
|
|
||||||
if got.AlbumID != "tidal:77616169" {
|
|
||||||
t.Fatalf("unexpected album ID: %q", got.AlbumID)
|
|
||||||
}
|
|
||||||
if got.ArtistID != "tidal:3852143" {
|
|
||||||
t.Fatalf("unexpected artist ID: %q", got.ArtistID)
|
|
||||||
}
|
|
||||||
if got.Images == "" || got.ExternalURL != "https://www.tidal.com/track/77616174" {
|
|
||||||
t.Fatalf("unexpected image/url: %q / %q", got.Images, got.ExternalURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTidalAlbumToArtistAlbum(t *testing.T) {
|
|
||||||
album := &tidalPublicAlbum{
|
|
||||||
ID: 77616169,
|
|
||||||
Title: "Bruckner: Symphonies 4-9",
|
|
||||||
Type: "ALBUM",
|
|
||||||
Cover: "fc18a64b-d76b-4582-962a-224cb05193f3",
|
|
||||||
ReleaseDate: "2016-02-26",
|
|
||||||
NumberOfTracks: 23,
|
|
||||||
Artists: []tidalPublicArtist{
|
|
||||||
{ID: 3852143, Name: "Staatskapelle Berlin", Type: "MAIN"},
|
|
||||||
{ID: 12430, Name: "Daniel Barenboim", Type: "FEATURED"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
got := tidalAlbumToArtistAlbum(album)
|
|
||||||
if got.ID != "tidal:77616169" {
|
|
||||||
t.Fatalf("unexpected album ID: %q", got.ID)
|
|
||||||
}
|
|
||||||
if got.AlbumType != "album" {
|
|
||||||
t.Fatalf("unexpected album type: %q", got.AlbumType)
|
|
||||||
}
|
|
||||||
if got.Artists != "Staatskapelle Berlin, Daniel Barenboim" {
|
|
||||||
t.Fatalf("unexpected artists: %q", got.Artists)
|
|
||||||
}
|
|
||||||
if got.Images == "" {
|
|
||||||
t.Fatalf("expected image URL, got empty string")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTidalAlbumToArtistAlbumWithFallbackType(t *testing.T) {
|
|
||||||
album := &tidalPublicAlbum{
|
|
||||||
ID: 490623904,
|
|
||||||
Title: "LET 'EM KNOW",
|
|
||||||
Cover: "fc18a64b-d76b-4582-962a-224cb05193f3",
|
|
||||||
NumberOfTracks: 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
got := tidalAlbumToArtistAlbumWithType(album, "single")
|
|
||||||
if got.AlbumType != "single" {
|
|
||||||
t.Fatalf("unexpected fallback album type: %q", got.AlbumType)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTidalArtistAlbumTypeFromModuleTitle(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
title string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{title: "Albums", want: "album"},
|
|
||||||
{title: "EP & Singles", want: "single"},
|
|
||||||
{title: "Compilations", want: "album"},
|
|
||||||
{title: "Appears On", want: "album"},
|
|
||||||
{title: "Unknown", want: ""},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
if got := tidalArtistAlbumTypeFromModuleTitle(test.title); got != test.want {
|
|
||||||
t.Fatalf("tidalArtistAlbumTypeFromModuleTitle(%q) = %q, want %q", test.title, got, test.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTidalPlaylistImageUsesOrigin(t *testing.T) {
|
|
||||||
got := tidalImageURL("e6b59fd3-6995-40f0-8a32-174db3a8f4f2", "origin")
|
|
||||||
want := "https://resources.tidal.com/images/e6b59fd3/6995/40f0/8a32/174db3a8f4f2/origin.jpg"
|
|
||||||
if got != want {
|
|
||||||
t.Fatalf("unexpected origin playlist image URL: %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTidalPlaylistOwnerName(t *testing.T) {
|
|
||||||
editorial := &tidalPublicPlaylist{Type: "EDITORIAL"}
|
|
||||||
if got := tidalPlaylistOwnerName(editorial); got != "TIDAL" {
|
|
||||||
t.Fatalf("unexpected editorial owner: %q", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
artist := &tidalPublicPlaylist{Type: "ARTIST"}
|
|
||||||
if got := tidalPlaylistOwnerName(artist); got != "Artist" {
|
|
||||||
t.Fatalf("unexpected artist owner: %q", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
user := &tidalPublicPlaylist{}
|
|
||||||
user.Creator.Name = "djtest"
|
|
||||||
if got := tidalPlaylistOwnerName(user); got != "djtest" {
|
|
||||||
t.Fatalf("unexpected creator owner: %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,25 +3,10 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
"golang.org/x/text/unicode/norm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func writeNormalizedArtistRune(b *strings.Builder, r rune) {
|
// normalizeLooseTitle collapses separators/punctuation so titles like
|
||||||
switch r {
|
// "Doctor / Cops" and "Doctor _ Cops" can still match.
|
||||||
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 == "" {
|
||||||
@@ -37,37 +22,11 @@ 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:
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +42,9 @@ 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 == "" {
|
||||||
@@ -106,48 +68,3 @@ func normalizeSymbolOnlyTitle(title string) string {
|
|||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
type resolvedTrackInfo struct {
|
|
||||||
Title string
|
|
||||||
ArtistName string
|
|
||||||
ISRC string
|
|
||||||
Duration int
|
|
||||||
SkipNameVerification bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool {
|
|
||||||
exactISRCMatch := req.ISRC != "" &&
|
|
||||||
resolved.ISRC != "" &&
|
|
||||||
strings.EqualFold(strings.TrimSpace(req.ISRC), strings.TrimSpace(resolved.ISRC))
|
|
||||||
|
|
||||||
if !exactISRCMatch && !resolved.SkipNameVerification {
|
|
||||||
if req.ArtistName != "" && resolved.ArtistName != "" &&
|
|
||||||
!artistsMatch(req.ArtistName, resolved.ArtistName) {
|
|
||||||
GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n",
|
|
||||||
logPrefix, req.ArtistName, resolved.ArtistName)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.TrackName != "" && resolved.Title != "" &&
|
|
||||||
!titlesMatch(req.TrackName, resolved.Title) {
|
|
||||||
GoLog("[%s] Verification failed: title mismatch — expected '%s', got '%s'\n",
|
|
||||||
logPrefix, req.TrackName, resolved.Title)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedDurationSec := req.DurationMS / 1000
|
|
||||||
if expectedDurationSec > 0 && resolved.Duration > 0 {
|
|
||||||
diff := expectedDurationSec - resolved.Duration
|
|
||||||
if diff < 0 {
|
|
||||||
diff = -diff
|
|
||||||
}
|
|
||||||
if diff > 10 {
|
|
||||||
GoLog("[%s] Verification failed: duration mismatch — expected %ds, got %ds\n",
|
|
||||||
logPrefix, expectedDurationSec, resolved.Duration)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -21,40 +21,6 @@ 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")
|
||||||
|
|||||||
@@ -0,0 +1,751 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type YouTubeDownloader struct {
|
||||||
|
client *http.Client
|
||||||
|
apiURL string
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
const spotubeBaseURL = "https://spotubedl.com"
|
||||||
|
|
||||||
|
var (
|
||||||
|
globalYouTubeDownloader *YouTubeDownloader
|
||||||
|
youtubeDownloaderOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
type YouTubeQuality string
|
||||||
|
|
||||||
|
const (
|
||||||
|
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
|
||||||
|
YouTubeQualityOpus128 YouTubeQuality = "opus_128"
|
||||||
|
YouTubeQualityMP3128 YouTubeQuality = "mp3_128"
|
||||||
|
YouTubeQualityMP3256 YouTubeQuality = "mp3_256"
|
||||||
|
YouTubeQualityMP3320 YouTubeQuality = "mp3_320"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
youtubeOpusSupportedBitrates = []int{128, 256}
|
||||||
|
youtubeMp3SupportedBitrates = []int{128, 256, 320}
|
||||||
|
)
|
||||||
|
|
||||||
|
type CobaltRequest struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
AudioBitrate string `json:"audioBitrate,omitempty"`
|
||||||
|
AudioFormat string `json:"audioFormat,omitempty"`
|
||||||
|
DownloadMode string `json:"downloadMode,omitempty"`
|
||||||
|
FilenameStyle string `json:"filenameStyle,omitempty"`
|
||||||
|
DisableMetadata bool `json:"disableMetadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CobaltResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
Filename string `json:"filename,omitempty"`
|
||||||
|
Error *struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Context *struct {
|
||||||
|
Service string `json:"service,omitempty"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
} `json:"context,omitempty"`
|
||||||
|
} `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type YouTubeDownloadResult struct {
|
||||||
|
FilePath string
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
Album string
|
||||||
|
ReleaseDate string
|
||||||
|
TrackNumber int
|
||||||
|
DiscNumber int
|
||||||
|
ISRC string
|
||||||
|
Format string // "opus" or "mp3"
|
||||||
|
Bitrate int
|
||||||
|
LyricsLRC string
|
||||||
|
CoverData []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewYouTubeDownloader() *YouTubeDownloader {
|
||||||
|
youtubeDownloaderOnce.Do(func() {
|
||||||
|
globalYouTubeDownloader = &YouTubeDownloader{
|
||||||
|
client: NewHTTPClientWithTimeout(120 * time.Second),
|
||||||
|
apiURL: "https://api.qwkuns.me",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalYouTubeDownloader
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractBitrateFromQuality(raw string, defaultBitrate int) int {
|
||||||
|
parts := strings.FieldsFunc(raw, func(r rune) bool {
|
||||||
|
return (r < '0' || r > '9')
|
||||||
|
})
|
||||||
|
for i := len(parts) - 1; i >= 0; i-- {
|
||||||
|
part := parts[i]
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if parsed, err := strconv.Atoi(part); err == nil {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultBitrate
|
||||||
|
}
|
||||||
|
|
||||||
|
func nearestSupportedBitrate(value int, supported []int) int {
|
||||||
|
nearest := supported[0]
|
||||||
|
nearestDistance := absInt(value - nearest)
|
||||||
|
|
||||||
|
for _, option := range supported[1:] {
|
||||||
|
distance := absInt(value - option)
|
||||||
|
// On tie prefer higher quality.
|
||||||
|
if distance < nearestDistance || (distance == nearestDistance && option > nearest) {
|
||||||
|
nearest = option
|
||||||
|
nearestDistance = distance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nearest
|
||||||
|
}
|
||||||
|
|
||||||
|
func absInt(value int) int {
|
||||||
|
if value < 0 {
|
||||||
|
return -value
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalized YouTubeQuality) {
|
||||||
|
normalizedRaw := strings.ToLower(strings.TrimSpace(raw))
|
||||||
|
|
||||||
|
if strings.HasPrefix(normalizedRaw, "opus") {
|
||||||
|
parsed := extractBitrateFromQuality(normalizedRaw, 256)
|
||||||
|
finalBitrate := nearestSupportedBitrate(parsed, youtubeOpusSupportedBitrates)
|
||||||
|
return "opus", finalBitrate, YouTubeQuality(fmt.Sprintf("opus_%d", finalBitrate))
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(normalizedRaw, "mp3") {
|
||||||
|
parsed := extractBitrateFromQuality(normalizedRaw, 320)
|
||||||
|
finalBitrate := nearestSupportedBitrate(parsed, youtubeMp3SupportedBitrates)
|
||||||
|
return "mp3", finalBitrate, YouTubeQuality(fmt.Sprintf("mp3_%d", finalBitrate))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward compatibility for legacy symbolic values.
|
||||||
|
switch normalizedRaw {
|
||||||
|
case "opus_256", "opus256", "opus":
|
||||||
|
return "opus", 256, YouTubeQualityOpus256
|
||||||
|
case "opus_128", "opus128":
|
||||||
|
return "opus", 128, YouTubeQualityOpus128
|
||||||
|
case "mp3_320", "mp3320", "mp3", "":
|
||||||
|
return "mp3", 320, YouTubeQualityMP3320
|
||||||
|
case "mp3_256", "mp3256":
|
||||||
|
return "mp3", 256, YouTubeQualityMP3256
|
||||||
|
case "mp3_128", "mp3128":
|
||||||
|
return "mp3", 128, YouTubeQualityMP3128
|
||||||
|
default:
|
||||||
|
return "mp3", 320, YouTubeQualityMP3320
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) {
|
||||||
|
query := fmt.Sprintf("%s %s", artistName, trackName)
|
||||||
|
searchQuery := url.QueryEscape(query)
|
||||||
|
|
||||||
|
GoLog("[YouTube] Search query: %s\n", query)
|
||||||
|
|
||||||
|
youtubeMusicURL := fmt.Sprintf("https://music.youtube.com/search?q=%s", searchQuery)
|
||||||
|
|
||||||
|
return youtubeMusicURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQuality) (*CobaltResponse, error) {
|
||||||
|
y.mu.Lock()
|
||||||
|
defer y.mu.Unlock()
|
||||||
|
|
||||||
|
audioFormat, bitrate, _ := parseYouTubeQualityInput(string(quality))
|
||||||
|
audioBitrate := strconv.Itoa(bitrate)
|
||||||
|
|
||||||
|
// Try SpotubeDL first (primary)
|
||||||
|
var spotubeErr error
|
||||||
|
videoID, extractErr := ExtractYouTubeVideoID(youtubeURL)
|
||||||
|
if extractErr == nil {
|
||||||
|
GoLog("[YouTube] Requesting from SpotubeDL: videoID=%s (format: %s, bitrate: %s)\n",
|
||||||
|
videoID, audioFormat, audioBitrate)
|
||||||
|
|
||||||
|
resp, err := y.requestSpotubeDL(videoID, audioFormat, audioBitrate)
|
||||||
|
if err == nil {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
spotubeErr = err
|
||||||
|
GoLog("[YouTube] SpotubeDL failed: %v, trying Cobalt fallback...\n", err)
|
||||||
|
} else {
|
||||||
|
GoLog("[YouTube] Could not extract video ID: %v, skipping SpotubeDL\n", extractErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: direct Cobalt API (api.qwkuns.me)
|
||||||
|
cobaltURL := toYouTubeMusicURL(youtubeURL)
|
||||||
|
GoLog("[YouTube] Requesting from Cobalt API: %s (format: %s, bitrate: %s)\n",
|
||||||
|
cobaltURL, audioFormat, audioBitrate)
|
||||||
|
|
||||||
|
resp, err := y.requestCobaltDirect(cobaltURL, audioFormat, audioBitrate)
|
||||||
|
if err != nil {
|
||||||
|
if spotubeErr != nil {
|
||||||
|
return nil, fmt.Errorf("all download methods failed: spotubedl: %v, cobalt: %v", spotubeErr, err)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("all download methods failed: spotubedl: extractErr=%v, cobalt: %v", extractErr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) {
|
||||||
|
reqBody := CobaltRequest{
|
||||||
|
URL: videoURL,
|
||||||
|
AudioFormat: audioFormat,
|
||||||
|
AudioBitrate: audioBitrate,
|
||||||
|
DownloadMode: "audio",
|
||||||
|
FilenameStyle: "basic",
|
||||||
|
DisableMetadata: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", y.apiURL, strings.NewReader(string(jsonData)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := DoRequestWithUserAgent(y.client, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cobalt API request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[YouTube] Cobalt API response status: %d\n", resp.StatusCode)
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("cobalt API returned status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var cobaltResp CobaltResponse
|
||||||
|
if err := json.Unmarshal(body, &cobaltResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cobaltResp.Status == "error" && cobaltResp.Error != nil {
|
||||||
|
return nil, fmt.Errorf("cobalt error: %s", cobaltResp.Error.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cobaltResp.Status != "tunnel" && cobaltResp.Status != "redirect" {
|
||||||
|
return nil, fmt.Errorf("unexpected cobalt status: %s", cobaltResp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cobaltResp.URL == "" {
|
||||||
|
return nil, fmt.Errorf("no download URL in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[YouTube] Got download URL from Cobalt (status: %s)\n", cobaltResp.Status)
|
||||||
|
return &cobaltResp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestSpotubeDL uses SpotubeDL as a Cobalt proxy (they handle auth to yt-dl.click instances).
|
||||||
|
// Engines v3/v2 are MP3-oriented outputs, so we only use them for MP3 requests.
|
||||||
|
func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate string) (*CobaltResponse, error) {
|
||||||
|
engines := []string{"v1"}
|
||||||
|
if strings.EqualFold(audioFormat, "mp3") {
|
||||||
|
engines = append(engines, "v3", "v2")
|
||||||
|
}
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
for _, engine := range engines {
|
||||||
|
resp, err := y.requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine)
|
||||||
|
if err == nil {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
GoLog("[YouTube] SpotubeDL (%s) failed: %v\n", engine, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastErr == nil {
|
||||||
|
lastErr = fmt.Errorf("no SpotubeDL engine available")
|
||||||
|
}
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (y *YouTubeDownloader) requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine string) (*CobaltResponse, error) {
|
||||||
|
apiURL := fmt.Sprintf("%s/api/download/%s?engine=%s&format=%s&quality=%s",
|
||||||
|
spotubeBaseURL, videoID, url.QueryEscape(engine), url.QueryEscape(audioFormat), url.QueryEscape(audioBitrate))
|
||||||
|
|
||||||
|
GoLog("[YouTube] Requesting from SpotubeDL (%s): %s\n", engine, apiURL)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := DoRequestWithUserAgent(y.client, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("spotubedl request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[YouTube] SpotubeDL (%s) response status: %d\n", engine, resp.StatusCode)
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("spotubedl(%s) returned status %d: %s", engine, resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse spotubedl response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadURL := strings.TrimSpace(result.URL)
|
||||||
|
if downloadURL == "" {
|
||||||
|
if result.Error != "" {
|
||||||
|
return nil, fmt.Errorf("spotubedl(%s) error: %s", engine, result.Error)
|
||||||
|
}
|
||||||
|
if result.Message != "" {
|
||||||
|
return nil, fmt.Errorf("spotubedl(%s) message: %s", engine, result.Message)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no download URL from spotubedl(%s)", engine)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(downloadURL, "/") {
|
||||||
|
downloadURL = spotubeBaseURL + downloadURL
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(downloadURL, "http://") && !strings.HasPrefix(downloadURL, "https://") {
|
||||||
|
return nil, fmt.Errorf("invalid download URL from spotubedl(%s): %s", engine, downloadURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := strings.TrimSpace(result.Filename)
|
||||||
|
if filename == "" {
|
||||||
|
if parsedURL, parseErr := url.Parse(downloadURL); parseErr == nil {
|
||||||
|
if queryFilename := strings.TrimSpace(parsedURL.Query().Get("filename")); queryFilename != "" {
|
||||||
|
if decodedFilename, decodeErr := url.QueryUnescape(queryFilename); decodeErr == nil {
|
||||||
|
filename = decodedFilename
|
||||||
|
} else {
|
||||||
|
filename = queryFilename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[YouTube] Got download URL from SpotubeDL (%s)\n", engine)
|
||||||
|
return &CobaltResponse{
|
||||||
|
Status: "tunnel",
|
||||||
|
URL: downloadURL,
|
||||||
|
Filename: filename,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (y *YouTubeDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if itemID != "" {
|
||||||
|
StartItemProgress(itemID)
|
||||||
|
defer CompleteItemProgress(itemID)
|
||||||
|
ctx = initDownloadCancel(itemID)
|
||||||
|
defer clearDownloadCancel(itemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := DoRequestWithUserAgent(y.client, req)
|
||||||
|
if err != nil {
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
|
return fmt.Errorf("download request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedSize := resp.ContentLength
|
||||||
|
if expectedSize > 0 && itemID != "" {
|
||||||
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := openOutputForWrite(outputPath, outputFD)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create output file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||||
|
|
||||||
|
var written int64
|
||||||
|
if itemID != "" {
|
||||||
|
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
||||||
|
written, err = io.Copy(progressWriter, resp.Body)
|
||||||
|
} else {
|
||||||
|
written, err = io.Copy(bufWriter, resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
flushErr := bufWriter.Flush()
|
||||||
|
closeErr := out.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
|
return fmt.Errorf("download interrupted: %w", err)
|
||||||
|
}
|
||||||
|
if flushErr != nil {
|
||||||
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
|
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
|
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if expectedSize > 0 && written != expectedSize {
|
||||||
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
|
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[YouTube] Download completed: %d bytes written\n", written)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildYouTubeSearchURL(trackName, artistName string) string {
|
||||||
|
query := fmt.Sprintf("%s %s official audio", artistName, trackName)
|
||||||
|
return fmt.Sprintf("https://music.youtube.com/search?q=%s", url.QueryEscape(query))
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildYouTubeWatchURL(videoID string) string {
|
||||||
|
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isYouTubeVideoID(s string) bool {
|
||||||
|
if len(s) != 11 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range s {
|
||||||
|
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsYouTubeURL(urlStr string) bool {
|
||||||
|
lower := strings.ToLower(urlStr)
|
||||||
|
return strings.Contains(lower, "youtube.com") ||
|
||||||
|
strings.Contains(lower, "youtu.be") ||
|
||||||
|
strings.Contains(lower, "music.youtube.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
// toYouTubeMusicURL converts any YouTube URL to music.youtube.com format.
|
||||||
|
// YouTube Music URLs bypass the login requirement that affects regular YouTube videos on Cobalt.
|
||||||
|
func toYouTubeMusicURL(rawURL string) string {
|
||||||
|
videoID, err := ExtractYouTubeVideoID(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractYouTubeVideoID(urlStr string) (string, error) {
|
||||||
|
if strings.Contains(urlStr, "youtu.be/") {
|
||||||
|
parts := strings.Split(urlStr, "youtu.be/")
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
videoID := strings.Split(parts[1], "?")[0]
|
||||||
|
videoID = strings.Split(videoID, "&")[0]
|
||||||
|
return strings.TrimSpace(videoID), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// /watch?v=
|
||||||
|
if v := parsed.Query().Get("v"); v != "" {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// /embed/
|
||||||
|
if strings.Contains(parsed.Path, "/embed/") {
|
||||||
|
parts := strings.Split(parsed.Path, "/embed/")
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
return strings.Split(parts[1], "/")[0], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// /v/
|
||||||
|
if strings.Contains(parsed.Path, "/v/") {
|
||||||
|
parts := strings.Split(parsed.Path, "/v/")
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
return strings.Split(parts[1], "/")[0], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("could not extract video ID from URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchYouTubeMusicViaExtension uses the YT Music extension's customSearch
|
||||||
|
// to find a track by artist + title. It filters for tracks only (not videos,
|
||||||
|
// albums, or playlists) and returns the YouTube Music watch URL for the first
|
||||||
|
// matching track, or "" if nothing was found.
|
||||||
|
func searchYouTubeMusicViaExtension(artistName, trackName string) string {
|
||||||
|
extManager := GetExtensionManager()
|
||||||
|
searchProviders := extManager.GetSearchProviders()
|
||||||
|
|
||||||
|
// Find the ytmusic-spotiflac extension
|
||||||
|
var ytProvider *ExtensionProviderWrapper
|
||||||
|
for _, p := range searchProviders {
|
||||||
|
if p.extension.ID == "ytmusic-spotiflac" {
|
||||||
|
ytProvider = p
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ytProvider == nil {
|
||||||
|
GoLog("[YouTube] YT Music extension not found or not enabled, skipping fallback\n")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
query := strings.TrimSpace(artistName + " " + trackName)
|
||||||
|
if query == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[YouTube] Searching YT Music extension for: %s\n", query)
|
||||||
|
results, err := ytProvider.CustomSearch(query, map[string]interface{}{
|
||||||
|
"filter": "tracks",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[YouTube] YT Music extension search failed: %v\n", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the first track result (item_type == "track" with a valid video ID)
|
||||||
|
for _, track := range results {
|
||||||
|
if track.ItemType != "" && track.ItemType != "track" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
videoID := strings.TrimSpace(track.ID)
|
||||||
|
if videoID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isYouTubeVideoID(videoID) {
|
||||||
|
return BuildYouTubeWatchURL(videoID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[YouTube] YT Music extension returned no matching tracks for: %s\n", query)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
|
||||||
|
downloader := NewYouTubeDownloader()
|
||||||
|
|
||||||
|
format, bitrate, quality := parseYouTubeQualityInput(req.Quality)
|
||||||
|
|
||||||
|
// URL lookup priority: YouTube video ID > YT Music extension > SongLink (Spotify/Deezer/ISRC)
|
||||||
|
var youtubeURL string
|
||||||
|
var lookupErr error
|
||||||
|
|
||||||
|
// SpotifyID might actually be a YouTube video ID (from YT Music extension)
|
||||||
|
if req.SpotifyID != "" && isYouTubeVideoID(req.SpotifyID) {
|
||||||
|
youtubeURL = BuildYouTubeWatchURL(req.SpotifyID)
|
||||||
|
GoLog("[YouTube] SpotifyID appears to be YouTube video ID, using directly: %s\n", youtubeURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try YT Music extension search first (if installed) - more accurate, tracks only
|
||||||
|
if youtubeURL == "" && (req.TrackName != "" || req.ArtistName != "") {
|
||||||
|
youtubeURL = searchYouTubeMusicViaExtension(req.ArtistName, req.TrackName)
|
||||||
|
if youtubeURL != "" {
|
||||||
|
GoLog("[YouTube] Found YouTube URL via YT Music extension: %s\n", youtubeURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Try Spotify ID via SongLink
|
||||||
|
if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) {
|
||||||
|
GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID)
|
||||||
|
songlink := NewSongLinkClient()
|
||||||
|
youtubeURL, lookupErr = songlink.GetYouTubeURLFromSpotify(req.SpotifyID)
|
||||||
|
if lookupErr != nil {
|
||||||
|
GoLog("[YouTube] SongLink Spotify lookup failed: %v\n", lookupErr)
|
||||||
|
} else {
|
||||||
|
GoLog("[YouTube] Found YouTube URL via SongLink (Spotify): %s\n", youtubeURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Try Deezer ID via SongLink
|
||||||
|
if youtubeURL == "" && req.DeezerID != "" {
|
||||||
|
GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID)
|
||||||
|
songlink := NewSongLinkClient()
|
||||||
|
youtubeURL, lookupErr = songlink.GetYouTubeURLFromDeezer(req.DeezerID)
|
||||||
|
if lookupErr != nil {
|
||||||
|
GoLog("[YouTube] SongLink Deezer lookup failed: %v\n", lookupErr)
|
||||||
|
} else {
|
||||||
|
GoLog("[YouTube] Found YouTube URL via SongLink (Deezer): %s\n", youtubeURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Try ISRC via SongLink
|
||||||
|
if youtubeURL == "" && req.ISRC != "" {
|
||||||
|
GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC)
|
||||||
|
songlink := NewSongLinkClient()
|
||||||
|
availability, isrcErr := songlink.CheckTrackAvailability("", req.ISRC)
|
||||||
|
if isrcErr == nil && availability.YouTube && availability.YouTubeURL != "" {
|
||||||
|
youtubeURL = availability.YouTubeURL
|
||||||
|
GoLog("[YouTube] Found YouTube URL via SongLink (ISRC): %s\n", youtubeURL)
|
||||||
|
} else if isrcErr != nil {
|
||||||
|
GoLog("[YouTube] SongLink ISRC lookup failed: %v\n", isrcErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cobalt requires direct video URLs, not search URLs
|
||||||
|
if youtubeURL == "" {
|
||||||
|
return YouTubeDownloadResult{}, fmt.Errorf("could not find YouTube URL for track: %s - %s (no Spotify/Deezer ID available or track not on YouTube)", req.ArtistName, req.TrackName)
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[YouTube] Requesting download from Cobalt for: %s\n", youtubeURL)
|
||||||
|
|
||||||
|
cobaltResp, err := downloader.GetDownloadURL(youtubeURL, quality)
|
||||||
|
if err != nil {
|
||||||
|
return YouTubeDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := ".mp3"
|
||||||
|
if format == "opus" {
|
||||||
|
ext = ".opus"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some SpotubeDL engines may return a different output container than requested.
|
||||||
|
// Respect the provider-reported filename to avoid saving MP3 bytes with .opus extension.
|
||||||
|
if cobaltResp != nil && cobaltResp.Filename != "" {
|
||||||
|
lowerName := strings.ToLower(strings.TrimSpace(cobaltResp.Filename))
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(lowerName, ".mp3"):
|
||||||
|
ext = ".mp3"
|
||||||
|
format = "mp3"
|
||||||
|
case strings.HasSuffix(lowerName, ".opus"), strings.HasSuffix(lowerName, ".ogg"):
|
||||||
|
ext = ".opus"
|
||||||
|
format = "opus"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
|
||||||
|
"title": req.TrackName,
|
||||||
|
"artist": req.ArtistName,
|
||||||
|
"album": req.AlbumName,
|
||||||
|
"track": req.TrackNumber,
|
||||||
|
"year": extractYear(req.ReleaseDate),
|
||||||
|
"date": req.ReleaseDate,
|
||||||
|
"disc": req.DiscNumber,
|
||||||
|
})
|
||||||
|
filename = sanitizeFilename(filename) + ext
|
||||||
|
|
||||||
|
var outputPath string
|
||||||
|
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||||
|
if isSafOutput {
|
||||||
|
outputPath = strings.TrimSpace(req.OutputPath)
|
||||||
|
if outputPath == "" && isFDOutput(req.OutputFD) {
|
||||||
|
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
outputPath = req.OutputDir + "/" + filename
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[YouTube] Downloading to: %s\n", outputPath)
|
||||||
|
|
||||||
|
var parallelResult *ParallelDownloadResult
|
||||||
|
if req.EmbedLyrics || req.CoverURL != "" {
|
||||||
|
GoLog("[YouTube] Starting parallel fetch for cover and lyrics...\n")
|
||||||
|
parallelResult = FetchCoverAndLyricsParallel(
|
||||||
|
req.CoverURL,
|
||||||
|
req.EmbedMaxQualityCover,
|
||||||
|
req.SpotifyID,
|
||||||
|
req.TrackName,
|
||||||
|
req.ArtistName,
|
||||||
|
req.EmbedLyrics,
|
||||||
|
int64(req.DurationMS),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := downloader.DownloadFile(cobaltResp.URL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
||||||
|
return YouTubeDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lyricsLRC := ""
|
||||||
|
var coverData []byte
|
||||||
|
if parallelResult != nil {
|
||||||
|
if parallelResult.LyricsLRC != "" {
|
||||||
|
lyricsLRC = parallelResult.LyricsLRC
|
||||||
|
GoLog("[YouTube] Got lyrics from lrclib (%d lines)\n", len(parallelResult.LyricsData.Lines))
|
||||||
|
}
|
||||||
|
if parallelResult.CoverData != nil {
|
||||||
|
coverData = parallelResult.CoverData
|
||||||
|
GoLog("[YouTube] Got cover art (%d bytes)\n", len(coverData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return YouTubeDownloadResult{
|
||||||
|
FilePath: outputPath,
|
||||||
|
Title: req.TrackName,
|
||||||
|
Artist: req.ArtistName,
|
||||||
|
Album: req.AlbumName,
|
||||||
|
ReleaseDate: req.ReleaseDate,
|
||||||
|
TrackNumber: req.TrackNumber,
|
||||||
|
DiscNumber: req.DiscNumber,
|
||||||
|
ISRC: req.ISRC,
|
||||||
|
Format: format,
|
||||||
|
Bitrate: bitrate,
|
||||||
|
LyricsLRC: lyricsLRC,
|
||||||
|
CoverData: coverData,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseYouTubeQualityInput_OpusNormalizesToSupportedBitrates(t *testing.T) {
|
||||||
|
format, bitrate, normalized := parseYouTubeQualityInput("opus_160")
|
||||||
|
if format != "opus" {
|
||||||
|
t.Fatalf("expected opus format, got %s", format)
|
||||||
|
}
|
||||||
|
if bitrate != 128 {
|
||||||
|
t.Fatalf("expected 128 bitrate, got %d", bitrate)
|
||||||
|
}
|
||||||
|
if normalized != YouTubeQualityOpus128 {
|
||||||
|
t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus128, normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseYouTubeQualityInput_Mp3NormalizesToSupportedBitrates(t *testing.T) {
|
||||||
|
format, bitrate, normalized := parseYouTubeQualityInput("mp3_192")
|
||||||
|
if format != "mp3" {
|
||||||
|
t.Fatalf("expected mp3 format, got %s", format)
|
||||||
|
}
|
||||||
|
if bitrate != 256 {
|
||||||
|
t.Fatalf("expected 256 bitrate, got %d", bitrate)
|
||||||
|
}
|
||||||
|
if normalized != YouTubeQualityMP3256 {
|
||||||
|
t.Fatalf("expected %s normalized, got %s", YouTubeQualityMP3256, normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
|
||||||
|
_, opusBitrate, _ := parseYouTubeQualityInput("opus_999")
|
||||||
|
if opusBitrate != 256 {
|
||||||
|
t.Fatalf("expected opus normalization to 256, got %d", opusBitrate)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1")
|
||||||
|
if mp3Bitrate != 128 {
|
||||||
|
t.Fatalf("expected mp3 normalization to 128, got %d", mp3Bitrate)
|
||||||
|
}
|
||||||
|
}
|
||||||
-33
@@ -27,37 +27,6 @@ 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!
|
||||||
@@ -73,8 +42,6 @@ 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|
|
||||||
|
|||||||
+20
-101
@@ -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?(self?.parseJsonPayload(payload))
|
self?.downloadProgressEventSink?(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?(self?.parseJsonPayload(payload))
|
self?.libraryScanProgressEventSink?(payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
libraryScanProgressTimer = timer
|
libraryScanProgressTimer = timer
|
||||||
@@ -133,17 +133,6 @@ 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 {
|
||||||
@@ -164,6 +153,13 @@ 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
|
||||||
@@ -180,11 +176,11 @@ import Gobackend // Import Go framework
|
|||||||
|
|
||||||
case "getDownloadProgress":
|
case "getDownloadProgress":
|
||||||
let response = GobackendGetDownloadProgress()
|
let response = GobackendGetDownloadProgress()
|
||||||
return parseJsonPayload(response as String? ?? "{}")
|
return response
|
||||||
|
|
||||||
case "getAllDownloadProgress":
|
case "getAllDownloadProgress":
|
||||||
let response = GobackendGetAllDownloadProgress()
|
let response = GobackendGetAllDownloadProgress()
|
||||||
return parseJsonPayload(response as String? ?? "{}")
|
return response
|
||||||
|
|
||||||
case "initItemProgress":
|
case "initItemProgress":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
@@ -307,15 +303,6 @@ 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
|
||||||
@@ -344,8 +331,7 @@ 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
|
||||||
let audioFilePath = args["audio_file_path"] as? String ?? ""
|
GobackendFetchAndSaveLyrics(trackName, artistName, spotifyId, durationMs, outputPath, &error)
|
||||||
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}"
|
||||||
|
|
||||||
@@ -381,26 +367,6 @@ 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
|
||||||
@@ -417,22 +383,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
case "getQobuzMetadata":
|
|
||||||
let args = call.arguments as! [String: Any]
|
|
||||||
let resourceType = args["resource_type"] as! String
|
|
||||||
let resourceId = args["resource_id"] as! String
|
|
||||||
let response = GobackendGetQobuzMetadata(resourceType, resourceId, &error)
|
|
||||||
if let error = error { throw error }
|
|
||||||
return response
|
|
||||||
|
|
||||||
case "getTidalMetadata":
|
|
||||||
let args = call.arguments as! [String: Any]
|
|
||||||
let resourceType = args["resource_type"] as! String
|
|
||||||
let resourceId = args["resource_id"] as! String
|
|
||||||
let response = GobackendGetTidalMetadata(resourceType, resourceId, &error)
|
|
||||||
if let error = error { throw error }
|
|
||||||
return response
|
|
||||||
|
|
||||||
case "parseDeezerUrl":
|
case "parseDeezerUrl":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let url = args["url"] as! String
|
let url = args["url"] as! String
|
||||||
@@ -440,13 +390,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
case "parseQobuzUrl":
|
|
||||||
let args = call.arguments as! [String: Any]
|
|
||||||
let url = args["url"] as! String
|
|
||||||
let response = GobackendParseQobuzURLExport(url, &error)
|
|
||||||
if let error = error { throw error }
|
|
||||||
return response
|
|
||||||
|
|
||||||
case "parseTidalUrl":
|
case "parseTidalUrl":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let url = args["url"] as! String
|
let url = args["url"] as! String
|
||||||
@@ -483,6 +426,13 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "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
|
||||||
@@ -650,20 +600,6 @@ import Gobackend // Import Go framework
|
|||||||
let response = GobackendSearchTracksWithExtensionsJSON(query, Int(limit), &error)
|
let response = GobackendSearchTracksWithExtensionsJSON(query, Int(limit), &error)
|
||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
case "searchTracksWithMetadataProviders":
|
|
||||||
let args = call.arguments as! [String: Any]
|
|
||||||
let query = args["query"] as! String
|
|
||||||
let limit = args["limit"] as? Int ?? 20
|
|
||||||
let includeExtensions = args["include_extensions"] as? Bool ?? true
|
|
||||||
let response = GobackendSearchTracksWithMetadataProvidersJSON(
|
|
||||||
query,
|
|
||||||
Int(limit),
|
|
||||||
includeExtensions,
|
|
||||||
&error
|
|
||||||
)
|
|
||||||
if let error = error { throw error }
|
|
||||||
return response
|
|
||||||
|
|
||||||
case "enrichTrackWithExtension":
|
case "enrichTrackWithExtension":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
@@ -855,23 +791,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
case "setStoreRegistryUrl":
|
|
||||||
let args = call.arguments as! [String: Any]
|
|
||||||
let registryUrl = args["registry_url"] as? String ?? ""
|
|
||||||
GobackendSetStoreRegistryURLJSON(registryUrl, &error)
|
|
||||||
if let error = error { throw error }
|
|
||||||
return nil
|
|
||||||
|
|
||||||
case "getStoreRegistryUrl":
|
|
||||||
let response = GobackendGetStoreRegistryURLJSON(&error)
|
|
||||||
if let error = error { throw error }
|
|
||||||
return response
|
|
||||||
|
|
||||||
case "clearStoreRegistryUrl":
|
|
||||||
GobackendClearStoreRegistryURLJSON(&error)
|
|
||||||
if let error = error { throw error }
|
|
||||||
return nil
|
|
||||||
|
|
||||||
case "getStoreExtensions":
|
case "getStoreExtensions":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let forceRefresh = args["force_refresh"] as? Bool ?? false
|
let forceRefresh = args["force_refresh"] as? Bool ?? false
|
||||||
@@ -944,7 +863,7 @@ import Gobackend // Import Go framework
|
|||||||
|
|
||||||
case "getLibraryScanProgress":
|
case "getLibraryScanProgress":
|
||||||
let response = GobackendGetLibraryScanProgressJSON()
|
let response = GobackendGetLibraryScanProgressJSON()
|
||||||
return parseJsonPayload(response as String? ?? "{}")
|
return response
|
||||||
|
|
||||||
case "cancelLibraryScan":
|
case "cancelLibraryScan":
|
||||||
GobackendCancelLibraryScanJSON()
|
GobackendCancelLibraryScanJSON()
|
||||||
|
|||||||
@@ -1,26 +1,21 @@
|
|||||||
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 = '4.2.0';
|
static const String version = '3.7.2';
|
||||||
static const String buildNumber = '121';
|
static const String buildNumber = '105';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
/// Shows "Internal" in debug builds, actual version in release.
|
|
||||||
static String get displayVersion => kDebugMode ? 'Internal' : version;
|
static const String appName = 'SpotiFLAC';
|
||||||
|
|
||||||
static const String appName = 'SpotiFLAC Mobile';
|
|
||||||
static const String copyright = '© 2026 SpotiFLAC';
|
static const String copyright = '© 2026 SpotiFLAC';
|
||||||
|
|
||||||
static const String mobileAuthor = 'zarzet';
|
static const String mobileAuthor = 'zarzet';
|
||||||
static const String originalAuthor = 'afkarxyz';
|
static const String originalAuthor = 'afkarxyz';
|
||||||
|
|
||||||
static const String githubRepo = 'zarzet/SpotiFLAC-Mobile';
|
static const String githubRepo = 'zarzet/SpotiFLAC-Mobile';
|
||||||
static const String githubUrl = 'https://github.com/$githubRepo';
|
static const String githubUrl = 'https://github.com/$githubRepo';
|
||||||
static const String originalGithubUrl =
|
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
|
||||||
'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/';
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-1606
File diff suppressed because it is too large
Load Diff
+64
-1049
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2147
-3412
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+69
-1053
File diff suppressed because it is too large
Load Diff
+24
-1004
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2144
-3409
File diff suppressed because it is too large
Load Diff
+36
-1017
File diff suppressed because it is too large
Load Diff
+152
-1126
File diff suppressed because it is too large
Load Diff
+2362
-3442
File diff suppressed because it is too large
Load Diff
+51
-143
@@ -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 und Qobuz herunter.",
|
"aboutAppDescription": "Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music 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 Dokumentenordner der App gespeichert. Du kannst sie über die Datei-App aufrufen.",
|
"setupDownloadLocationIosMessage": "Auf iOS werden Downloads im Dokumentenverzeichnis der App gespeichert. Du kannst sie über die Datei-App aufrufen.",
|
||||||
"@setupDownloadLocationIosMessage": {
|
"@setupDownloadLocationIosMessage": {
|
||||||
"description": "iOS-specific folder info"
|
"description": "iOS-specific folder info"
|
||||||
},
|
},
|
||||||
@@ -897,18 +897,6 @@
|
|||||||
"@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",
|
||||||
@@ -959,7 +947,7 @@
|
|||||||
"@selectionAllSelected": {
|
"@selectionAllSelected": {
|
||||||
"description": "Status - all items selected"
|
"description": "Status - all items selected"
|
||||||
},
|
},
|
||||||
"selectionSelectToDelete": "Titel zum Löschen wählen",
|
"selectionSelectToDelete": "Titel zum Löschen auswählen",
|
||||||
"@selectionSelectToDelete": {
|
"@selectionSelectToDelete": {
|
||||||
"description": "Placeholder when nothing selected"
|
"description": "Placeholder when nothing selected"
|
||||||
},
|
},
|
||||||
@@ -987,7 +975,7 @@
|
|||||||
"@searchArtists": {
|
"@searchArtists": {
|
||||||
"description": "Search result category - artists"
|
"description": "Search result category - artists"
|
||||||
},
|
},
|
||||||
"searchAlbums": "Alben",
|
"searchAlbums": "Albums",
|
||||||
"@searchAlbums": {
|
"@searchAlbums": {
|
||||||
"description": "Search result category - albums"
|
"description": "Search result category - albums"
|
||||||
},
|
},
|
||||||
@@ -1015,14 +1003,6 @@
|
|||||||
"@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"
|
||||||
@@ -1039,7 +1019,7 @@
|
|||||||
"@folderOrganizationDescription": {
|
"@folderOrganizationDescription": {
|
||||||
"description": "Folder organization sheet description"
|
"description": "Folder organization sheet description"
|
||||||
},
|
},
|
||||||
"folderOrganizationNoneSubtitle": "Alle Dateien im Download-Ordner",
|
"folderOrganizationNoneSubtitle": "Alle Dateien im Download-Verzeichnis",
|
||||||
"@folderOrganizationNoneSubtitle": {
|
"@folderOrganizationNoneSubtitle": {
|
||||||
"description": "Subtitle for no organization option"
|
"description": "Subtitle for no organization option"
|
||||||
},
|
},
|
||||||
@@ -1117,7 +1097,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "Integriert",
|
"providerBuiltIn": "Integriert",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Erweiterung",
|
"providerExtension": "Erweiterung",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1773,11 +1753,23 @@
|
|||||||
"@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": "Download-Ordner",
|
"downloadDirectory": "Downloadverzeichnis",
|
||||||
"@downloadDirectory": {
|
"@downloadDirectory": {
|
||||||
"description": "Setting - download folder"
|
"description": "Setting - download folder"
|
||||||
},
|
},
|
||||||
@@ -1785,15 +1777,15 @@
|
|||||||
"@downloadSeparateSinglesFolder": {
|
"@downloadSeparateSinglesFolder": {
|
||||||
"description": "Setting - separate folder for singles"
|
"description": "Setting - separate folder for singles"
|
||||||
},
|
},
|
||||||
"downloadAlbumFolderStructure": "Album-Ordnerstruktur",
|
"downloadAlbumFolderStructure": "Album Folder Structure",
|
||||||
"@downloadAlbumFolderStructure": {
|
"@downloadAlbumFolderStructure": {
|
||||||
"description": "Setting - album folder organization"
|
"description": "Setting - album folder organization"
|
||||||
},
|
},
|
||||||
"downloadUseAlbumArtistForFolders": "Album-Künstler für Ordner verwenden",
|
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
||||||
"@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": "Primärer Künstler nur für Ordner",
|
"downloadUsePrimaryArtistOnly": "Primary artist only for folders",
|
||||||
"@downloadUsePrimaryArtistOnly": {
|
"@downloadUsePrimaryArtistOnly": {
|
||||||
"description": "Setting - strip featured artists from folder name"
|
"description": "Setting - strip featured artists from folder name"
|
||||||
},
|
},
|
||||||
@@ -1801,7 +1793,7 @@
|
|||||||
"@downloadUsePrimaryArtistOnlyEnabled": {
|
"@downloadUsePrimaryArtistOnlyEnabled": {
|
||||||
"description": "Subtitle when primary artist only is enabled"
|
"description": "Subtitle when primary artist only is enabled"
|
||||||
},
|
},
|
||||||
"downloadUsePrimaryArtistOnlyDisabled": "Vollständiger Künstler für Ordnername",
|
"downloadUsePrimaryArtistOnlyDisabled": "Full artist string used for folder name",
|
||||||
"@downloadUsePrimaryArtistOnlyDisabled": {
|
"@downloadUsePrimaryArtistOnlyDisabled": {
|
||||||
"description": "Subtitle when primary artist only is disabled"
|
"description": "Subtitle when primary artist only is disabled"
|
||||||
},
|
},
|
||||||
@@ -1829,7 +1821,7 @@
|
|||||||
"@queueClearAllMessage": {
|
"@queueClearAllMessage": {
|
||||||
"description": "Clear queue confirmation"
|
"description": "Clear queue confirmation"
|
||||||
},
|
},
|
||||||
"settingsAutoExportFailed": "Auto-Export fehlgeschlagener Downloads",
|
"settingsAutoExportFailed": "Auto-export failed downloads",
|
||||||
"@settingsAutoExportFailed": {
|
"@settingsAutoExportFailed": {
|
||||||
"description": "Setting toggle for auto-export"
|
"description": "Setting toggle for auto-export"
|
||||||
},
|
},
|
||||||
@@ -1857,15 +1849,15 @@
|
|||||||
"@albumFolderArtistAlbum": {
|
"@albumFolderArtistAlbum": {
|
||||||
"description": "Album folder option"
|
"description": "Album folder option"
|
||||||
},
|
},
|
||||||
"albumFolderArtistAlbumSubtitle": "Alben/Künster Name/Album Name/",
|
"albumFolderArtistAlbumSubtitle": "Albums/Artist Name/Album Name/",
|
||||||
"@albumFolderArtistAlbumSubtitle": {
|
"@albumFolderArtistAlbumSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
"albumFolderArtistYearAlbum": "Künstler / [Year] Album",
|
"albumFolderArtistYearAlbum": "Artist / [Year] Album",
|
||||||
"@albumFolderArtistYearAlbum": {
|
"@albumFolderArtistYearAlbum": {
|
||||||
"description": "Album folder option with year"
|
"description": "Album folder option with year"
|
||||||
},
|
},
|
||||||
"albumFolderArtistYearAlbumSubtitle": "Alben/Künster Name/[2005] Album Name/",
|
"albumFolderArtistYearAlbumSubtitle": "Albums/Künster Name/[2005] Album Name/",
|
||||||
"@albumFolderArtistYearAlbumSubtitle": {
|
"@albumFolderArtistYearAlbumSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
@@ -1881,15 +1873,15 @@
|
|||||||
"@albumFolderYearAlbum": {
|
"@albumFolderYearAlbum": {
|
||||||
"description": "Album folder option with year"
|
"description": "Album folder option with year"
|
||||||
},
|
},
|
||||||
"albumFolderYearAlbumSubtitle": "Alben/[2005] Album Name/",
|
"albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/",
|
||||||
"@albumFolderYearAlbumSubtitle": {
|
"@albumFolderYearAlbumSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
"albumFolderArtistAlbumSingles": "Künstler / Album + Singles",
|
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
|
||||||
"@albumFolderArtistAlbumSingles": {
|
"@albumFolderArtistAlbumSingles": {
|
||||||
"description": "Album folder option with singles inside artist"
|
"description": "Album folder option with singles inside artist"
|
||||||
},
|
},
|
||||||
"albumFolderArtistAlbumSinglesSubtitle": "Künstler/Album/ und Künstler/Singles/",
|
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
|
||||||
"@albumFolderArtistAlbumSinglesSubtitle": {
|
"@albumFolderArtistAlbumSinglesSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
@@ -1932,7 +1924,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"downloadedAlbumSelectToDelete": "Titel zum Löschen wählen",
|
"downloadedAlbumSelectToDelete": "Select tracks to delete",
|
||||||
"@downloadedAlbumSelectToDelete": {
|
"@downloadedAlbumSelectToDelete": {
|
||||||
"description": "Placeholder when nothing selected"
|
"description": "Placeholder when nothing selected"
|
||||||
},
|
},
|
||||||
@@ -2004,7 +1996,7 @@
|
|||||||
"@discographyAlbumsOnly": {
|
"@discographyAlbumsOnly": {
|
||||||
"description": "Option - download only albums"
|
"description": "Option - download only albums"
|
||||||
},
|
},
|
||||||
"discographyAlbumsOnlySubtitle": "{count} Titel aus {albumCount} Alben",
|
"discographyAlbumsOnlySubtitle": "{count} Titel von {albumCount} Albums",
|
||||||
"@discographyAlbumsOnlySubtitle": {
|
"@discographyAlbumsOnlySubtitle": {
|
||||||
"description": "Subtitle showing album tracks count",
|
"description": "Subtitle showing album tracks count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2036,7 +2028,7 @@
|
|||||||
"@discographySelectAlbums": {
|
"@discographySelectAlbums": {
|
||||||
"description": "Option - manually select albums to download"
|
"description": "Option - manually select albums to download"
|
||||||
},
|
},
|
||||||
"discographySelectAlbumsSubtitle": "Wähle bestimmte Alben oder Singles",
|
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
|
||||||
"@discographySelectAlbumsSubtitle": {
|
"@discographySelectAlbumsSubtitle": {
|
||||||
"description": "Subtitle for select albums option"
|
"description": "Subtitle for select albums option"
|
||||||
},
|
},
|
||||||
@@ -2044,7 +2036,7 @@
|
|||||||
"@discographyFetchingTracks": {
|
"@discographyFetchingTracks": {
|
||||||
"description": "Progress - fetching album tracks"
|
"description": "Progress - fetching album tracks"
|
||||||
},
|
},
|
||||||
"discographyFetchingAlbum": "Lade {current} von {total}...",
|
"discographyFetchingAlbum": "Fetching {current} of {total}...",
|
||||||
"@discographyFetchingAlbum": {
|
"@discographyFetchingAlbum": {
|
||||||
"description": "Progress - fetching specific album",
|
"description": "Progress - fetching specific album",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2069,7 +2061,7 @@
|
|||||||
"@discographyDownloadSelected": {
|
"@discographyDownloadSelected": {
|
||||||
"description": "Button - download selected albums"
|
"description": "Button - download selected albums"
|
||||||
},
|
},
|
||||||
"discographyAddedToQueue": "{count} Titel zur Warteschlange hinzugefügt",
|
"discographyAddedToQueue": "Added {count} tracks to queue",
|
||||||
"@discographyAddedToQueue": {
|
"@discographyAddedToQueue": {
|
||||||
"description": "Snackbar - tracks added from discography",
|
"description": "Snackbar - tracks added from discography",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2094,7 +2086,7 @@
|
|||||||
"@discographyNoAlbums": {
|
"@discographyNoAlbums": {
|
||||||
"description": "Error - no albums found for artist"
|
"description": "Error - no albums found for artist"
|
||||||
},
|
},
|
||||||
"discographyFailedToFetch": "Fehler beim Abrufen einiger Alben",
|
"discographyFailedToFetch": "Failed to fetch some albums",
|
||||||
"@discographyFailedToFetch": {
|
"@discographyFailedToFetch": {
|
||||||
"description": "Error - some albums failed to load"
|
"description": "Error - some albums failed to load"
|
||||||
},
|
},
|
||||||
@@ -2106,15 +2098,15 @@
|
|||||||
"@allFilesAccess": {
|
"@allFilesAccess": {
|
||||||
"description": "Toggle for MANAGE_EXTERNAL_STORAGE permission"
|
"description": "Toggle for MANAGE_EXTERNAL_STORAGE permission"
|
||||||
},
|
},
|
||||||
"allFilesAccessEnabledSubtitle": "Darf in jeden Ordner schreiben",
|
"allFilesAccessEnabledSubtitle": "Can write to any folder",
|
||||||
"@allFilesAccessEnabledSubtitle": {
|
"@allFilesAccessEnabledSubtitle": {
|
||||||
"description": "Subtitle when all files access is enabled"
|
"description": "Subtitle when all files access is enabled"
|
||||||
},
|
},
|
||||||
"allFilesAccessDisabledSubtitle": "Nur auf Medienordner begrenzt",
|
"allFilesAccessDisabledSubtitle": "Limited to media folders only",
|
||||||
"@allFilesAccessDisabledSubtitle": {
|
"@allFilesAccessDisabledSubtitle": {
|
||||||
"description": "Subtitle when all files access is disabled"
|
"description": "Subtitle when all files access is disabled"
|
||||||
},
|
},
|
||||||
"allFilesAccessDescription": "Option bei Schreibfehlern bitte aktivieren (erforderlich ab Android 13).",
|
"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": {
|
"@allFilesAccessDescription": {
|
||||||
"description": "Description explaining when to enable all files access"
|
"description": "Description explaining when to enable all files access"
|
||||||
},
|
},
|
||||||
@@ -2130,7 +2122,7 @@
|
|||||||
"@settingsLocalLibrary": {
|
"@settingsLocalLibrary": {
|
||||||
"description": "Settings menu item - local library"
|
"description": "Settings menu item - local library"
|
||||||
},
|
},
|
||||||
"settingsLocalLibrarySubtitle": "Musik scannen & Duplikate erkennen",
|
"settingsLocalLibrarySubtitle": "Scan music & detect duplicates",
|
||||||
"@settingsLocalLibrarySubtitle": {
|
"@settingsLocalLibrarySubtitle": {
|
||||||
"description": "Subtitle for local library settings"
|
"description": "Subtitle for local library settings"
|
||||||
},
|
},
|
||||||
@@ -2138,7 +2130,7 @@
|
|||||||
"@settingsCache": {
|
"@settingsCache": {
|
||||||
"description": "Settings menu item - cache management"
|
"description": "Settings menu item - cache management"
|
||||||
},
|
},
|
||||||
"settingsCacheSubtitle": "Größe anzeigen und Daten im Cache leeren",
|
"settingsCacheSubtitle": "View size and clear cached data",
|
||||||
"@settingsCacheSubtitle": {
|
"@settingsCacheSubtitle": {
|
||||||
"description": "Subtitle for cache management menu"
|
"description": "Subtitle for cache management menu"
|
||||||
},
|
},
|
||||||
@@ -2154,7 +2146,7 @@
|
|||||||
"@libraryEnableLocalLibrary": {
|
"@libraryEnableLocalLibrary": {
|
||||||
"description": "Toggle to enable library scanning"
|
"description": "Toggle to enable library scanning"
|
||||||
},
|
},
|
||||||
"libraryEnableLocalLibrarySubtitle": "Scan und verfolge deine bestehende Musik",
|
"libraryEnableLocalLibrarySubtitle": "Scan and track your existing music",
|
||||||
"@libraryEnableLocalLibrarySubtitle": {
|
"@libraryEnableLocalLibrarySubtitle": {
|
||||||
"description": "Subtitle for enable toggle"
|
"description": "Subtitle for enable toggle"
|
||||||
},
|
},
|
||||||
@@ -2166,7 +2158,7 @@
|
|||||||
"@libraryFolderHint": {
|
"@libraryFolderHint": {
|
||||||
"description": "Placeholder when no folder selected"
|
"description": "Placeholder when no folder selected"
|
||||||
},
|
},
|
||||||
"libraryShowDuplicateIndicator": "Duplikat Indikator anzeigen",
|
"libraryShowDuplicateIndicator": "Show Duplicate Indicator",
|
||||||
"@libraryShowDuplicateIndicator": {
|
"@libraryShowDuplicateIndicator": {
|
||||||
"description": "Toggle for duplicate indicator in search"
|
"description": "Toggle for duplicate indicator in search"
|
||||||
},
|
},
|
||||||
@@ -2391,7 +2383,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"description": "Tutorial welcome tip 1"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip2": "Hole dir FLAC Audio von Tidal, Qobuz oder Deezer",
|
"tutorialWelcomeTip2": "Hole dir FLAC Audio von Tidal, Qobuz oder Amazon Musik",
|
||||||
"@tutorialWelcomeTip2": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2463,7 +2455,7 @@
|
|||||||
"@tutorialSettingsDesc": {
|
"@tutorialSettingsDesc": {
|
||||||
"description": "Tutorial settings page description"
|
"description": "Tutorial settings page description"
|
||||||
},
|
},
|
||||||
"tutorialSettingsTip1": "Download-Ordner und Ordner-Organisation ändern",
|
"tutorialSettingsTip1": "Downloadverzeichnis und Ordnerorganisation ändern",
|
||||||
"@tutorialSettingsTip1": {
|
"@tutorialSettingsTip1": {
|
||||||
"description": "Tutorial settings tip 1"
|
"description": "Tutorial settings tip 1"
|
||||||
},
|
},
|
||||||
@@ -2537,7 +2529,7 @@
|
|||||||
"@cacheSectionMaintenance": {
|
"@cacheSectionMaintenance": {
|
||||||
"description": "Section header for cleanup actions"
|
"description": "Section header for cleanup actions"
|
||||||
},
|
},
|
||||||
"cacheAppDirectory": "App-Cache Ordner",
|
"cacheAppDirectory": "App-Cache Verzeichnis",
|
||||||
"@cacheAppDirectory": {
|
"@cacheAppDirectory": {
|
||||||
"description": "Cache item title for app cache directory"
|
"description": "Cache item title for app cache directory"
|
||||||
},
|
},
|
||||||
@@ -2545,7 +2537,7 @@
|
|||||||
"@cacheAppDirectoryDesc": {
|
"@cacheAppDirectoryDesc": {
|
||||||
"description": "Description of what app cache directory contains"
|
"description": "Description of what app cache directory contains"
|
||||||
},
|
},
|
||||||
"cacheTempDirectory": "Temporärer Ordner",
|
"cacheTempDirectory": "Temporäres Verzeichnis",
|
||||||
"@cacheTempDirectory": {
|
"@cacheTempDirectory": {
|
||||||
"description": "Cache item title for temporary files directory"
|
"description": "Cache item title for temporary files directory"
|
||||||
},
|
},
|
||||||
@@ -2713,7 +2705,7 @@
|
|||||||
"@trackEditMetadata": {
|
"@trackEditMetadata": {
|
||||||
"description": "Menu action - edit embedded metadata"
|
"description": "Menu action - edit embedded metadata"
|
||||||
},
|
},
|
||||||
"trackCoverSaved": "Cover in {fileName} gespeichert",
|
"trackCoverSaved": "Cover art saved to {fileName}",
|
||||||
"@trackCoverSaved": {
|
"@trackCoverSaved": {
|
||||||
"description": "Snackbar after cover art saved",
|
"description": "Snackbar after cover art saved",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2722,7 +2714,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"trackCoverNoSource": "Keine Cover Quelle vorhanden",
|
"trackCoverNoSource": "No cover art source available",
|
||||||
"@trackCoverNoSource": {
|
"@trackCoverNoSource": {
|
||||||
"description": "Snackbar when no cover art URL or embedded cover"
|
"description": "Snackbar when no cover art URL or embedded cover"
|
||||||
},
|
},
|
||||||
@@ -2816,90 +2808,6 @@
|
|||||||
"@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"
|
||||||
@@ -3186,11 +3094,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"downloadUseAlbumArtistForFoldersAlbumSubtitle": "Interpret-Ordner verwenden Album-Interpret, sofern vorhanden",
|
"downloadUseAlbumArtistForFoldersAlbumSubtitle": "Künstlerordner verwenden den Album-Interpreten, wenn verfügbar",
|
||||||
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": {
|
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": {
|
||||||
"description": "Subtitle when Album Artist is used for folder naming"
|
"description": "Subtitle when Album Artist is used for folder naming"
|
||||||
},
|
},
|
||||||
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Künstler-Ordner nur für Titel-Künstler",
|
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only",
|
||||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||||
"description": "Subtitle when Track Artist is used for folder naming"
|
"description": "Subtitle when Track Artist is used for folder naming"
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-1323
File diff suppressed because it is too large
Load Diff
+7
-401
@@ -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 and Qobuz.",
|
"aboutAppDescription": "Descarga pistas de Spotify con calidad sin pérdida de Tidal y Qobuz.",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -897,18 +897,6 @@
|
|||||||
"@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",
|
||||||
@@ -1003,26 +991,10 @@
|
|||||||
"@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"
|
||||||
@@ -1773,6 +1745,10 @@
|
|||||||
"@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"
|
||||||
@@ -2222,15 +2198,6 @@
|
|||||||
"@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",
|
||||||
@@ -2391,7 +2358,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"description": "Tutorial welcome tip 1"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
"tutorialWelcomeTip2": "Obtén audio en calidad FLAC de Tidal, Qobuz o Deezer",
|
||||||
"@tutorialWelcomeTip2": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2816,367 +2783,6 @@
|
|||||||
"@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",
|
||||||
@@ -3194,4 +2800,4 @@
|
|||||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||||
"description": "Subtitle when Track Artist is used for folder naming"
|
"description": "Subtitle when Track Artist is used for folder naming"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-107
@@ -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 and Qobuz.",
|
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -897,18 +897,6 @@
|
|||||||
"@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",
|
||||||
@@ -1015,14 +1003,6 @@
|
|||||||
"@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"
|
||||||
@@ -1117,7 +1097,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "Built-in",
|
"providerBuiltIn": "Built-in",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Extension",
|
"providerExtension": "Extension",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1773,6 +1753,18 @@
|
|||||||
"@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"
|
||||||
@@ -2391,7 +2383,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"description": "Tutorial welcome tip 1"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
|
||||||
"@tutorialWelcomeTip2": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2816,90 +2808,6 @@
|
|||||||
"@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"
|
||||||
|
|||||||
+15
-107
@@ -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 and Qobuz.",
|
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -897,18 +897,6 @@
|
|||||||
"@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",
|
||||||
@@ -1015,14 +1003,6 @@
|
|||||||
"@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"
|
||||||
@@ -1117,7 +1097,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "Built-in",
|
"providerBuiltIn": "Built-in",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Extension",
|
"providerExtension": "Extension",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1773,6 +1753,18 @@
|
|||||||
"@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"
|
||||||
@@ -2391,7 +2383,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"description": "Tutorial welcome tip 1"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
|
||||||
"@tutorialWelcomeTip2": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2816,90 +2808,6 @@
|
|||||||
"@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"
|
||||||
|
|||||||
+71
-228
@@ -17,7 +17,7 @@
|
|||||||
"@navSettings": {
|
"@navSettings": {
|
||||||
"description": "Bottom navigation - Settings tab"
|
"description": "Bottom navigation - Settings tab"
|
||||||
},
|
},
|
||||||
"navStore": "Repo",
|
"navStore": "Toko",
|
||||||
"@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 URL yang didukung atau cari berdasarkan nama",
|
"homeSubtitle": "Tempel link Spotify 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": "Repo Ekstensi",
|
"optionsExtensionStore": "Toko Ekstensi",
|
||||||
"@optionsExtensionStore": {
|
"@optionsExtensionStore": {
|
||||||
"description": "Show/hide store tab"
|
"description": "Show/hide store tab"
|
||||||
},
|
},
|
||||||
"optionsExtensionStoreSubtitle": "Tampilkan tab Repo di navigasi",
|
"optionsExtensionStoreSubtitle": "Tampilkan tab Toko di navigasi",
|
||||||
"@optionsExtensionStoreSubtitle": {
|
"@optionsExtensionStoreSubtitle": {
|
||||||
"description": "Subtitle for extension store toggle"
|
"description": "Subtitle for extension store toggle"
|
||||||
},
|
},
|
||||||
@@ -318,14 +318,10 @@
|
|||||||
"@extensionsUninstall": {
|
"@extensionsUninstall": {
|
||||||
"description": "Uninstall extension button"
|
"description": "Uninstall extension button"
|
||||||
},
|
},
|
||||||
"storeTitle": "Repo Ekstensi",
|
"storeTitle": "Toko 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"
|
||||||
@@ -454,7 +450,7 @@
|
|||||||
"@aboutSpotiSaverDesc": {
|
"@aboutSpotiSaverDesc": {
|
||||||
"description": "Credit for SpotiSaver API"
|
"description": "Credit for SpotiSaver API"
|
||||||
},
|
},
|
||||||
"aboutAppDescription": "Unduh lagu-lagu Spotify dalam kualitas lossless dari Tidal dan Qobuz.",
|
"aboutAppDescription": "Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -1007,11 +1003,11 @@
|
|||||||
"@filenameFormat": {
|
"@filenameFormat": {
|
||||||
"description": "Setting title - filename pattern"
|
"description": "Setting title - filename pattern"
|
||||||
},
|
},
|
||||||
"filenameShowAdvancedTags": "Tampilkan tag lanjutan",
|
"filenameShowAdvancedTags": "Show advanced tags",
|
||||||
"@filenameShowAdvancedTags": {
|
"@filenameShowAdvancedTags": {
|
||||||
"description": "Toggle label for showing advanced filename tags"
|
"description": "Toggle label for showing advanced filename tags"
|
||||||
},
|
},
|
||||||
"filenameShowAdvancedTagsDescription": "Aktifkan tag yang diformat untuk padding trek dan pola tanggal",
|
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
|
||||||
"@filenameShowAdvancedTagsDescription": {
|
"@filenameShowAdvancedTagsDescription": {
|
||||||
"description": "Description for advanced filename tag toggle"
|
"description": "Description for advanced filename tag toggle"
|
||||||
},
|
},
|
||||||
@@ -1019,14 +1015,6 @@
|
|||||||
"@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"
|
||||||
@@ -1121,7 +1109,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "Bawaan",
|
"providerBuiltIn": "Bawaan",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Ekstensi",
|
"providerExtension": "Ekstensi",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1221,7 +1209,7 @@
|
|||||||
"@credentialsDescription": {
|
"@credentialsDescription": {
|
||||||
"description": "Credentials dialog explanation"
|
"description": "Credentials dialog explanation"
|
||||||
},
|
},
|
||||||
"credentialsClientId": "ID Klien",
|
"credentialsClientId": "Client ID",
|
||||||
"@credentialsClientId": {
|
"@credentialsClientId": {
|
||||||
"description": "Client ID field label - DO NOT TRANSLATE"
|
"description": "Client ID field label - DO NOT TRANSLATE"
|
||||||
},
|
},
|
||||||
@@ -1229,7 +1217,7 @@
|
|||||||
"@credentialsClientIdHint": {
|
"@credentialsClientIdHint": {
|
||||||
"description": "Client ID placeholder"
|
"description": "Client ID placeholder"
|
||||||
},
|
},
|
||||||
"credentialsClientSecret": "Rahasia Klien",
|
"credentialsClientSecret": "Client Secret",
|
||||||
"@credentialsClientSecret": {
|
"@credentialsClientSecret": {
|
||||||
"description": "Client Secret field label - DO NOT TRANSLATE"
|
"description": "Client Secret field label - DO NOT TRANSLATE"
|
||||||
},
|
},
|
||||||
@@ -1241,7 +1229,7 @@
|
|||||||
"@channelStable": {
|
"@channelStable": {
|
||||||
"description": "Update channel - stable releases"
|
"description": "Update channel - stable releases"
|
||||||
},
|
},
|
||||||
"channelPreview": "Pratinjau",
|
"channelPreview": "Preview",
|
||||||
"@channelPreview": {
|
"@channelPreview": {
|
||||||
"description": "Update channel - beta/preview releases"
|
"description": "Update channel - beta/preview releases"
|
||||||
},
|
},
|
||||||
@@ -1281,39 +1269,39 @@
|
|||||||
"@sectionFileSettings": {
|
"@sectionFileSettings": {
|
||||||
"description": "Settings section header"
|
"description": "Settings section header"
|
||||||
},
|
},
|
||||||
"sectionLyrics": "Lirik",
|
"sectionLyrics": "Lyrics",
|
||||||
"@sectionLyrics": {
|
"@sectionLyrics": {
|
||||||
"description": "Settings section header"
|
"description": "Settings section header"
|
||||||
},
|
},
|
||||||
"lyricsMode": "Mode Lirik",
|
"lyricsMode": "Lyrics Mode",
|
||||||
"@lyricsMode": {
|
"@lyricsMode": {
|
||||||
"description": "Setting - how to save lyrics"
|
"description": "Setting - how to save lyrics"
|
||||||
},
|
},
|
||||||
"lyricsModeDescription": "Pilih cara lirik disimpan bersama unduhan Anda",
|
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
|
||||||
"@lyricsModeDescription": {
|
"@lyricsModeDescription": {
|
||||||
"description": "Lyrics mode picker description"
|
"description": "Lyrics mode picker description"
|
||||||
},
|
},
|
||||||
"lyricsModeEmbed": "Sematkan dalam file",
|
"lyricsModeEmbed": "Embed in file",
|
||||||
"@lyricsModeEmbed": {
|
"@lyricsModeEmbed": {
|
||||||
"description": "Lyrics mode option - embed in audio file"
|
"description": "Lyrics mode option - embed in audio file"
|
||||||
},
|
},
|
||||||
"lyricsModeEmbedSubtitle": "Lirik tersimpan di dalam metadata FLAC",
|
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
|
||||||
"@lyricsModeEmbedSubtitle": {
|
"@lyricsModeEmbedSubtitle": {
|
||||||
"description": "Subtitle for embed option"
|
"description": "Subtitle for embed option"
|
||||||
},
|
},
|
||||||
"lyricsModeExternal": "File .lrc eksternal",
|
"lyricsModeExternal": "External .lrc file",
|
||||||
"@lyricsModeExternal": {
|
"@lyricsModeExternal": {
|
||||||
"description": "Lyrics mode option - separate LRC file"
|
"description": "Lyrics mode option - separate LRC file"
|
||||||
},
|
},
|
||||||
"lyricsModeExternalSubtitle": "File .lrc terpisah untuk pemutar musik seperti Samsung Music",
|
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
|
||||||
"@lyricsModeExternalSubtitle": {
|
"@lyricsModeExternalSubtitle": {
|
||||||
"description": "Subtitle for external option"
|
"description": "Subtitle for external option"
|
||||||
},
|
},
|
||||||
"lyricsModeBoth": "Keduanya",
|
"lyricsModeBoth": "Both",
|
||||||
"@lyricsModeBoth": {
|
"@lyricsModeBoth": {
|
||||||
"description": "Lyrics mode option - embed and external"
|
"description": "Lyrics mode option - embed and external"
|
||||||
},
|
},
|
||||||
"lyricsModeBothSubtitle": "Sematkan dan simpan file .lrc",
|
"lyricsModeBothSubtitle": "Embed and save .lrc file",
|
||||||
"@lyricsModeBothSubtitle": {
|
"@lyricsModeBothSubtitle": {
|
||||||
"description": "Subtitle for both option"
|
"description": "Subtitle for both option"
|
||||||
},
|
},
|
||||||
@@ -1459,11 +1447,11 @@
|
|||||||
"@trackGenre": {
|
"@trackGenre": {
|
||||||
"description": "Metadata label - music genre"
|
"description": "Metadata label - music genre"
|
||||||
},
|
},
|
||||||
"trackLabel": "Lebel",
|
"trackLabel": "Label",
|
||||||
"@trackLabel": {
|
"@trackLabel": {
|
||||||
"description": "Metadata label - record label"
|
"description": "Metadata label - record label"
|
||||||
},
|
},
|
||||||
"trackCopyright": "Hak cipta",
|
"trackCopyright": "Copyright",
|
||||||
"@trackCopyright": {
|
"@trackCopyright": {
|
||||||
"description": "Metadata label - copyright information"
|
"description": "Metadata label - copyright information"
|
||||||
},
|
},
|
||||||
@@ -1487,15 +1475,15 @@
|
|||||||
"@trackLyricsLoadFailed": {
|
"@trackLyricsLoadFailed": {
|
||||||
"description": "Message when lyrics loading fails"
|
"description": "Message when lyrics loading fails"
|
||||||
},
|
},
|
||||||
"trackEmbedLyrics": "Sematkan Lirik",
|
"trackEmbedLyrics": "Embed Lyrics",
|
||||||
"@trackEmbedLyrics": {
|
"@trackEmbedLyrics": {
|
||||||
"description": "Action - embed lyrics into audio file"
|
"description": "Action - embed lyrics into audio file"
|
||||||
},
|
},
|
||||||
"trackLyricsEmbedded": "Lirik berhasil disematkan",
|
"trackLyricsEmbedded": "Lyrics embedded successfully",
|
||||||
"@trackLyricsEmbedded": {
|
"@trackLyricsEmbedded": {
|
||||||
"description": "Snackbar - lyrics saved to file"
|
"description": "Snackbar - lyrics saved to file"
|
||||||
},
|
},
|
||||||
"trackInstrumental": "Lagu instrumental",
|
"trackInstrumental": "Instrumental track",
|
||||||
"@trackInstrumental": {
|
"@trackInstrumental": {
|
||||||
"description": "Message when track is instrumental (no lyrics)"
|
"description": "Message when track is instrumental (no lyrics)"
|
||||||
},
|
},
|
||||||
@@ -1574,7 +1562,7 @@
|
|||||||
"@storeClearFilters": {
|
"@storeClearFilters": {
|
||||||
"description": "Button to clear all filters"
|
"description": "Button to clear all filters"
|
||||||
},
|
},
|
||||||
"extensionDefaultProvider": "Bawaan (Deezer/Spotify)",
|
"extensionDefaultProvider": "Default (Deezer/Spotify)",
|
||||||
"@extensionDefaultProvider": {
|
"@extensionDefaultProvider": {
|
||||||
"description": "Default search provider option"
|
"description": "Default search provider option"
|
||||||
},
|
},
|
||||||
@@ -1590,7 +1578,7 @@
|
|||||||
"@extensionId": {
|
"@extensionId": {
|
||||||
"description": "Extension detail - unique ID"
|
"description": "Extension detail - unique ID"
|
||||||
},
|
},
|
||||||
"extensionError": "Terjadi kesalahan",
|
"extensionError": "Error",
|
||||||
"@extensionError": {
|
"@extensionError": {
|
||||||
"description": "Extension detail - error message"
|
"description": "Extension detail - error message"
|
||||||
},
|
},
|
||||||
@@ -1777,6 +1765,18 @@
|
|||||||
"@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,35 +1793,19 @@
|
|||||||
"@downloadAlbumFolderStructure": {
|
"@downloadAlbumFolderStructure": {
|
||||||
"description": "Setting - album folder organization"
|
"description": "Setting - album folder organization"
|
||||||
},
|
},
|
||||||
"downloadUseAlbumArtistForFolders": "Gunakan Artis Album untuk folder",
|
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
||||||
"@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"
|
||||||
},
|
},
|
||||||
"downloadCreatePlaylistSourceFolder": "Buat folder sumber playlist",
|
"downloadUsePrimaryArtistOnly": "Primary artist only for folders",
|
||||||
"@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": "Artis unggulan dihapus dari nama folder (misalnya Justin Bieber, Quavo → Justin Bieber)",
|
"downloadUsePrimaryArtistOnlyEnabled": "Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)",
|
||||||
"@downloadUsePrimaryArtistOnlyEnabled": {
|
"@downloadUsePrimaryArtistOnlyEnabled": {
|
||||||
"description": "Subtitle when primary artist only is enabled"
|
"description": "Subtitle when primary artist only is enabled"
|
||||||
},
|
},
|
||||||
"downloadUsePrimaryArtistOnlyDisabled": "Nama lengkap artis digunakan untuk nama folder",
|
"downloadUsePrimaryArtistOnlyDisabled": "Full artist string used for folder name",
|
||||||
"@downloadUsePrimaryArtistOnlyDisabled": {
|
"@downloadUsePrimaryArtistOnlyDisabled": {
|
||||||
"description": "Subtitle when primary artist only is disabled"
|
"description": "Subtitle when primary artist only is disabled"
|
||||||
},
|
},
|
||||||
@@ -1849,27 +1833,27 @@
|
|||||||
"@queueClearAllMessage": {
|
"@queueClearAllMessage": {
|
||||||
"description": "Clear queue confirmation"
|
"description": "Clear queue confirmation"
|
||||||
},
|
},
|
||||||
"settingsAutoExportFailed": "Unduhan yang gagal diekspor otomatis",
|
"settingsAutoExportFailed": "Auto-export failed downloads",
|
||||||
"@settingsAutoExportFailed": {
|
"@settingsAutoExportFailed": {
|
||||||
"description": "Setting toggle for auto-export"
|
"description": "Setting toggle for auto-export"
|
||||||
},
|
},
|
||||||
"settingsAutoExportFailedSubtitle": "Simpan unduhan yang gagal ke file TXT secara otomatis",
|
"settingsAutoExportFailedSubtitle": "Save failed downloads to TXT file automatically",
|
||||||
"@settingsAutoExportFailedSubtitle": {
|
"@settingsAutoExportFailedSubtitle": {
|
||||||
"description": "Subtitle for auto-export setting"
|
"description": "Subtitle for auto-export setting"
|
||||||
},
|
},
|
||||||
"settingsDownloadNetwork": "Jaringan Unduhan",
|
"settingsDownloadNetwork": "Download Network",
|
||||||
"@settingsDownloadNetwork": {
|
"@settingsDownloadNetwork": {
|
||||||
"description": "Setting for network type preference"
|
"description": "Setting for network type preference"
|
||||||
},
|
},
|
||||||
"settingsDownloadNetworkAny": "WiFi + Data Seluler",
|
"settingsDownloadNetworkAny": "WiFi + Mobile Data",
|
||||||
"@settingsDownloadNetworkAny": {
|
"@settingsDownloadNetworkAny": {
|
||||||
"description": "Network option - use any connection"
|
"description": "Network option - use any connection"
|
||||||
},
|
},
|
||||||
"settingsDownloadNetworkWifiOnly": "Hanya WiFi",
|
"settingsDownloadNetworkWifiOnly": "WiFi Only",
|
||||||
"@settingsDownloadNetworkWifiOnly": {
|
"@settingsDownloadNetworkWifiOnly": {
|
||||||
"description": "Network option - only use WiFi"
|
"description": "Network option - only use WiFi"
|
||||||
},
|
},
|
||||||
"settingsDownloadNetworkSubtitle": "Pilih jaringan mana yang akan digunakan untuk mengunduh. Jika diatur ke Hanya WiFi, unduhan akan berhenti sementara dan menggunakan data seluler.",
|
"settingsDownloadNetworkSubtitle": "Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.",
|
||||||
"@settingsDownloadNetworkSubtitle": {
|
"@settingsDownloadNetworkSubtitle": {
|
||||||
"description": "Subtitle explaining network preference"
|
"description": "Subtitle explaining network preference"
|
||||||
},
|
},
|
||||||
@@ -1905,11 +1889,11 @@
|
|||||||
"@albumFolderYearAlbumSubtitle": {
|
"@albumFolderYearAlbumSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
"albumFolderArtistAlbumSingles": "Artis / Album + Singel",
|
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
|
||||||
"@albumFolderArtistAlbumSingles": {
|
"@albumFolderArtistAlbumSingles": {
|
||||||
"description": "Album folder option with singles inside artist"
|
"description": "Album folder option with singles inside artist"
|
||||||
},
|
},
|
||||||
"albumFolderArtistAlbumSinglesSubtitle": "Artis/Album/ dan Artis/Single/",
|
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
|
||||||
"@albumFolderArtistAlbumSinglesSubtitle": {
|
"@albumFolderArtistAlbumSinglesSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
@@ -1978,19 +1962,19 @@
|
|||||||
"@recentTypeSong": {
|
"@recentTypeSong": {
|
||||||
"description": "Recent access item type - song/track"
|
"description": "Recent access item type - song/track"
|
||||||
},
|
},
|
||||||
"recentTypePlaylist": "Daftar putar",
|
"recentTypePlaylist": "Playlist",
|
||||||
"@recentTypePlaylist": {
|
"@recentTypePlaylist": {
|
||||||
"description": "Recent access item type - playlist"
|
"description": "Recent access item type - playlist"
|
||||||
},
|
},
|
||||||
"recentEmpty": "Belum ada item terbaru",
|
"recentEmpty": "No recent items yet",
|
||||||
"@recentEmpty": {
|
"@recentEmpty": {
|
||||||
"description": "Empty state text for recent access list"
|
"description": "Empty state text for recent access list"
|
||||||
},
|
},
|
||||||
"recentShowAllDownloads": "Tampilkan Semua Unduhan",
|
"recentShowAllDownloads": "Show All Downloads",
|
||||||
"@recentShowAllDownloads": {
|
"@recentShowAllDownloads": {
|
||||||
"description": "Button label to unhide hidden downloads in recent access"
|
"description": "Button label to unhide hidden downloads in recent access"
|
||||||
},
|
},
|
||||||
"recentPlaylistInfo": "Daftar Putar: {name}",
|
"recentPlaylistInfo": "Playlist: {name}",
|
||||||
"@recentPlaylistInfo": {
|
"@recentPlaylistInfo": {
|
||||||
"description": "Snackbar message when tapping playlist in recent access",
|
"description": "Snackbar message when tapping playlist in recent access",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2000,7 +1984,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"discographyDownload": "Unduh Diskografi",
|
"discographyDownload": "Download Discography",
|
||||||
"@discographyDownload": {
|
"@discographyDownload": {
|
||||||
"description": "Button - download artist discography"
|
"description": "Button - download artist discography"
|
||||||
},
|
},
|
||||||
@@ -2399,47 +2383,47 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTitle": "Selamat Datang di SpotiFLAC!",
|
"tutorialWelcomeTitle": "Welcome to SpotiFLAC!",
|
||||||
"@tutorialWelcomeTitle": {
|
"@tutorialWelcomeTitle": {
|
||||||
"description": "Tutorial welcome page title"
|
"description": "Tutorial welcome page title"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeDesc": "Mari kita pelajari cara mengunduh musik favorit Anda dalam kualitas lossless. Tutorial singkat ini akan menunjukkan dasar-dasarnya.",
|
"tutorialWelcomeDesc": "Let's learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.",
|
||||||
"@tutorialWelcomeDesc": {
|
"@tutorialWelcomeDesc": {
|
||||||
"description": "Tutorial welcome page description"
|
"description": "Tutorial welcome page description"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip1": "Unduh musik dari Spotify, Deezer, atau tempel URL yang didukung",
|
"tutorialWelcomeTip1": "Download music from Spotify, Deezer, or paste any supported URL",
|
||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"description": "Tutorial welcome tip 1"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip2": "Dapatkan audio berkualitas FLAC dari Tidal, Qobuz, atau Deezer",
|
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
|
||||||
"@tutorialWelcomeTip2": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip3": "Penyematan metadata, sampul album, dan lirik secara otomatis",
|
"tutorialWelcomeTip3": "Automatic metadata, cover art, and lyrics embedding",
|
||||||
"@tutorialWelcomeTip3": {
|
"@tutorialWelcomeTip3": {
|
||||||
"description": "Tutorial welcome tip 3"
|
"description": "Tutorial welcome tip 3"
|
||||||
},
|
},
|
||||||
"tutorialSearchTitle": "Menemukan Musik",
|
"tutorialSearchTitle": "Finding Music",
|
||||||
"@tutorialSearchTitle": {
|
"@tutorialSearchTitle": {
|
||||||
"description": "Tutorial search page title"
|
"description": "Tutorial search page title"
|
||||||
},
|
},
|
||||||
"tutorialSearchDesc": "Ada dua cara mudah untuk menemukan musik yang ingin Anda unduh.",
|
"tutorialSearchDesc": "There are two easy ways to find music you want to download.",
|
||||||
"@tutorialSearchDesc": {
|
"@tutorialSearchDesc": {
|
||||||
"description": "Tutorial search page description"
|
"description": "Tutorial search page description"
|
||||||
},
|
},
|
||||||
"tutorialDownloadTitle": "Mengunduh Musik",
|
"tutorialDownloadTitle": "Downloading Music",
|
||||||
"@tutorialDownloadTitle": {
|
"@tutorialDownloadTitle": {
|
||||||
"description": "Tutorial download page title"
|
"description": "Tutorial download page title"
|
||||||
},
|
},
|
||||||
"tutorialDownloadDesc": "Mengunduh musik itu mudah dan cepat. Begini cara kerjanya.",
|
"tutorialDownloadDesc": "Downloading music is simple and fast. Here's how it works.",
|
||||||
"@tutorialDownloadDesc": {
|
"@tutorialDownloadDesc": {
|
||||||
"description": "Tutorial download page description"
|
"description": "Tutorial download page description"
|
||||||
},
|
},
|
||||||
"tutorialLibraryTitle": "Perpustakaan Anda",
|
"tutorialLibraryTitle": "Your Library",
|
||||||
"@tutorialLibraryTitle": {
|
"@tutorialLibraryTitle": {
|
||||||
"description": "Tutorial library page title"
|
"description": "Tutorial library page title"
|
||||||
},
|
},
|
||||||
"tutorialLibraryDesc": "Semua musik yang Anda unduh tersusun rapi di tab Perpustakaan.",
|
"tutorialLibraryDesc": "All your downloaded music is organized in the Library tab.",
|
||||||
"@tutorialLibraryDesc": {
|
"@tutorialLibraryDesc": {
|
||||||
"description": "Tutorial library page description"
|
"description": "Tutorial library page description"
|
||||||
},
|
},
|
||||||
@@ -2463,7 +2447,7 @@
|
|||||||
"@tutorialExtensionsDesc": {
|
"@tutorialExtensionsDesc": {
|
||||||
"description": "Tutorial extensions page description"
|
"description": "Tutorial extensions page description"
|
||||||
},
|
},
|
||||||
"tutorialExtensionsTip1": "Buka tab Repo untuk menemukan ekstensi yang berguna",
|
"tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions",
|
||||||
"@tutorialExtensionsTip1": {
|
"@tutorialExtensionsTip1": {
|
||||||
"description": "Tutorial extensions tip 1"
|
"description": "Tutorial extensions tip 1"
|
||||||
},
|
},
|
||||||
@@ -2771,47 +2755,6 @@
|
|||||||
"@trackReEnrichFfmpegFailed": {
|
"@trackReEnrichFfmpegFailed": {
|
||||||
"description": "Snackbar when FFmpeg embed fails for MP3/Opus"
|
"description": "Snackbar when FFmpeg embed fails for MP3/Opus"
|
||||||
},
|
},
|
||||||
"queueFlacAction": "Antrekan FLAC",
|
|
||||||
"@queueFlacAction": {
|
|
||||||
"description": "Action/button label for queueing FLAC redownloads for local tracks"
|
|
||||||
},
|
|
||||||
"queueFlacConfirmMessage": "Cari kecocokan online untuk track yang dipilih lalu antrekan download FLAC.\n\nFile yang sudah ada tidak akan diubah atau dihapus.\n\nHanya kecocokan dengan keyakinan tinggi yang akan diantrikan otomatis.\n\n{count} dipilih",
|
|
||||||
"@queueFlacConfirmMessage": {
|
|
||||||
"description": "Confirmation dialog body before queueing FLAC redownloads for local tracks",
|
|
||||||
"placeholders": {
|
|
||||||
"count": {
|
|
||||||
"type": "int"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"queueFlacFindingProgress": "Mencari kecocokan FLAC... ({current}/{total})",
|
|
||||||
"@queueFlacFindingProgress": {
|
|
||||||
"description": "Snackbar while resolving remote matches for local FLAC redownloads",
|
|
||||||
"placeholders": {
|
|
||||||
"current": {
|
|
||||||
"type": "int"
|
|
||||||
},
|
|
||||||
"total": {
|
|
||||||
"type": "int"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"queueFlacNoReliableMatches": "Tidak ada kecocokan online yang cukup meyakinkan untuk pilihan ini",
|
|
||||||
"@queueFlacNoReliableMatches": {
|
|
||||||
"description": "Snackbar when no safe FLAC redownload matches were found"
|
|
||||||
},
|
|
||||||
"queueFlacQueuedWithSkipped": "Menambahkan {addedCount} track ke antrean, melewati {skippedCount}",
|
|
||||||
"@queueFlacQueuedWithSkipped": {
|
|
||||||
"description": "Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped",
|
|
||||||
"placeholders": {
|
|
||||||
"addedCount": {
|
|
||||||
"type": "int"
|
|
||||||
},
|
|
||||||
"skippedCount": {
|
|
||||||
"type": "int"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"trackSaveFailed": "Failed: {error}",
|
"trackSaveFailed": "Failed: {error}",
|
||||||
"@trackSaveFailed": {
|
"@trackSaveFailed": {
|
||||||
"description": "Snackbar when save operation fails",
|
"description": "Snackbar when save operation fails",
|
||||||
@@ -2825,7 +2768,7 @@
|
|||||||
"@trackConvertFormat": {
|
"@trackConvertFormat": {
|
||||||
"description": "Menu item - convert audio format"
|
"description": "Menu item - convert audio format"
|
||||||
},
|
},
|
||||||
"trackConvertFormatSubtitle": "Konversi ke MP3, Opus, ALAC, atau FLAC",
|
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
|
||||||
"@trackConvertFormatSubtitle": {
|
"@trackConvertFormatSubtitle": {
|
||||||
"description": "Subtitle for convert format menu item"
|
"description": "Subtitle for convert format menu item"
|
||||||
},
|
},
|
||||||
@@ -2860,22 +2803,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"trackConvertConfirmMessageLossless": "Konversi dari {sourceFormat} ke {targetFormat}? (Lossless — tanpa kehilangan kualitas)\n\nFile asli akan dihapus setelah konversi.",
|
|
||||||
"@trackConvertConfirmMessageLossless": {
|
|
||||||
"description": "Confirmation dialog message for lossless-to-lossless conversion",
|
|
||||||
"placeholders": {
|
|
||||||
"sourceFormat": {
|
|
||||||
"type": "String"
|
|
||||||
},
|
|
||||||
"targetFormat": {
|
|
||||||
"type": "String"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"trackConvertLosslessHint": "Konversi lossless — tanpa kehilangan kualitas",
|
|
||||||
"@trackConvertLosslessHint": {
|
|
||||||
"description": "Hint shown when converting between lossless formats"
|
|
||||||
},
|
|
||||||
"trackConvertConverting": "Converting audio...",
|
"trackConvertConverting": "Converting audio...",
|
||||||
"@trackConvertConverting": {
|
"@trackConvertConverting": {
|
||||||
"description": "Snackbar while converting"
|
"description": "Snackbar while converting"
|
||||||
@@ -2893,90 +2820,6 @@
|
|||||||
"@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"
|
||||||
@@ -3271,4 +3114,4 @@
|
|||||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||||
"description": "Subtitle when Track Artist is used for folder naming"
|
"description": "Subtitle when Track Artist is used for folder naming"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+24
-116
@@ -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 and Qobuz.",
|
"aboutAppDescription": "Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -897,18 +897,6 @@
|
|||||||
"@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,7 +991,7 @@
|
|||||||
"@filenameFormat": {
|
"@filenameFormat": {
|
||||||
"description": "Setting title - filename pattern"
|
"description": "Setting title - filename pattern"
|
||||||
},
|
},
|
||||||
"filenameShowAdvancedTags": "高度なタグを表示",
|
"filenameShowAdvancedTags": "Show advanced tags",
|
||||||
"@filenameShowAdvancedTags": {
|
"@filenameShowAdvancedTags": {
|
||||||
"description": "Toggle label for showing advanced filename tags"
|
"description": "Toggle label for showing advanced filename tags"
|
||||||
},
|
},
|
||||||
@@ -1015,14 +1003,6 @@
|
|||||||
"@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"
|
||||||
@@ -1117,7 +1097,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "内蔵",
|
"providerBuiltIn": "内蔵",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
||||||
},
|
},
|
||||||
"providerExtension": "拡張",
|
"providerExtension": "拡張",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1491,7 +1471,7 @@
|
|||||||
"@trackLyricsEmbedded": {
|
"@trackLyricsEmbedded": {
|
||||||
"description": "Snackbar - lyrics saved to file"
|
"description": "Snackbar - lyrics saved to file"
|
||||||
},
|
},
|
||||||
"trackInstrumental": "インストゥルメンタルのトラック",
|
"trackInstrumental": "Instrumental track",
|
||||||
"@trackInstrumental": {
|
"@trackInstrumental": {
|
||||||
"description": "Message when track is instrumental (no lyrics)"
|
"description": "Message when track is instrumental (no lyrics)"
|
||||||
},
|
},
|
||||||
@@ -1773,6 +1753,18 @@
|
|||||||
"@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"
|
||||||
@@ -2391,7 +2383,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"description": "Tutorial welcome tip 1"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
|
||||||
"@tutorialWelcomeTip2": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2816,90 +2808,6 @@
|
|||||||
"@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"
|
||||||
@@ -3032,7 +2940,7 @@
|
|||||||
"@collectionRemoveFromPlaylist": {
|
"@collectionRemoveFromPlaylist": {
|
||||||
"description": "Tooltip for removing track from playlist"
|
"description": "Tooltip for removing track from playlist"
|
||||||
},
|
},
|
||||||
"collectionRemoveFromFolder": "フォルダから削除",
|
"collectionRemoveFromFolder": "Remove from folder",
|
||||||
"@collectionRemoveFromFolder": {
|
"@collectionRemoveFromFolder": {
|
||||||
"description": "Tooltip for removing track from wishlist/loved folder"
|
"description": "Tooltip for removing track from wishlist/loved folder"
|
||||||
},
|
},
|
||||||
@@ -3089,23 +2997,23 @@
|
|||||||
"@trackOptionRemoveFromLoved": {
|
"@trackOptionRemoveFromLoved": {
|
||||||
"description": "Bottom sheet action label - remove track from loved folder"
|
"description": "Bottom sheet action label - remove track from loved folder"
|
||||||
},
|
},
|
||||||
"trackOptionAddToWishlist": "ウィッシュリストに追加",
|
"trackOptionAddToWishlist": "Add to Wishlist",
|
||||||
"@trackOptionAddToWishlist": {
|
"@trackOptionAddToWishlist": {
|
||||||
"description": "Bottom sheet action label - add track to wishlist"
|
"description": "Bottom sheet action label - add track to wishlist"
|
||||||
},
|
},
|
||||||
"trackOptionRemoveFromWishlist": "ウィッシュから削除",
|
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
|
||||||
"@trackOptionRemoveFromWishlist": {
|
"@trackOptionRemoveFromWishlist": {
|
||||||
"description": "Bottom sheet action label - remove track from wishlist"
|
"description": "Bottom sheet action label - remove track from wishlist"
|
||||||
},
|
},
|
||||||
"collectionPlaylistChangeCover": "カバー画像を変更",
|
"collectionPlaylistChangeCover": "Change cover image",
|
||||||
"@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": "カバー画像を削除",
|
"collectionPlaylistRemoveCover": "Remove cover image",
|
||||||
"@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": "{count} {count, plural, =1{個のトラック} other{個のトラック}}を共有",
|
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
|
||||||
"@selectionShareCount": {
|
"@selectionShareCount": {
|
||||||
"description": "Share button text with count in selection mode",
|
"description": "Share button text with count in selection mode",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -3131,7 +3039,7 @@
|
|||||||
"@selectionConvertNoConvertible": {
|
"@selectionConvertNoConvertible": {
|
||||||
"description": "Snackbar when no selected tracks support conversion"
|
"description": "Snackbar when no selected tracks support conversion"
|
||||||
},
|
},
|
||||||
"selectionBatchConvertConfirmTitle": "一括変換",
|
"selectionBatchConvertConfirmTitle": "Batch Convert",
|
||||||
"@selectionBatchConvertConfirmTitle": {
|
"@selectionBatchConvertConfirmTitle": {
|
||||||
"description": "Confirmation dialog title for batch conversion"
|
"description": "Confirmation dialog title for batch conversion"
|
||||||
},
|
},
|
||||||
|
|||||||
+15
-107
@@ -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 and Qobuz.",
|
"aboutAppDescription": "Tidal, Qobuz, Amazon Music에서 Spotify 트랙을 무손실 음질로 다운로드하세요.",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -897,18 +897,6 @@
|
|||||||
"@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",
|
||||||
@@ -1015,14 +1003,6 @@
|
|||||||
"@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"
|
||||||
@@ -1117,7 +1097,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "Built-in",
|
"providerBuiltIn": "Built-in",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Extension",
|
"providerExtension": "Extension",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1773,6 +1753,18 @@
|
|||||||
"@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"
|
||||||
@@ -2391,7 +2383,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"description": "Tutorial welcome tip 1"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
|
||||||
"@tutorialWelcomeTip2": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2816,90 +2808,6 @@
|
|||||||
"@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"
|
||||||
|
|||||||
+19
-111
@@ -194,11 +194,11 @@
|
|||||||
"@optionsConcurrentDownloads": {
|
"@optionsConcurrentDownloads": {
|
||||||
"description": "Number of parallel downloads"
|
"description": "Number of parallel downloads"
|
||||||
},
|
},
|
||||||
"optionsConcurrentSequential": "Sequentiële (1 per keer)",
|
"optionsConcurrentSequential": "Sequential (1 at a time)",
|
||||||
"@optionsConcurrentSequential": {
|
"@optionsConcurrentSequential": {
|
||||||
"description": "Download one at a time"
|
"description": "Download one at a time"
|
||||||
},
|
},
|
||||||
"optionsConcurrentParallel": "",
|
"optionsConcurrentParallel": "{count} parallel downloads",
|
||||||
"@optionsConcurrentParallel": {
|
"@optionsConcurrentParallel": {
|
||||||
"description": "Multiple parallel downloads",
|
"description": "Multiple parallel downloads",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -207,7 +207,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"optionsConcurrentWarning": "Parallel downloaden kan leiden tot rate-limiting",
|
"optionsConcurrentWarning": "Parallel downloads may trigger 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": "",
|
"aboutMobileDeveloper": "Mobile version developer",
|
||||||
"@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 and Qobuz.",
|
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -897,18 +897,6 @@
|
|||||||
"@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",
|
||||||
@@ -1015,14 +1003,6 @@
|
|||||||
"@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"
|
||||||
@@ -1117,7 +1097,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "Built-in",
|
"providerBuiltIn": "Built-in",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Extension",
|
"providerExtension": "Extension",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1773,6 +1753,18 @@
|
|||||||
"@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"
|
||||||
@@ -2391,7 +2383,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"description": "Tutorial welcome tip 1"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
|
||||||
"@tutorialWelcomeTip2": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2816,90 +2808,6 @@
|
|||||||
"@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"
|
||||||
|
|||||||
+7
-401
@@ -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 and Qobuz.",
|
"aboutAppDescription": "Baixe faixas do Spotify em qualidade sem perdas do Tidal e Qobuz.",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -897,18 +897,6 @@
|
|||||||
"@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",
|
||||||
@@ -1003,26 +991,10 @@
|
|||||||
"@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"
|
||||||
@@ -1773,6 +1745,10 @@
|
|||||||
"@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"
|
||||||
@@ -2222,15 +2198,6 @@
|
|||||||
"@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",
|
||||||
@@ -2391,7 +2358,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"description": "Tutorial welcome tip 1"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
"tutorialWelcomeTip2": "Obtenha áudio em qualidade FLAC do Tidal, Qobuz ou Deezer",
|
||||||
"@tutorialWelcomeTip2": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2816,367 +2783,6 @@
|
|||||||
"@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",
|
||||||
@@ -3194,4 +2800,4 @@
|
|||||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||||
"description": "Subtitle when Track Artist is used for folder naming"
|
"description": "Subtitle when Track Artist is used for folder naming"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-114
@@ -450,7 +450,7 @@
|
|||||||
"@aboutSpotiSaverDesc": {
|
"@aboutSpotiSaverDesc": {
|
||||||
"description": "Credit for SpotiSaver API"
|
"description": "Credit for SpotiSaver API"
|
||||||
},
|
},
|
||||||
"aboutAppDescription": "Скачивайте треки Spotify в lossless качестве с Tidal и Qobuz.",
|
"aboutAppDescription": "Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -897,18 +897,6 @@
|
|||||||
"@errorNoTracksFound": {
|
"@errorNoTracksFound": {
|
||||||
"description": "Error - search returned no results"
|
"description": "Error - search returned no results"
|
||||||
},
|
},
|
||||||
"errorUrlNotRecognized": "Ссылка не распознана",
|
|
||||||
"@errorUrlNotRecognized": {
|
|
||||||
"description": "Error title - URL not handled by any extension or service"
|
|
||||||
},
|
|
||||||
"errorUrlNotRecognizedMessage": "Эта ссылка не поддерживается. Убедитесь, что URL-адрес указан правильно и установлено совместимое расширение.",
|
|
||||||
"@errorUrlNotRecognizedMessage": {
|
|
||||||
"description": "Error message - URL not recognized explanation"
|
|
||||||
},
|
|
||||||
"errorUrlFetchFailed": "Не удалось загрузить контент по этой ссылке. Пожалуйста, попробуйте еще раз.",
|
|
||||||
"@errorUrlFetchFailed": {
|
|
||||||
"description": "Error message - generic URL fetch failure"
|
|
||||||
},
|
|
||||||
"errorMissingExtensionSource": "Невозможно загрузить {item}: отсутствует источник расширения",
|
"errorMissingExtensionSource": "Невозможно загрузить {item}: отсутствует источник расширения",
|
||||||
"@errorMissingExtensionSource": {
|
"@errorMissingExtensionSource": {
|
||||||
"description": "Error - extension source not available",
|
"description": "Error - extension source not available",
|
||||||
@@ -1015,14 +1003,6 @@
|
|||||||
"@folderOrganizationNone": {
|
"@folderOrganizationNone": {
|
||||||
"description": "Folder option - flat structure"
|
"description": "Folder option - flat structure"
|
||||||
},
|
},
|
||||||
"folderOrganizationByPlaylist": "По плейлисту",
|
|
||||||
"@folderOrganizationByPlaylist": {
|
|
||||||
"description": "Folder option - playlist folders"
|
|
||||||
},
|
|
||||||
"folderOrganizationByPlaylistSubtitle": "Отдельная папка для каждого плейлиста",
|
|
||||||
"@folderOrganizationByPlaylistSubtitle": {
|
|
||||||
"description": "Subtitle for playlist folder option"
|
|
||||||
},
|
|
||||||
"folderOrganizationByArtist": "По исполнителю",
|
"folderOrganizationByArtist": "По исполнителю",
|
||||||
"@folderOrganizationByArtist": {
|
"@folderOrganizationByArtist": {
|
||||||
"description": "Folder option - artist folders"
|
"description": "Folder option - artist folders"
|
||||||
@@ -1117,7 +1097,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "Встроенные",
|
"providerBuiltIn": "Встроенные",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Расширение",
|
"providerExtension": "Расширение",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1773,6 +1753,18 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
|
"youtubeQualityNote": "YouTube обеспечивает только звук с потерями(Lossy).",
|
||||||
|
"@youtubeQualityNote": {
|
||||||
|
"description": "Note for YouTube service explaining lossy-only quality"
|
||||||
|
},
|
||||||
|
"youtubeOpusBitrateTitle": "Битрейт YouTube Opus",
|
||||||
|
"@youtubeOpusBitrateTitle": {
|
||||||
|
"description": "Title for YouTube Opus bitrate setting"
|
||||||
|
},
|
||||||
|
"youtubeMp3BitrateTitle": "Битрейт YouTube MP3",
|
||||||
|
"@youtubeMp3BitrateTitle": {
|
||||||
|
"description": "Title for YouTube MP3 bitrate setting"
|
||||||
|
},
|
||||||
"downloadAskBeforeDownload": "Спрашивать перед скачиванием",
|
"downloadAskBeforeDownload": "Спрашивать перед скачиванием",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -1797,7 +1789,7 @@
|
|||||||
"@downloadUsePrimaryArtistOnly": {
|
"@downloadUsePrimaryArtistOnly": {
|
||||||
"description": "Setting - strip featured artists from folder name"
|
"description": "Setting - strip featured artists from folder name"
|
||||||
},
|
},
|
||||||
"downloadUsePrimaryArtistOnlyEnabled": "Список исполнителей, чьи работы были удалены из названия папки (например, Джастин Бибер, Quavo → Джастин Бибер)",
|
"downloadUsePrimaryArtistOnlyEnabled": "Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)",
|
||||||
"@downloadUsePrimaryArtistOnlyEnabled": {
|
"@downloadUsePrimaryArtistOnlyEnabled": {
|
||||||
"description": "Subtitle when primary artist only is enabled"
|
"description": "Subtitle when primary artist only is enabled"
|
||||||
},
|
},
|
||||||
@@ -2391,7 +2383,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"description": "Tutorial welcome tip 1"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip2": "Получите аудио в качестве FLAC от Tidal, Qobuz или Deezer",
|
"tutorialWelcomeTip2": "Скачайте FLAC с Tidal, Qobuz или Amazon Music",
|
||||||
"@tutorialWelcomeTip2": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2495,7 +2487,7 @@
|
|||||||
"@cleanupOrphanedDownloadsSubtitle": {
|
"@cleanupOrphanedDownloadsSubtitle": {
|
||||||
"description": "Subtitle for orphaned cleanup button"
|
"description": "Subtitle for orphaned cleanup button"
|
||||||
},
|
},
|
||||||
"cleanupOrphanedDownloadsResult": "Удалено {count} утерянных записей из истории",
|
"cleanupOrphanedDownloadsResult": "Removed {count} orphaned entries from history",
|
||||||
"@cleanupOrphanedDownloadsResult": {
|
"@cleanupOrphanedDownloadsResult": {
|
||||||
"description": "Snackbar after orphan cleanup",
|
"description": "Snackbar after orphan cleanup",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2533,7 +2525,7 @@
|
|||||||
"@cacheSectionStorage": {
|
"@cacheSectionStorage": {
|
||||||
"description": "Section header for cache entries"
|
"description": "Section header for cache entries"
|
||||||
},
|
},
|
||||||
"cacheSectionMaintenance": "Обслуживание",
|
"cacheSectionMaintenance": "Maintenance",
|
||||||
"@cacheSectionMaintenance": {
|
"@cacheSectionMaintenance": {
|
||||||
"description": "Section header for cleanup actions"
|
"description": "Section header for cleanup actions"
|
||||||
},
|
},
|
||||||
@@ -2585,7 +2577,7 @@
|
|||||||
"@cacheTrackLookupDesc": {
|
"@cacheTrackLookupDesc": {
|
||||||
"description": "Description of what track lookup cache contains"
|
"description": "Description of what track lookup cache contains"
|
||||||
},
|
},
|
||||||
"cacheCleanupUnusedDesc": "Удалить записи из истории загрузок и библиотеки, которые остались без файлов.",
|
"cacheCleanupUnusedDesc": "Remove orphaned download history and library entries for missing files.",
|
||||||
"@cacheCleanupUnusedDesc": {
|
"@cacheCleanupUnusedDesc": {
|
||||||
"description": "Description of what cleanup unused data does"
|
"description": "Description of what cleanup unused data does"
|
||||||
},
|
},
|
||||||
@@ -2661,7 +2653,7 @@
|
|||||||
"@cacheCleanupUnused": {
|
"@cacheCleanupUnused": {
|
||||||
"description": "Action title for cleaning unused entries"
|
"description": "Action title for cleaning unused entries"
|
||||||
},
|
},
|
||||||
"cacheCleanupUnusedSubtitle": "Удалить историю загрузок, оставшихся без просмотра, и отсутствующие записи в библиотеке",
|
"cacheCleanupUnusedSubtitle": "Remove orphaned download history and missing library entries",
|
||||||
"@cacheCleanupUnusedSubtitle": {
|
"@cacheCleanupUnusedSubtitle": {
|
||||||
"description": "Subtitle for cleanup unused data action"
|
"description": "Subtitle for cleanup unused data action"
|
||||||
},
|
},
|
||||||
@@ -2816,90 +2808,6 @@
|
|||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"description": "Snackbar when conversion fails"
|
||||||
},
|
},
|
||||||
"cueSplitTitle": "Разделить CUE Sheet",
|
|
||||||
"@cueSplitTitle": {
|
|
||||||
"description": "Title for CUE split bottom sheet"
|
|
||||||
},
|
|
||||||
"cueSplitSubtitle": "Разделить файл CUE+FLAC на отдельные треки",
|
|
||||||
"@cueSplitSubtitle": {
|
|
||||||
"description": "Subtitle for CUE split menu item"
|
|
||||||
},
|
|
||||||
"cueSplitAlbum": "Альбом: {album}",
|
|
||||||
"@cueSplitAlbum": {
|
|
||||||
"description": "Album name in CUE split sheet",
|
|
||||||
"placeholders": {
|
|
||||||
"album": {
|
|
||||||
"type": "String"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"cueSplitArtist": "Артист: {artist}",
|
|
||||||
"@cueSplitArtist": {
|
|
||||||
"description": "Artist name in CUE split sheet",
|
|
||||||
"placeholders": {
|
|
||||||
"artist": {
|
|
||||||
"type": "String"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"cueSplitTrackCount": "{count} треков",
|
|
||||||
"@cueSplitTrackCount": {
|
|
||||||
"description": "Number of tracks in CUE sheet",
|
|
||||||
"placeholders": {
|
|
||||||
"count": {
|
|
||||||
"type": "int"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"cueSplitConfirmTitle": "Разделенный CUE-альбом",
|
|
||||||
"@cueSplitConfirmTitle": {
|
|
||||||
"description": "CUE split confirmation dialog title"
|
|
||||||
},
|
|
||||||
"cueSplitConfirmMessage": "Разбить \"{album}\" на {count} отдельных FLAC-файлов?",
|
|
||||||
"@cueSplitConfirmMessage": {
|
|
||||||
"description": "CUE split confirmation dialog message",
|
|
||||||
"placeholders": {
|
|
||||||
"album": {
|
|
||||||
"type": "String"
|
|
||||||
},
|
|
||||||
"count": {
|
|
||||||
"type": "int"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"cueSplitSplitting": "Разделение CUE sheet... ({current}/{total})",
|
|
||||||
"@cueSplitSplitting": {
|
|
||||||
"description": "Snackbar while splitting CUE",
|
|
||||||
"placeholders": {
|
|
||||||
"current": {
|
|
||||||
"type": "int"
|
|
||||||
},
|
|
||||||
"total": {
|
|
||||||
"type": "int"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"cueSplitSuccess": "Успешно разделено на {count} треков",
|
|
||||||
"@cueSplitSuccess": {
|
|
||||||
"description": "Snackbar after successful CUE split",
|
|
||||||
"placeholders": {
|
|
||||||
"count": {
|
|
||||||
"type": "int"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"cueSplitFailed": "Разделение CUE не удалось",
|
|
||||||
"@cueSplitFailed": {
|
|
||||||
"description": "Snackbar when CUE split fails"
|
|
||||||
},
|
|
||||||
"cueSplitNoAudioFile": "Аудиофайл для этого CUE sheet не найден",
|
|
||||||
"@cueSplitNoAudioFile": {
|
|
||||||
"description": "Error when CUE audio file is missing"
|
|
||||||
},
|
|
||||||
"cueSplitButton": "Разделить на Треки",
|
|
||||||
"@cueSplitButton": {
|
|
||||||
"description": "Button text to start CUE splitting"
|
|
||||||
},
|
|
||||||
"actionCreate": "Создать",
|
"actionCreate": "Создать",
|
||||||
"@actionCreate": {
|
"@actionCreate": {
|
||||||
"description": "Generic action button - create"
|
"description": "Generic action button - create"
|
||||||
@@ -3114,7 +3022,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"selectionShareNoFiles": "Файлы, доступные для совместного доступа, не найдены",
|
"selectionShareNoFiles": "No shareable files found",
|
||||||
"@selectionShareNoFiles": {
|
"@selectionShareNoFiles": {
|
||||||
"description": "Snackbar when no selected files exist on disk"
|
"description": "Snackbar when no selected files exist on disk"
|
||||||
},
|
},
|
||||||
@@ -3135,7 +3043,7 @@
|
|||||||
"@selectionBatchConvertConfirmTitle": {
|
"@selectionBatchConvertConfirmTitle": {
|
||||||
"description": "Confirmation dialog title for batch conversion"
|
"description": "Confirmation dialog title for batch conversion"
|
||||||
},
|
},
|
||||||
"selectionBatchConvertConfirmMessage": "Преобразовать {count} {count, plural, =1{track} other{tracks}} в {format} с {bitrate}?",
|
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
|
||||||
"@selectionBatchConvertConfirmMessage": {
|
"@selectionBatchConvertConfirmMessage": {
|
||||||
"description": "Confirmation dialog message for batch conversion",
|
"description": "Confirmation dialog message for batch conversion",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
+142
-536
File diff suppressed because it is too large
Load Diff
+111
-203
@@ -5,143 +5,143 @@
|
|||||||
"@appName": {
|
"@appName": {
|
||||||
"description": "App name - DO NOT TRANSLATE"
|
"description": "App name - DO NOT TRANSLATE"
|
||||||
},
|
},
|
||||||
"navHome": "主页",
|
"navHome": "Home",
|
||||||
"@navHome": {
|
"@navHome": {
|
||||||
"description": "Bottom navigation - Home tab"
|
"description": "Bottom navigation - Home tab"
|
||||||
},
|
},
|
||||||
"navLibrary": "乐库",
|
"navLibrary": "Library",
|
||||||
"@navLibrary": {
|
"@navLibrary": {
|
||||||
"description": "Bottom navigation - Library tab"
|
"description": "Bottom navigation - Library tab"
|
||||||
},
|
},
|
||||||
"navSettings": "设置",
|
"navSettings": "Settings",
|
||||||
"@navSettings": {
|
"@navSettings": {
|
||||||
"description": "Bottom navigation - Settings tab"
|
"description": "Bottom navigation - Settings tab"
|
||||||
},
|
},
|
||||||
"navStore": "商店",
|
"navStore": "Store",
|
||||||
"@navStore": {
|
"@navStore": {
|
||||||
"description": "Bottom navigation - Extension store tab"
|
"description": "Bottom navigation - Extension store tab"
|
||||||
},
|
},
|
||||||
"homeTitle": "主页",
|
"homeTitle": "Home",
|
||||||
"@homeTitle": {
|
"@homeTitle": {
|
||||||
"description": "Home screen title"
|
"description": "Home screen title"
|
||||||
},
|
},
|
||||||
"homeSubtitle": "粘贴 Spotify 链接或按名称搜索",
|
"homeSubtitle": "Paste a Spotify link or search by name",
|
||||||
"@homeSubtitle": {
|
"@homeSubtitle": {
|
||||||
"description": "Subtitle shown below search box"
|
"description": "Subtitle shown below search box"
|
||||||
},
|
},
|
||||||
"homeSupports": "支持:歌曲、专辑、播放列表、艺人网址",
|
"homeSupports": "Supports: Track, Album, Playlist, Artist URLs",
|
||||||
"@homeSupports": {
|
"@homeSupports": {
|
||||||
"description": "Info text about supported URL types"
|
"description": "Info text about supported URL types"
|
||||||
},
|
},
|
||||||
"homeRecent": "最近",
|
"homeRecent": "Recent",
|
||||||
"@homeRecent": {
|
"@homeRecent": {
|
||||||
"description": "Section header for recent searches"
|
"description": "Section header for recent searches"
|
||||||
},
|
},
|
||||||
"historyFilterAll": "全部",
|
"historyFilterAll": "All",
|
||||||
"@historyFilterAll": {
|
"@historyFilterAll": {
|
||||||
"description": "Filter chip - show all items"
|
"description": "Filter chip - show all items"
|
||||||
},
|
},
|
||||||
"historyFilterAlbums": "专辑",
|
"historyFilterAlbums": "Albums",
|
||||||
"@historyFilterAlbums": {
|
"@historyFilterAlbums": {
|
||||||
"description": "Filter chip - show albums only"
|
"description": "Filter chip - show albums only"
|
||||||
},
|
},
|
||||||
"historyFilterSingles": "单曲",
|
"historyFilterSingles": "Singles",
|
||||||
"@historyFilterSingles": {
|
"@historyFilterSingles": {
|
||||||
"description": "Filter chip - show singles only"
|
"description": "Filter chip - show singles only"
|
||||||
},
|
},
|
||||||
"historySearchHint": "搜索历史……",
|
"historySearchHint": "Search history...",
|
||||||
"@historySearchHint": {
|
"@historySearchHint": {
|
||||||
"description": "Search bar placeholder in history"
|
"description": "Search bar placeholder in history"
|
||||||
},
|
},
|
||||||
"settingsTitle": "设置",
|
"settingsTitle": "Settings",
|
||||||
"@settingsTitle": {
|
"@settingsTitle": {
|
||||||
"description": "Settings screen title"
|
"description": "Settings screen title"
|
||||||
},
|
},
|
||||||
"settingsDownload": "下载",
|
"settingsDownload": "Download",
|
||||||
"@settingsDownload": {
|
"@settingsDownload": {
|
||||||
"description": "Settings section - download options"
|
"description": "Settings section - download options"
|
||||||
},
|
},
|
||||||
"settingsAppearance": "外观",
|
"settingsAppearance": "Appearance",
|
||||||
"@settingsAppearance": {
|
"@settingsAppearance": {
|
||||||
"description": "Settings section - visual customization"
|
"description": "Settings section - visual customization"
|
||||||
},
|
},
|
||||||
"settingsOptions": "选项",
|
"settingsOptions": "Options",
|
||||||
"@settingsOptions": {
|
"@settingsOptions": {
|
||||||
"description": "Settings section - app options"
|
"description": "Settings section - app options"
|
||||||
},
|
},
|
||||||
"settingsExtensions": "扩展",
|
"settingsExtensions": "Extensions",
|
||||||
"@settingsExtensions": {
|
"@settingsExtensions": {
|
||||||
"description": "Settings section - extension management"
|
"description": "Settings section - extension management"
|
||||||
},
|
},
|
||||||
"settingsAbout": "关于",
|
"settingsAbout": "About",
|
||||||
"@settingsAbout": {
|
"@settingsAbout": {
|
||||||
"description": "Settings section - app info"
|
"description": "Settings section - app info"
|
||||||
},
|
},
|
||||||
"downloadTitle": "下载",
|
"downloadTitle": "Download",
|
||||||
"@downloadTitle": {
|
"@downloadTitle": {
|
||||||
"description": "Download settings page title"
|
"description": "Download settings page title"
|
||||||
},
|
},
|
||||||
"downloadAskQualitySubtitle": "为每次下载显示质量选择器",
|
"downloadAskQualitySubtitle": "Show quality picker for each download",
|
||||||
"@downloadAskQualitySubtitle": {
|
"@downloadAskQualitySubtitle": {
|
||||||
"description": "Subtitle for ask quality toggle"
|
"description": "Subtitle for ask quality toggle"
|
||||||
},
|
},
|
||||||
"downloadFilenameFormat": "文件名格式",
|
"downloadFilenameFormat": "Filename Format",
|
||||||
"@downloadFilenameFormat": {
|
"@downloadFilenameFormat": {
|
||||||
"description": "Setting for output filename pattern"
|
"description": "Setting for output filename pattern"
|
||||||
},
|
},
|
||||||
"downloadFolderOrganization": "文件夹结构",
|
"downloadFolderOrganization": "Folder Organization",
|
||||||
"@downloadFolderOrganization": {
|
"@downloadFolderOrganization": {
|
||||||
"description": "Setting for folder structure"
|
"description": "Setting for folder structure"
|
||||||
},
|
},
|
||||||
"appearanceTitle": "外观",
|
"appearanceTitle": "Appearance",
|
||||||
"@appearanceTitle": {
|
"@appearanceTitle": {
|
||||||
"description": "Appearance settings page title"
|
"description": "Appearance settings page title"
|
||||||
},
|
},
|
||||||
"appearanceThemeSystem": "系统",
|
"appearanceThemeSystem": "System",
|
||||||
"@appearanceThemeSystem": {
|
"@appearanceThemeSystem": {
|
||||||
"description": "Follow system theme"
|
"description": "Follow system theme"
|
||||||
},
|
},
|
||||||
"appearanceThemeLight": "浅色",
|
"appearanceThemeLight": "Light",
|
||||||
"@appearanceThemeLight": {
|
"@appearanceThemeLight": {
|
||||||
"description": "Light theme"
|
"description": "Light theme"
|
||||||
},
|
},
|
||||||
"appearanceThemeDark": "深色",
|
"appearanceThemeDark": "Dark",
|
||||||
"@appearanceThemeDark": {
|
"@appearanceThemeDark": {
|
||||||
"description": "Dark theme"
|
"description": "Dark theme"
|
||||||
},
|
},
|
||||||
"appearanceDynamicColor": "动态色彩",
|
"appearanceDynamicColor": "Dynamic Color",
|
||||||
"@appearanceDynamicColor": {
|
"@appearanceDynamicColor": {
|
||||||
"description": "Material You dynamic colors"
|
"description": "Material You dynamic colors"
|
||||||
},
|
},
|
||||||
"appearanceDynamicColorSubtitle": "使用壁纸的颜色",
|
"appearanceDynamicColorSubtitle": "Use colors from your wallpaper",
|
||||||
"@appearanceDynamicColorSubtitle": {
|
"@appearanceDynamicColorSubtitle": {
|
||||||
"description": "Subtitle for dynamic color"
|
"description": "Subtitle for dynamic color"
|
||||||
},
|
},
|
||||||
"appearanceHistoryView": "历史记录",
|
"appearanceHistoryView": "History View",
|
||||||
"@appearanceHistoryView": {
|
"@appearanceHistoryView": {
|
||||||
"description": "Layout style for history"
|
"description": "Layout style for history"
|
||||||
},
|
},
|
||||||
"appearanceHistoryViewList": "列表",
|
"appearanceHistoryViewList": "List",
|
||||||
"@appearanceHistoryViewList": {
|
"@appearanceHistoryViewList": {
|
||||||
"description": "List layout option"
|
"description": "List layout option"
|
||||||
},
|
},
|
||||||
"appearanceHistoryViewGrid": "网格",
|
"appearanceHistoryViewGrid": "Grid",
|
||||||
"@appearanceHistoryViewGrid": {
|
"@appearanceHistoryViewGrid": {
|
||||||
"description": "Grid layout option"
|
"description": "Grid layout option"
|
||||||
},
|
},
|
||||||
"optionsTitle": "选项",
|
"optionsTitle": "Options",
|
||||||
"@optionsTitle": {
|
"@optionsTitle": {
|
||||||
"description": "Options settings page title"
|
"description": "Options settings page title"
|
||||||
},
|
},
|
||||||
"optionsPrimaryProvider": "主要提供者",
|
"optionsPrimaryProvider": "Primary Provider",
|
||||||
"@optionsPrimaryProvider": {
|
"@optionsPrimaryProvider": {
|
||||||
"description": "Main search provider setting"
|
"description": "Main search provider setting"
|
||||||
},
|
},
|
||||||
"optionsPrimaryProviderSubtitle": "按歌曲名称搜索时使用的服务。",
|
"optionsPrimaryProviderSubtitle": "Service used when searching by track name.",
|
||||||
"@optionsPrimaryProviderSubtitle": {
|
"@optionsPrimaryProviderSubtitle": {
|
||||||
"description": "Subtitle for primary provider"
|
"description": "Subtitle for primary provider"
|
||||||
},
|
},
|
||||||
"optionsUsingExtension": "使用扩展:{extensionName}",
|
"optionsUsingExtension": "Using extension: {extensionName}",
|
||||||
"@optionsUsingExtension": {
|
"@optionsUsingExtension": {
|
||||||
"description": "Shows active extension name",
|
"description": "Shows active extension name",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -150,55 +150,55 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"optionsSwitchBack": "点击 Deezer 或 Spotify 即可从扩展程序切换回来",
|
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
|
||||||
"@optionsSwitchBack": {
|
"@optionsSwitchBack": {
|
||||||
"description": "Hint to switch back to built-in providers"
|
"description": "Hint to switch back to built-in providers"
|
||||||
},
|
},
|
||||||
"optionsAutoFallback": "自动回退",
|
"optionsAutoFallback": "Auto Fallback",
|
||||||
"@optionsAutoFallback": {
|
"@optionsAutoFallback": {
|
||||||
"description": "Auto-retry with other services"
|
"description": "Auto-retry with other services"
|
||||||
},
|
},
|
||||||
"optionsAutoFallbackSubtitle": "如果下载失败,请尝试其他服务",
|
"optionsAutoFallbackSubtitle": "Try other services if download fails",
|
||||||
"@optionsAutoFallbackSubtitle": {
|
"@optionsAutoFallbackSubtitle": {
|
||||||
"description": "Subtitle for auto fallback"
|
"description": "Subtitle for auto fallback"
|
||||||
},
|
},
|
||||||
"optionsUseExtensionProviders": "使用扩展提供商",
|
"optionsUseExtensionProviders": "Use Extension Providers",
|
||||||
"@optionsUseExtensionProviders": {
|
"@optionsUseExtensionProviders": {
|
||||||
"description": "Enable extension download providers"
|
"description": "Enable extension download providers"
|
||||||
},
|
},
|
||||||
"optionsUseExtensionProvidersOn": "扩展会被最先尝试",
|
"optionsUseExtensionProvidersOn": "Extensions will be tried first",
|
||||||
"@optionsUseExtensionProvidersOn": {
|
"@optionsUseExtensionProvidersOn": {
|
||||||
"description": "Status when extension providers enabled"
|
"description": "Status when extension providers enabled"
|
||||||
},
|
},
|
||||||
"optionsUseExtensionProvidersOff": "仅使用内置提供商",
|
"optionsUseExtensionProvidersOff": "Using built-in providers only",
|
||||||
"@optionsUseExtensionProvidersOff": {
|
"@optionsUseExtensionProvidersOff": {
|
||||||
"description": "Status when extension providers disabled"
|
"description": "Status when extension providers disabled"
|
||||||
},
|
},
|
||||||
"optionsEmbedLyrics": "内嵌歌词",
|
"optionsEmbedLyrics": "Embed Lyrics",
|
||||||
"@optionsEmbedLyrics": {
|
"@optionsEmbedLyrics": {
|
||||||
"description": "Embed lyrics in audio files"
|
"description": "Embed lyrics in audio files"
|
||||||
},
|
},
|
||||||
"optionsEmbedLyricsSubtitle": "嵌入已同步歌词到 FLAC 文件",
|
"optionsEmbedLyricsSubtitle": "Embed synced lyrics into FLAC files",
|
||||||
"@optionsEmbedLyricsSubtitle": {
|
"@optionsEmbedLyricsSubtitle": {
|
||||||
"description": "Subtitle for embed lyrics"
|
"description": "Subtitle for embed lyrics"
|
||||||
},
|
},
|
||||||
"optionsMaxQualityCover": "最高质量封面",
|
"optionsMaxQualityCover": "Max Quality Cover",
|
||||||
"@optionsMaxQualityCover": {
|
"@optionsMaxQualityCover": {
|
||||||
"description": "Download highest quality album art"
|
"description": "Download highest quality album art"
|
||||||
},
|
},
|
||||||
"optionsMaxQualityCoverSubtitle": "下载最高分辨率封面",
|
"optionsMaxQualityCoverSubtitle": "Download highest resolution cover art",
|
||||||
"@optionsMaxQualityCoverSubtitle": {
|
"@optionsMaxQualityCoverSubtitle": {
|
||||||
"description": "Subtitle for max quality cover"
|
"description": "Subtitle for max quality cover"
|
||||||
},
|
},
|
||||||
"optionsConcurrentDownloads": "并行下载数",
|
"optionsConcurrentDownloads": "Concurrent Downloads",
|
||||||
"@optionsConcurrentDownloads": {
|
"@optionsConcurrentDownloads": {
|
||||||
"description": "Number of parallel downloads"
|
"description": "Number of parallel downloads"
|
||||||
},
|
},
|
||||||
"optionsConcurrentSequential": "按顺序下载(一次一首)",
|
"optionsConcurrentSequential": "Sequential (1 at a time)",
|
||||||
"@optionsConcurrentSequential": {
|
"@optionsConcurrentSequential": {
|
||||||
"description": "Download one at a time"
|
"description": "Download one at a time"
|
||||||
},
|
},
|
||||||
"optionsConcurrentParallel": "同时下载 {count} 首",
|
"optionsConcurrentParallel": "{count} parallel downloads",
|
||||||
"@optionsConcurrentParallel": {
|
"@optionsConcurrentParallel": {
|
||||||
"description": "Multiple parallel downloads",
|
"description": "Multiple parallel downloads",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -207,67 +207,67 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"optionsConcurrentWarning": "并行下载可能会触发速率限制",
|
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
|
||||||
"@optionsConcurrentWarning": {
|
"@optionsConcurrentWarning": {
|
||||||
"description": "Warning about rate limits"
|
"description": "Warning about rate limits"
|
||||||
},
|
},
|
||||||
"optionsExtensionStore": "扩展商店",
|
"optionsExtensionStore": "Extension Store",
|
||||||
"@optionsExtensionStore": {
|
"@optionsExtensionStore": {
|
||||||
"description": "Show/hide store tab"
|
"description": "Show/hide store tab"
|
||||||
},
|
},
|
||||||
"optionsExtensionStoreSubtitle": "在导航中显示商店标签",
|
"optionsExtensionStoreSubtitle": "Show Store tab in navigation",
|
||||||
"@optionsExtensionStoreSubtitle": {
|
"@optionsExtensionStoreSubtitle": {
|
||||||
"description": "Subtitle for extension store toggle"
|
"description": "Subtitle for extension store toggle"
|
||||||
},
|
},
|
||||||
"optionsCheckUpdates": "检查更新",
|
"optionsCheckUpdates": "Check for Updates",
|
||||||
"@optionsCheckUpdates": {
|
"@optionsCheckUpdates": {
|
||||||
"description": "Auto update check toggle"
|
"description": "Auto update check toggle"
|
||||||
},
|
},
|
||||||
"optionsCheckUpdatesSubtitle": "当有新版本可用时通知",
|
"optionsCheckUpdatesSubtitle": "Notify when new version is available",
|
||||||
"@optionsCheckUpdatesSubtitle": {
|
"@optionsCheckUpdatesSubtitle": {
|
||||||
"description": "Subtitle for update check"
|
"description": "Subtitle for update check"
|
||||||
},
|
},
|
||||||
"optionsUpdateChannel": "更新频道",
|
"optionsUpdateChannel": "Update Channel",
|
||||||
"@optionsUpdateChannel": {
|
"@optionsUpdateChannel": {
|
||||||
"description": "Stable vs preview releases"
|
"description": "Stable vs preview releases"
|
||||||
},
|
},
|
||||||
"optionsUpdateChannelStable": "仅稳定版本",
|
"optionsUpdateChannelStable": "Stable releases only",
|
||||||
"@optionsUpdateChannelStable": {
|
"@optionsUpdateChannelStable": {
|
||||||
"description": "Only stable updates"
|
"description": "Only stable updates"
|
||||||
},
|
},
|
||||||
"optionsUpdateChannelPreview": "获取预览版本",
|
"optionsUpdateChannelPreview": "Get preview releases",
|
||||||
"@optionsUpdateChannelPreview": {
|
"@optionsUpdateChannelPreview": {
|
||||||
"description": "Include beta/preview updates"
|
"description": "Include beta/preview updates"
|
||||||
},
|
},
|
||||||
"optionsUpdateChannelWarning": "预览版本可能包含错误或者尚未完善的功能",
|
"optionsUpdateChannelWarning": "Preview may contain bugs or incomplete features",
|
||||||
"@optionsUpdateChannelWarning": {
|
"@optionsUpdateChannelWarning": {
|
||||||
"description": "Warning about preview channel"
|
"description": "Warning about preview channel"
|
||||||
},
|
},
|
||||||
"optionsClearHistory": "清除下载历史记录",
|
"optionsClearHistory": "Clear Download History",
|
||||||
"@optionsClearHistory": {
|
"@optionsClearHistory": {
|
||||||
"description": "Delete all download history"
|
"description": "Delete all download history"
|
||||||
},
|
},
|
||||||
"optionsClearHistorySubtitle": "从历史记录中清除所有已下载的曲目",
|
"optionsClearHistorySubtitle": "Remove all downloaded tracks from history",
|
||||||
"@optionsClearHistorySubtitle": {
|
"@optionsClearHistorySubtitle": {
|
||||||
"description": "Subtitle for clear history"
|
"description": "Subtitle for clear history"
|
||||||
},
|
},
|
||||||
"optionsDetailedLogging": "详细日志",
|
"optionsDetailedLogging": "Detailed Logging",
|
||||||
"@optionsDetailedLogging": {
|
"@optionsDetailedLogging": {
|
||||||
"description": "Enable verbose logs for debugging"
|
"description": "Enable verbose logs for debugging"
|
||||||
},
|
},
|
||||||
"optionsDetailedLoggingOn": "正在记录详细日志",
|
"optionsDetailedLoggingOn": "Detailed logs are being recorded",
|
||||||
"@optionsDetailedLoggingOn": {
|
"@optionsDetailedLoggingOn": {
|
||||||
"description": "Status when logging enabled"
|
"description": "Status when logging enabled"
|
||||||
},
|
},
|
||||||
"optionsDetailedLoggingOff": "为错误报告启用",
|
"optionsDetailedLoggingOff": "Enable for bug reports",
|
||||||
"@optionsDetailedLoggingOff": {
|
"@optionsDetailedLoggingOff": {
|
||||||
"description": "Status when logging disabled"
|
"description": "Status when logging disabled"
|
||||||
},
|
},
|
||||||
"optionsSpotifyCredentials": "Spotify 凭据",
|
"optionsSpotifyCredentials": "Spotify Credentials",
|
||||||
"@optionsSpotifyCredentials": {
|
"@optionsSpotifyCredentials": {
|
||||||
"description": "Spotify API credentials setting"
|
"description": "Spotify API credentials setting"
|
||||||
},
|
},
|
||||||
"optionsSpotifyCredentialsConfigured": "客户端 ID:{clientId}……",
|
"optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...",
|
||||||
"@optionsSpotifyCredentialsConfigured": {
|
"@optionsSpotifyCredentialsConfigured": {
|
||||||
"description": "Shows configured client ID preview",
|
"description": "Shows configured client ID preview",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -276,27 +276,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"optionsSpotifyCredentialsRequired": "必填 - 点击配置",
|
"optionsSpotifyCredentialsRequired": "Required - tap to configure",
|
||||||
"@optionsSpotifyCredentialsRequired": {
|
"@optionsSpotifyCredentialsRequired": {
|
||||||
"description": "Prompt to set up credentials"
|
"description": "Prompt to set up credentials"
|
||||||
},
|
},
|
||||||
"optionsSpotifyWarning": "Spotify 需要您自己的 API 凭据。在 developer.spotify.com 免费获取",
|
"optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com",
|
||||||
"@optionsSpotifyWarning": {
|
"@optionsSpotifyWarning": {
|
||||||
"description": "Info about Spotify API requirement"
|
"description": "Info about Spotify API requirement"
|
||||||
},
|
},
|
||||||
"optionsSpotifyDeprecationWarning": "Spotify 搜索将在 2026 年 3 月 3 日因 Spotify API 更改而被废弃。请切换到 Deezer。",
|
"optionsSpotifyDeprecationWarning": "Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.",
|
||||||
"@optionsSpotifyDeprecationWarning": {
|
"@optionsSpotifyDeprecationWarning": {
|
||||||
"description": "Warning about Spotify API deprecation"
|
"description": "Warning about Spotify API deprecation"
|
||||||
},
|
},
|
||||||
"extensionsTitle": "扩展",
|
"extensionsTitle": "Extensions",
|
||||||
"@extensionsTitle": {
|
"@extensionsTitle": {
|
||||||
"description": "Extensions page title"
|
"description": "Extensions page title"
|
||||||
},
|
},
|
||||||
"extensionsDisabled": "禁用",
|
"extensionsDisabled": "Disabled",
|
||||||
"@extensionsDisabled": {
|
"@extensionsDisabled": {
|
||||||
"description": "Extension status - inactive"
|
"description": "Extension status - inactive"
|
||||||
},
|
},
|
||||||
"extensionsVersion": "版本 {version}",
|
"extensionsVersion": "Version {version}",
|
||||||
"@extensionsVersion": {
|
"@extensionsVersion": {
|
||||||
"description": "Extension version display",
|
"description": "Extension version display",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -305,7 +305,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"extensionsAuthor": "来自 {author}",
|
"extensionsAuthor": "by {author}",
|
||||||
"@extensionsAuthor": {
|
"@extensionsAuthor": {
|
||||||
"description": "Extension author credit",
|
"description": "Extension author credit",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -314,75 +314,75 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"extensionsUninstall": "卸载",
|
"extensionsUninstall": "Uninstall",
|
||||||
"@extensionsUninstall": {
|
"@extensionsUninstall": {
|
||||||
"description": "Uninstall extension button"
|
"description": "Uninstall extension button"
|
||||||
},
|
},
|
||||||
"storeTitle": "扩展商店",
|
"storeTitle": "Extension Store",
|
||||||
"@storeTitle": {
|
"@storeTitle": {
|
||||||
"description": "Store screen title"
|
"description": "Store screen title"
|
||||||
},
|
},
|
||||||
"storeSearch": "搜索扩展……",
|
"storeSearch": "Search extensions...",
|
||||||
"@storeSearch": {
|
"@storeSearch": {
|
||||||
"description": "Store search placeholder"
|
"description": "Store search placeholder"
|
||||||
},
|
},
|
||||||
"storeInstall": "安装",
|
"storeInstall": "Install",
|
||||||
"@storeInstall": {
|
"@storeInstall": {
|
||||||
"description": "Install extension button"
|
"description": "Install extension button"
|
||||||
},
|
},
|
||||||
"storeInstalled": "已安装",
|
"storeInstalled": "Installed",
|
||||||
"@storeInstalled": {
|
"@storeInstalled": {
|
||||||
"description": "Already installed badge"
|
"description": "Already installed badge"
|
||||||
},
|
},
|
||||||
"storeUpdate": "更新",
|
"storeUpdate": "Update",
|
||||||
"@storeUpdate": {
|
"@storeUpdate": {
|
||||||
"description": "Update available button"
|
"description": "Update available button"
|
||||||
},
|
},
|
||||||
"aboutTitle": "关于",
|
"aboutTitle": "About",
|
||||||
"@aboutTitle": {
|
"@aboutTitle": {
|
||||||
"description": "About page title"
|
"description": "About page title"
|
||||||
},
|
},
|
||||||
"aboutContributors": "贡献者",
|
"aboutContributors": "Contributors",
|
||||||
"@aboutContributors": {
|
"@aboutContributors": {
|
||||||
"description": "Section for contributors"
|
"description": "Section for contributors"
|
||||||
},
|
},
|
||||||
"aboutMobileDeveloper": "移动版本开发者",
|
"aboutMobileDeveloper": "Mobile version developer",
|
||||||
"@aboutMobileDeveloper": {
|
"@aboutMobileDeveloper": {
|
||||||
"description": "Role description for mobile dev"
|
"description": "Role description for mobile dev"
|
||||||
},
|
},
|
||||||
"aboutOriginalCreator": "原 SpotiLDAC 创建者",
|
"aboutOriginalCreator": "Creator of the original SpotiFLAC",
|
||||||
"@aboutOriginalCreator": {
|
"@aboutOriginalCreator": {
|
||||||
"description": "Role description for original creator"
|
"description": "Role description for original creator"
|
||||||
},
|
},
|
||||||
"aboutLogoArtist": "有才华的艺术家创建了我们美丽的应用图标!",
|
"aboutLogoArtist": "The talented artist who created our beautiful app logo!",
|
||||||
"@aboutLogoArtist": {
|
"@aboutLogoArtist": {
|
||||||
"description": "Role description for logo artist"
|
"description": "Role description for logo artist"
|
||||||
},
|
},
|
||||||
"aboutTranslators": "译者",
|
"aboutTranslators": "Translators",
|
||||||
"@aboutTranslators": {
|
"@aboutTranslators": {
|
||||||
"description": "Section for translators"
|
"description": "Section for translators"
|
||||||
},
|
},
|
||||||
"aboutSpecialThanks": "特别鸣谢",
|
"aboutSpecialThanks": "Special Thanks",
|
||||||
"@aboutSpecialThanks": {
|
"@aboutSpecialThanks": {
|
||||||
"description": "Section for special thanks"
|
"description": "Section for special thanks"
|
||||||
},
|
},
|
||||||
"aboutLinks": "相关链接",
|
"aboutLinks": "Links",
|
||||||
"@aboutLinks": {
|
"@aboutLinks": {
|
||||||
"description": "Section for external links"
|
"description": "Section for external links"
|
||||||
},
|
},
|
||||||
"aboutMobileSource": "移动版本源代码",
|
"aboutMobileSource": "Mobile source code",
|
||||||
"@aboutMobileSource": {
|
"@aboutMobileSource": {
|
||||||
"description": "Link to mobile GitHub repo"
|
"description": "Link to mobile GitHub repo"
|
||||||
},
|
},
|
||||||
"aboutPCSource": "桌面版本源代码",
|
"aboutPCSource": "PC source code",
|
||||||
"@aboutPCSource": {
|
"@aboutPCSource": {
|
||||||
"description": "Link to PC GitHub repo"
|
"description": "Link to PC GitHub repo"
|
||||||
},
|
},
|
||||||
"aboutReportIssue": "报告一个问题",
|
"aboutReportIssue": "Report an issue",
|
||||||
"@aboutReportIssue": {
|
"@aboutReportIssue": {
|
||||||
"description": "Link to report bugs"
|
"description": "Link to report bugs"
|
||||||
},
|
},
|
||||||
"aboutReportIssueSubtitle": "报告您遇到的任何问题",
|
"aboutReportIssueSubtitle": "Report any problems you encounter",
|
||||||
"@aboutReportIssueSubtitle": {
|
"@aboutReportIssueSubtitle": {
|
||||||
"description": "Subtitle for report issue"
|
"description": "Subtitle for report issue"
|
||||||
},
|
},
|
||||||
@@ -450,7 +450,7 @@
|
|||||||
"@aboutSpotiSaverDesc": {
|
"@aboutSpotiSaverDesc": {
|
||||||
"description": "Credit for SpotiSaver API"
|
"description": "Credit for SpotiSaver API"
|
||||||
},
|
},
|
||||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -603,23 +603,23 @@
|
|||||||
"@setupNotificationGranted": {
|
"@setupNotificationGranted": {
|
||||||
"description": "Success message for notification permission"
|
"description": "Success message for notification permission"
|
||||||
},
|
},
|
||||||
"setupNotificationEnable": "启用通知",
|
"setupNotificationEnable": "Enable Notifications",
|
||||||
"@setupNotificationEnable": {
|
"@setupNotificationEnable": {
|
||||||
"description": "Button to enable notifications"
|
"description": "Button to enable notifications"
|
||||||
},
|
},
|
||||||
"setupFolderChoose": "选择下载文件夹",
|
"setupFolderChoose": "Choose Download Folder",
|
||||||
"@setupFolderChoose": {
|
"@setupFolderChoose": {
|
||||||
"description": "Button to choose folder"
|
"description": "Button to choose folder"
|
||||||
},
|
},
|
||||||
"setupFolderDescription": "选择保存您下载的音乐的文件夹。",
|
"setupFolderDescription": "Select a folder where your downloaded music will be saved.",
|
||||||
"@setupFolderDescription": {
|
"@setupFolderDescription": {
|
||||||
"description": "Explanation for folder selection"
|
"description": "Explanation for folder selection"
|
||||||
},
|
},
|
||||||
"setupSelectFolder": "选择文件夹",
|
"setupSelectFolder": "Select Folder",
|
||||||
"@setupSelectFolder": {
|
"@setupSelectFolder": {
|
||||||
"description": "Button to select folder"
|
"description": "Button to select folder"
|
||||||
},
|
},
|
||||||
"setupEnableNotifications": "启用通知",
|
"setupEnableNotifications": "Enable Notifications",
|
||||||
"@setupEnableNotifications": {
|
"@setupEnableNotifications": {
|
||||||
"description": "Button to enable notifications"
|
"description": "Button to enable notifications"
|
||||||
},
|
},
|
||||||
@@ -889,26 +889,14 @@
|
|||||||
"@errorRateLimited": {
|
"@errorRateLimited": {
|
||||||
"description": "Error title - too many requests"
|
"description": "Error title - too many requests"
|
||||||
},
|
},
|
||||||
"errorRateLimitedMessage": "请求过多。请等一会再搜索。",
|
"errorRateLimitedMessage": "Too many requests. Please wait a moment before searching again.",
|
||||||
"@errorRateLimitedMessage": {
|
"@errorRateLimitedMessage": {
|
||||||
"description": "Error message - rate limit explanation"
|
"description": "Error message - rate limit explanation"
|
||||||
},
|
},
|
||||||
"errorNoTracksFound": "未找到曲目",
|
"errorNoTracksFound": "No tracks found",
|
||||||
"@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",
|
||||||
@@ -1015,14 +1003,6 @@
|
|||||||
"@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"
|
||||||
@@ -1117,7 +1097,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "Built-in",
|
"providerBuiltIn": "Built-in",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Extension",
|
"providerExtension": "Extension",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1773,6 +1753,18 @@
|
|||||||
"@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"
|
||||||
@@ -2391,7 +2383,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"description": "Tutorial welcome tip 1"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
|
||||||
"@tutorialWelcomeTip2": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2816,90 +2808,6 @@
|
|||||||
"@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"
|
||||||
|
|||||||
+15
-107
@@ -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 and Qobuz.",
|
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -897,18 +897,6 @@
|
|||||||
"@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",
|
||||||
@@ -1015,14 +1003,6 @@
|
|||||||
"@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"
|
||||||
@@ -1117,7 +1097,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "Built-in",
|
"providerBuiltIn": "Built-in",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Extension",
|
"providerExtension": "Extension",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1773,6 +1753,18 @@
|
|||||||
"@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"
|
||||||
@@ -2391,7 +2383,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"description": "Tutorial welcome tip 1"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
|
||||||
"@tutorialWelcomeTip2": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2816,90 +2808,6 @@
|
|||||||
"@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"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user