mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-06 12:48:03 +02:00
Compare commits
196 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c66d13c9fd | |||
| 8529985a0e | |||
| a8a3973225 | |||
| 6710f90e1e | |||
| 929c5f3249 | |||
| f170ead7b9 | |||
| e63e366228 | |||
| 95e755e54e | |||
| c719406425 | |||
| 9627ef66cf | |||
| 15f977d98d | |||
| 5b5f043624 | |||
| 529a920b24 | |||
| 09eb6cf206 | |||
| af6fa6ea53 | |||
| 280b921755 | |||
| 6ebe0c51ce | |||
| 47bd24c1bd | |||
| 2b23678c0d | |||
| e8327545ad | |||
| 89a38af538 | |||
| b7f34ec47c | |||
| 967523bfc6 | |||
| 29d8a185f9 | |||
| 4495d4bf4e | |||
| 67737467e0 | |||
| 13845eea04 | |||
| 12779778d3 | |||
| d4178ad036 | |||
| 49ea84384d | |||
| a6d9849468 | |||
| 16100aa0fd | |||
| 387dd47374 | |||
| f67f52eba9 | |||
| 18607597e9 | |||
| 78cd396847 | |||
| 8540da484f | |||
| 8c18c7b8f1 | |||
| 10c5293f64 | |||
| d5381afcf9 | |||
| 134bf4375f | |||
| aa9854fc0a | |||
| 10bc29e347 | |||
| 733efce161 | |||
| ac9141f167 | |||
| d89850e8a9 | |||
| 5948e4f125 | |||
| 34d22f783c | |||
| c347b6999e | |||
| adc74741ce | |||
| 48f614359e | |||
| 16669d8b7a | |||
| f1eef47600 | |||
| fc1567d2c8 | |||
| fffce6039a | |||
| df77ae3986 | |||
| 3cd6d068a2 | |||
| 29165da5ac | |||
| 9343583c69 | |||
| d82d255bae | |||
| 93a7042a84 | |||
| 5be5c869da | |||
| 8d45e023b2 | |||
| f2ae1398db | |||
| c2736a61fb | |||
| 76fe8dbc69 | |||
| 64408c8d8b | |||
| db55bb4693 | |||
| 9c6856b584 | |||
| a4899144c5 | |||
| 808083c938 | |||
| 7e41ab4460 | |||
| 75a2bec8d5 | |||
| c35857bb61 | |||
| 2c897992c5 | |||
| 7d5cb574c6 | |||
| c582f96cf6 | |||
| 8fab3f60a7 | |||
| c6e981b3a1 | |||
| f0c5c5660a | |||
| 9c647bb31b | |||
| e1e82ac586 | |||
| 585d6da98d | |||
| bc279dd7fd | |||
| f2fdead6d3 | |||
| f66ccb4741 | |||
| 32c10c2b23 | |||
| 05674d9586 | |||
| 11bda9aae5 | |||
| 02c803385c | |||
| 8fe7a1e756 | |||
| 4a61ffea8d | |||
| 91548691ad | |||
| 36a646e5c0 | |||
| f306599ab2 | |||
| 3a7b777717 | |||
| 2334e659ad | |||
| 2a0216c87a | |||
| ab2d671760 | |||
| 5532d0a7d9 | |||
| 277e7719d3 | |||
| d6cb9fc261 | |||
| e6a857335f | |||
| e82e3a8343 | |||
| 6d812c76c2 | |||
| 5af4bb7ade | |||
| 030d66fd65 | |||
| c929f8d0a6 | |||
| 6fb50cfc67 | |||
| ebcdcf40dc | |||
| 76a05e717b | |||
| 062ce31cf7 | |||
| 8675ab3215 | |||
| ad6ef2884a | |||
| 3ebb8a5e79 | |||
| 652b1b0821 | |||
| a202ca4865 | |||
| a2db5bef25 | |||
| a81fa1ead7 | |||
| e7315cbc7e | |||
| cd757f177f | |||
| 103c55c072 | |||
| 765caab6df | |||
| 72f4663dd5 | |||
| deb6d92b55 | |||
| 0222ea6ccb | |||
| 8c047600a0 | |||
| 57b5877fdc | |||
| 7ddf67a977 | |||
| 7af2212d11 | |||
| 5e13651ed9 | |||
| 08e9c8d463 | |||
| b3d93880b5 | |||
| 05e100a492 | |||
| a4e22de455 | |||
| d76d020cfe | |||
| 85bf3cfa84 | |||
| 8eec73d88c | |||
| b63dbbbfd5 | |||
| 8b16157047 | |||
| 6628682f97 | |||
| 5971ffc470 | |||
| baf95ec328 | |||
| 0a6590fafd | |||
| 22dd0ee0f6 | |||
| f9ad6046e8 | |||
| 8a21902fa1 | |||
| 016564eda7 | |||
| 5a8ff7db37 | |||
| cc08596adf | |||
| e83fd66023 | |||
| d49bab403d | |||
| a6bef63aa7 | |||
| 898e28c40c | |||
| 9fda7ef596 | |||
| 17ba1713ad | |||
| f4110204b1 | |||
| d2a183b52d | |||
| a8dcf3113c | |||
| 1f52a6c9e0 | |||
| adbed63196 | |||
| 33e20845f1 | |||
| 9a7096c301 | |||
| 4c365032ff | |||
| bbd32d40a6 | |||
| 73f4a91fa1 | |||
| 1e2e383794 | |||
| 3b70b071e3 | |||
| 838c0ea421 | |||
| b39ec41255 | |||
| d4d661d6d4 | |||
| 2092f078ec | |||
| 924569aefb | |||
| a5864e15f8 | |||
| 564dd8bf95 | |||
| b317f7cd76 | |||
| a3b49d2642 | |||
| 6f20620c97 | |||
| b6a055a01a | |||
| 44ac593ddc | |||
| ca4c2a661e | |||
| 8b3b39f390 | |||
| 915934e5dd | |||
| 42f15018ae | |||
| 3554a7b5b9 | |||
| f2941939b7 | |||
| 1a77ded997 | |||
| 05d25d4d7c | |||
| 7cc1fef989 | |||
| 4a966e5e52 | |||
| d8ba4549aa | |||
| 309568becc | |||
| dd9b6dbfe3 | |||
| 4692b48174 | |||
| db82fa3ae1 | |||
| 5c42507b12 |
+111
-64
@@ -309,32 +309,22 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Full history needed for git-cliff
|
||||||
|
|
||||||
- name: Extract changelog for version
|
- name: Generate changelog with git-cliff
|
||||||
id: changelog
|
id: changelog
|
||||||
|
uses: orhun/git-cliff-action@v4
|
||||||
|
with:
|
||||||
|
config: cliff.toml
|
||||||
|
args: --latest --strip header
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
OUTPUT: /tmp/changelog.txt
|
||||||
|
|
||||||
|
- name: Show generated changelog
|
||||||
run: |
|
run: |
|
||||||
VERSION=${{ needs.get-version.outputs.version }}
|
echo "Generated changelog:"
|
||||||
VERSION_NUM=${VERSION#v} # Remove 'v' prefix
|
|
||||||
|
|
||||||
echo "Looking for version: $VERSION_NUM"
|
|
||||||
|
|
||||||
# Extract changelog section for this version using sed
|
|
||||||
# Find the line with version, then print until next version header or end
|
|
||||||
CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md)
|
|
||||||
|
|
||||||
# If no changelog found, use default message
|
|
||||||
if [ -z "$CHANGELOG" ]; then
|
|
||||||
echo "No changelog found for version $VERSION_NUM"
|
|
||||||
CHANGELOG="See CHANGELOG.md for details."
|
|
||||||
else
|
|
||||||
echo "Found changelog content"
|
|
||||||
# Remove trailing --- separator if present (CHANGELOG uses --- between versions)
|
|
||||||
CHANGELOG=$(echo "$CHANGELOG" | sed '/^---$/d')
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Save to file for multiline support
|
|
||||||
echo "$CHANGELOG" > /tmp/changelog.txt
|
|
||||||
echo "Extracted changelog:"
|
|
||||||
cat /tmp/changelog.txt
|
cat /tmp/changelog.txt
|
||||||
|
|
||||||
- name: Download Android APK
|
- name: Download Android APK
|
||||||
@@ -352,15 +342,22 @@ jobs:
|
|||||||
- name: Prepare release body
|
- name: Prepare release body
|
||||||
run: |
|
run: |
|
||||||
VERSION=${{ needs.get-version.outputs.version }}
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
cat > /tmp/release_body.txt << 'HEADER'
|
|
||||||
### What's New
|
|
||||||
HEADER
|
|
||||||
|
|
||||||
cat /tmp/changelog.txt >> /tmp/release_body.txt
|
|
||||||
|
|
||||||
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
|
||||||
|
# deterministic previous-tag lookup from git.
|
||||||
|
sed '/^## [0-9][0-9.[:alpha:]-]*$/d; /^\*\*Full Changelog\*\*/d' /tmp/changelog.txt > /tmp/release_body.txt
|
||||||
|
|
||||||
|
if [ -n "$PREVIOUS_TAG" ]; then
|
||||||
|
printf '\n**Full Changelog**: [%s...%s](https://github.com/%s/%s/compare/%s...%s)\n' \
|
||||||
|
"$PREVIOUS_TAG" "$VERSION" "$REPO_OWNER" "$REPO_NAME" "$PREVIOUS_TAG" "$VERSION" \
|
||||||
|
>> /tmp/release_body.txt
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Append download section
|
||||||
cat >> /tmp/release_body.txt << FOOTER
|
cat >> /tmp/release_body.txt << FOOTER
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -396,6 +393,63 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
update-altstore:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [get-version, build-ios, create-release]
|
||||||
|
if: ${{ needs.get-version.outputs.is_prerelease != 'true' }}
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout main branch
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
|
||||||
|
- name: Download iOS IPA
|
||||||
|
uses: actions/download-artifact@v7
|
||||||
|
with:
|
||||||
|
name: ios-ipa
|
||||||
|
path: ./release
|
||||||
|
|
||||||
|
- name: Update apps.json
|
||||||
|
run: |
|
||||||
|
VERSION="${{ needs.get-version.outputs.version }}"
|
||||||
|
VERSION_NUM="${VERSION#v}"
|
||||||
|
DATE=$(date -u +%Y-%m-%d)
|
||||||
|
IPA_FILE=$(find ./release -name "*ios*.ipa" | head -1)
|
||||||
|
|
||||||
|
if [ -z "$IPA_FILE" ]; then
|
||||||
|
echo "WARNING: IPA file not found, skipping apps.json update"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
IPA_SIZE=$(stat -c%s "$IPA_FILE" 2>/dev/null || stat -f%z "$IPA_FILE")
|
||||||
|
|
||||||
|
if [ ! -f apps.json ]; then
|
||||||
|
echo "WARNING: apps.json not found on main, skipping"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
jq --arg ver "$VERSION_NUM" \
|
||||||
|
--arg date "$DATE" \
|
||||||
|
--arg url "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa" \
|
||||||
|
--argjson size "$IPA_SIZE" \
|
||||||
|
'.apps[0].version = $ver | .apps[0].versionDate = $date | .apps[0].downloadURL = $url | .apps[0].size = $size' \
|
||||||
|
apps.json > apps.json.tmp && mv apps.json.tmp apps.json
|
||||||
|
|
||||||
|
echo "Updated apps.json:"
|
||||||
|
cat apps.json
|
||||||
|
|
||||||
|
- name: Commit and push
|
||||||
|
run: |
|
||||||
|
VERSION="${{ needs.get-version.outputs.version }}"
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add apps.json
|
||||||
|
git diff --cached --quiet && echo "No changes to commit" || \
|
||||||
|
(git commit -m "chore: update AltStore source to ${VERSION}" && git push)
|
||||||
|
|
||||||
notify-telegram:
|
notify-telegram:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [get-version, create-release]
|
needs: [get-version, create-release]
|
||||||
@@ -404,6 +458,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Download Android APK
|
- name: Download Android APK
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v7
|
||||||
@@ -417,52 +473,43 @@ jobs:
|
|||||||
name: ios-ipa
|
name: ios-ipa
|
||||||
path: ./release
|
path: ./release
|
||||||
|
|
||||||
- name: Extract changelog for version
|
- name: Generate changelog with git-cliff for Telegram
|
||||||
|
uses: orhun/git-cliff-action@v4
|
||||||
|
with:
|
||||||
|
config: cliff.toml
|
||||||
|
args: --latest --strip all
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
OUTPUT: /tmp/cliff_tg.txt
|
||||||
|
|
||||||
|
- name: Convert changelog for Telegram
|
||||||
id: changelog
|
id: changelog
|
||||||
run: |
|
run: |
|
||||||
VERSION=${{ needs.get-version.outputs.version }}
|
if [ ! -s /tmp/cliff_tg.txt ]; then
|
||||||
VERSION_NUM=${VERSION#v}
|
echo "See release notes on GitHub for details." > /tmp/changelog.txt
|
||||||
|
|
||||||
# Extract changelog, limit to ~2500 chars for Telegram (4096 limit minus message overhead)
|
|
||||||
# Use tr -d '\r' to handle CRLF line endings from Windows
|
|
||||||
FULL_CHANGELOG=$(cat CHANGELOG.md | tr -d '\r' | sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" | sed '/^---$/d')
|
|
||||||
|
|
||||||
echo "DEBUG: Extracted changelog length: ${#FULL_CHANGELOG}"
|
|
||||||
echo "DEBUG: First 200 chars: ${FULL_CHANGELOG:0:200}"
|
|
||||||
|
|
||||||
if [ -z "$FULL_CHANGELOG" ]; then
|
|
||||||
CHANGELOG="See release notes on GitHub for details."
|
|
||||||
else
|
else
|
||||||
# Convert GitHub Markdown to Telegram HTML:
|
# Convert Markdown to Telegram HTML
|
||||||
# - **text** → <b>text</b>
|
CHANGELOG=$(cat /tmp/cliff_tg.txt | \
|
||||||
# - `code` → <code>code</code>
|
sed '/^## [0-9][0-9.[:alpha:]-]*$/d' | \
|
||||||
# - ### Header → <b>Header</b>
|
sed '/^\*\*Full Changelog\*\*/d' | \
|
||||||
# - Escape HTML special chars first
|
sed 's/ by \[@[^]]*\](https:\/\/github\.com\/[^)]*)//g' | \
|
||||||
# - Remove > blockquote prefix
|
sed 's/ by @[A-Za-z0-9_-]\+//g' | \
|
||||||
CHANGELOG=$(echo "$FULL_CHANGELOG" | \
|
sed 's/\[#\([0-9]*\)\]([^)]*)/#\1/g' | \
|
||||||
sed 's/^> //' | \
|
sed 's/\[@\([^]]*\)\]([^)]*)/@\1/g' | \
|
||||||
sed 's/&/\&/g' | \
|
sed 's/&/\&/g' | \
|
||||||
sed 's/</\</g' | \
|
sed 's/</\</g' | \
|
||||||
sed 's/>/\>/g' | \
|
sed 's/>/\>/g' | \
|
||||||
sed 's/`\([^`]*\)`/<code>\1<\/code>/g' | \
|
|
||||||
sed 's/\*\*\([^*]*\)\*\*/<b>\1<\/b>/g' | \
|
sed 's/\*\*\([^*]*\)\*\*/<b>\1<\/b>/g' | \
|
||||||
sed 's/^### \(.*\)$/<b>\1<\/b>/g' | \
|
sed 's/^### \(.*\)$/<b>\1<\/b>/g' | \
|
||||||
sed 's/^## \(.*\)$/<b>\1<\/b>/g' | \
|
sed 's/^## \(.*\)$/<b>\1<\/b>/g' | \
|
||||||
sed 's/^- /• /g' | \
|
sed 's/^- /• /g')
|
||||||
sed 's/^ - / ◦ /g')
|
|
||||||
|
# Truncate for Telegram 4096 char limit
|
||||||
# Take first 2500 characters, then cut at last complete line
|
|
||||||
CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d')
|
CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d')
|
||||||
|
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||||
# Check if truncated
|
|
||||||
FULL_LEN=${#FULL_CHANGELOG}
|
|
||||||
if [ $FULL_LEN -gt 2500 ]; then
|
|
||||||
CHANGELOG="${CHANGELOG}"$'\n\n... (see full changelog on GitHub)'
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "$CHANGELOG" > /tmp/changelog.txt
|
echo "Telegram changelog:"
|
||||||
echo "DEBUG: Final changelog:"
|
|
||||||
cat /tmp/changelog.txt
|
cat /tmp/changelog.txt
|
||||||
|
|
||||||
- name: Send to Telegram Channel
|
- name: Send to Telegram Channel
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ Thumbs.db
|
|||||||
# Kiro specs (development only)
|
# Kiro specs (development only)
|
||||||
.kiro/
|
.kiro/
|
||||||
|
|
||||||
|
# Design assets (banners, mockups)
|
||||||
|
design/
|
||||||
|
|
||||||
# Reference folder (development only)
|
# Reference folder (development only)
|
||||||
referensi/
|
referensi/
|
||||||
|
|
||||||
@@ -64,6 +67,7 @@ AGENTS.md
|
|||||||
|
|
||||||
# Temp/misc
|
# Temp/misc
|
||||||
nul
|
nul
|
||||||
|
network_requests.txt
|
||||||
|
|
||||||
# Log files
|
# Log files
|
||||||
*.log
|
*.log
|
||||||
@@ -73,3 +77,6 @@ flutter_*.log
|
|||||||
# Development tools
|
# Development tools
|
||||||
tool/
|
tool/
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# FVM Version Cache
|
||||||
|
.fvm/
|
||||||
|
|||||||
+74
-2
@@ -1,5 +1,77 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [3.7.2] - 2026-03-07
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Amazon Music is now an Extension**: Amazon Music has been moved from a built-in service to a separate installable extension. Install the "Amazon Music" extension from the Store to continue using it.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Deezer Downloads Timing Out**: Deezer downloads were failing with "context deadline exceeded" on larger files. Now uses the proper download timeout, matching Tidal and Qobuz.
|
||||||
|
- **iOS Local Library Scan Fails**: Local library scanning was failing on iOS because the app lost access to user-picked folders after the FilePicker session ended. Implemented iOS security-scoped bookmark system:
|
||||||
|
- When a library folder is picked on iOS, a security-scoped bookmark is created and persisted in settings (`localLibraryBookmark`)
|
||||||
|
- Before each scan, the bookmark is resolved and security-scoped access is started; access is released in `finally` block after scan completes
|
||||||
|
- `cleanupMissingFiles` also activates the bookmark before checking file existence on iOS
|
||||||
|
- New `AppDelegate.swift` method channel handlers: `createIosBookmarkFromPath`, `startAccessingIosBookmark`, `stopAccessingIosBookmark`, `resolveIosBookmark`
|
||||||
|
- New `PlatformBridge` methods: `createIosBookmarkFromPath()`, `startAccessingIosBookmark()`, `stopAccessingIosBookmark()`
|
||||||
|
- All scan call-sites (Library Settings, Queue tab, Local Album screen) now pass the iOS bookmark to `startScan()`
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Amazon Music Extension**: Available in `extension/Amazon-SpotiFLAC/` — same functionality as before, now as an installable extension.
|
||||||
|
- **Accessibility Tooltips**: Added localized tooltips to all `IconButton` and `PopupMenuButton` widgets across the entire UI for screen reader and long-press discoverability
|
||||||
|
- Back buttons use `MaterialLocalizations.backButtonTooltip`
|
||||||
|
- Close buttons use `MaterialLocalizations.closeButtonTooltip`
|
||||||
|
- Menu buttons use `MaterialLocalizations.showMenuTooltip`
|
||||||
|
- Search buttons use `MaterialLocalizations.searchFieldLabel`
|
||||||
|
- Contextual actions use descriptive labels: "Play track", "Dismiss", "Clear search", "Change folder", "Refresh"
|
||||||
|
- Screens affected: Album, Artist, Playlist, Downloaded Album, Local Album, Home, Search, Queue, Library Playlists, Library Tracks Folder, Setup, Tutorial, Track Metadata, Store, Extension Store Details, and all Settings sub-pages (About, Appearance, Cache Management, Donate, Download, Extensions, Extension Detail, Library, Log, Options, Provider Priority)
|
||||||
|
- **Semantics Wrappers**: Added `Semantics` widgets to interactive elements that previously had no accessibility information
|
||||||
|
- Album tiles in Artist screen: announces selection state and album name
|
||||||
|
- Recently downloaded track tiles in Home tab: announces track name and artist
|
||||||
|
- Explore items (albums/artists/playlists) in Home tab: announces item type and name
|
||||||
|
- Color palette picker in Appearance settings: announces selected state and color hex value
|
||||||
|
- Download button demo in Tutorial screen: added `ExcludeSemantics` on icon to prevent duplicate screen reader announcements
|
||||||
|
- Queue tab playlist cards: announces playlist name and item count
|
||||||
|
- Queue tab downloaded album cards: announces album name, artist, and track count
|
||||||
|
- Queue tab local album cards: announces album name, artist, and track count
|
||||||
|
- Queue tab play button on completed downloads: announces track name and artist with `ExcludeSemantics` on icon
|
||||||
|
- Queue tab download status indicators: "Finalizing download", "Download completed", "Downloaded file missing" labels with `ExcludeSemantics` on icons
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- **Code Formatting**: Reformatted and corrected indentation across multiple files to comply with Dart style guidelines
|
||||||
|
- `extension_detail_page.dart`: Fixed `SliverAppBar` and all subsequent slivers indentation (was 2 spaces short)
|
||||||
|
- `log_screen.dart`: Fixed `SliverAppBar` indentation alignment
|
||||||
|
- `donate_page.dart`: Reformatted ternary expressions and `_cr` function body
|
||||||
|
- `library_tracks_folder_screen.dart`: Minor line-break formatting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.7.1] - 2026-03-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Deezer Download Service**: Deezer is now available as a built-in download service (FLAC CD Quality).
|
||||||
|
- **Smarter YouTube Downloads**: If the YouTube Music extension is installed, the app now uses it first to find the correct song — more accurate than SongLink, especially for new releases.
|
||||||
|
- **Songs-Only Search Filter**: YouTube Music extension search now filters results server-side, so you only get actual songs — no music videos or covers mixed in.
|
||||||
|
- **Qobuz Squid.wtf Fallback**: Added Squid.wtf as an additional Qobuz download provider.
|
||||||
|
- **Qobuz Search Fallback**: If Qobuz API search returns nothing, the app now tries the Qobuz web store as a backup to find the track.
|
||||||
|
- **Better ISRC Lookup**: Tracks can now be resolved via ISRC even without a Spotify ID, using Deezer as an intermediary.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Download Queue Stability**: Fixed duplicate queue item IDs, cancel not working reliably, and "Clear All" not properly stopping active downloads.
|
||||||
|
- **Queue Restore on Restart**: Duplicate or broken queue item IDs are now auto-fixed when the app restarts.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Update Checker**: The app can now detect updates across all versions, not just within the same major version.
|
||||||
|
- **Localization Cleanup**: Cleaned up and consolidated translation files across all 13 supported languages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [3.7.0] - 2026-03-04
|
## [3.7.0] - 2026-03-04
|
||||||
|
|
||||||
Hey everyone, thank you so much for sticking with SpotiFLAC Mobile.
|
Hey everyone, thank you so much for sticking with SpotiFLAC Mobile.
|
||||||
@@ -262,7 +334,7 @@ Thank you for your understanding and continued support. This decision was made t
|
|||||||
- Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service
|
- Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service
|
||||||
- New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)`
|
- New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)`
|
||||||
- SpotFetch metadata fallback integration for Spotify-blocked regions
|
- SpotFetch metadata fallback integration for Spotify-blocked regions
|
||||||
- New backend client for `spotify.afkarxyz.fun/api`
|
- New backend client for `sp.afkarxyz.qzz.io/api`
|
||||||
- Automatic fallback in Spotify metadata fetch path when primary source fails
|
- Automatic fallback in Spotify metadata fetch path when primary source fails
|
||||||
- Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC
|
- Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC
|
||||||
- Includes heuristic detection of lyrics stored in Comment fields
|
- Includes heuristic detection of lyrics stored in Comment fields
|
||||||
@@ -277,7 +349,7 @@ Thank you for your understanding and continued support. This decision was made t
|
|||||||
- Legacy Dart bridge methods (`downloadTrack`, `downloadWithFallback`, `downloadWithExtensions`, `downloadFromYouTube`) are now thin wrappers and marked `@Deprecated`
|
- Legacy Dart bridge methods (`downloadTrack`, `downloadWithFallback`, `downloadWithExtensions`, `downloadFromYouTube`) are now thin wrappers and marked `@Deprecated`
|
||||||
- Qobuz downloader updated to latest Jumo API contract (`/get` endpoint, required headers)
|
- Qobuz downloader updated to latest Jumo API contract (`/get` endpoint, required headers)
|
||||||
- Amazon download flow now returns `decryption_key` from Go and performs decryption in Flutter (local file + SAF paths)
|
- Amazon download flow now returns `decryption_key` from Go and performs decryption in Flutter (local file + SAF paths)
|
||||||
- Amazon now uses the new `amazon.afkarxyz.fun` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
|
- Amazon now uses the new `amzn.afkarxyz.qzz.io` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
|
||||||
- Amazon ASIN extraction rewritten with robust URL/query-param parsing and regex fallback
|
- Amazon ASIN extraction rewritten with robust URL/query-param parsing and regex fallback
|
||||||
- Amazon provider re-enabled in download service picker and download settings (alongside Tidal, Qobuz, and YouTube picker flow)
|
- Amazon provider re-enabled in download service picker and download settings (alongside Tidal, Qobuz, and YouTube picker flow)
|
||||||
- Track Metadata cover UI now refreshes from the embedded file after Edit Metadata/Re-enrich, so the displayed art matches actual file tags
|
- Track Metadata cover UI now refreshes from the embedded file after Edit Metadata/Re-enrich, so the displayed art matches actual file tags
|
||||||
|
|||||||
+17
-3
@@ -86,17 +86,31 @@ Translation files are located in `lib/l10n/arb/`.
|
|||||||
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
|
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Install dependencies**
|
3. **Use FVM (Flutter Version: 3.38.1)**
|
||||||
|
```bash
|
||||||
|
fvm use
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Install dependencies**
|
||||||
```bash
|
```bash
|
||||||
flutter pub get
|
flutter pub get
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Generate code** (for Riverpod, JSON serialization, etc.)
|
5. **Generate code** (for Riverpod, JSON serialization, etc.)
|
||||||
```bash
|
```bash
|
||||||
dart run build_runner build --delete-conflicting-outputs
|
dart run build_runner build --delete-conflicting-outputs
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **Run the app**
|
6. **Set up Go environment (Go Version: 1.25.7)**
|
||||||
|
```bash
|
||||||
|
cd go_backend
|
||||||
|
mkdir -p ../android/app/libs
|
||||||
|
gomobile init
|
||||||
|
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Run the app**
|
||||||
```bash
|
```bash
|
||||||
flutter run
|
flutter run
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
|
||||||
[](https://www.virustotal.com/gui/file/40f8f1914287dea317122a837f98b0ddf7af3205adc2f84a350d767e0a6a345c)
|
|
||||||
[](https://crowdin.com/project/spotiflac-mobile)
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<img src="icon.png" width="128" />
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="assets/images/banner-readme-dark.png">
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="assets/images/banner-readme-light.png">
|
||||||
|
<img alt="SpotiFLAC Mobile" src="assets/images/banner-readme-light.png" width="650" height="auto">
|
||||||
|
</picture>
|
||||||
|
|
||||||
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
<p align="center">
|
||||||
|
<a href="https://trendshift.io/repositories/17247">
|
||||||

|
<img src="https://trendshift.io/api/badge/repositories/17247" alt="zarzet%2FSpotiFLAC-Mobile | Trendshift" width="250" height="55">
|
||||||

|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -24,37 +23,47 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no acc
|
|||||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
|
[](https://www.virustotal.com/gui/file/63a445a956fa71ea347ad3695a62d543e14e341933326b9dbb9a15d79614ef58)
|
||||||
|
[](https://crowdin.com/project/spotiflac-mobile)
|
||||||
|
|
||||||
|
[](https://t.me/spotiflac)
|
||||||
|
[](https://t.me/spotiflac_chat)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
## Extensions
|
## Extensions
|
||||||
|
|
||||||
Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
|
Extensions 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. Go to **Store** tab in the app
|
||||||
2. Browse and install extensions with one tap
|
2. When opening the Store for the first time, you will be asked to enter an **Extension Repository URL**
|
||||||
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 in **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.
|
Want to create your own extension? Check out the [Extension Development Guide](https://zarzet.github.io/SpotiFLAC-Mobile/docs) for complete documentation.
|
||||||
|
|
||||||
## Other project
|
## Other project
|
||||||
|
|
||||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||||
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
||||||
|
|
||||||
## Telegram
|
|
||||||
|
|
||||||
[](https://t.me/spotiflac)
|
|
||||||
[](https://t.me/spotiflac_chat)
|
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
|
**Q: Why does the Store tab ask me to enter a URL?**
|
||||||
|
A: Starting from version 3.8.0, SpotiFLAC uses a decentralized extension repository system — extensions are hosted on GitHub repositories rather than a built-in server, so anyone can create and host their own. Enter a repository URL in the Store tab to browse and install extensions.
|
||||||
|
|
||||||
**Q: Why is my download failing with "Song not found"?**
|
**Q: Why is my download failing with "Song not found"?**
|
||||||
A: The track may not be available on Tidal, Qobuz, or Amazon Music. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions from the Store.
|
A: The track may not be available on the streaming services. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions like Amazon Music from the Store.
|
||||||
|
|
||||||
**Q: Why are some tracks downloading in lower quality?**
|
**Q: Why are some tracks downloading in lower quality?**
|
||||||
A: Quality depends on what's available from the streaming service. Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Amazon up to 24-bit/48kHz. The app automatically selects the best available 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.
|
||||||
|
|
||||||
**Q: Can I download playlists?**
|
**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.
|
A: Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
|
||||||
@@ -68,6 +77,11 @@ A: Yes, the app is open source and you can verify the code yourself. Each releas
|
|||||||
**Q: Why is download not working in my country?**
|
**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.
|
A: Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
|
||||||
|
|
||||||
|
**Q: Can I add SpotiFLAC to AltStore or SideStore?**
|
||||||
|
A: Yes! You can add the official source to receive updates directly within the app. Just copy this link:
|
||||||
|
https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/refs/heads/main/apps.json
|
||||||
|
In AltStore/SideStore, go to the Browse tab, tap Sources at the top, then tap the + icon and paste the link.
|
||||||
|
|
||||||
|
|
||||||
### Want to support SpotiFLAC-Mobile?
|
### Want to support SpotiFLAC-Mobile?
|
||||||
|
|
||||||
@@ -75,22 +89,17 @@ _If this software is useful and brings you value, consider supporting the projec
|
|||||||
|
|
||||||
[](https://ko-fi.com/zarzet)
|
[](https://ko-fi.com/zarzet)
|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
## Disclaimer
|
Thanks to all the amazing people who have contributed to SpotiFLAC Mobile!
|
||||||
|
|
||||||
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
<a href="https://github.com/zarzet/SpotiFLAC-Mobile/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=zarzet/SpotiFLAC-Mobile" />
|
||||||
|
</a>
|
||||||
|
|
||||||
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Tidal, Qobuz, Amazon Music, Deezer, or any other streaming service.
|
We also appreciate everyone who has helped with [translations on Crowdin](https://crowdin.com/project/spotiflac-mobile), reported bugs, suggested features, and spread the word about SpotiFLAC Mobile.
|
||||||
|
|
||||||
The application is purely a user interface that facilitates communication between your device and existing third-party services.
|
|
||||||
|
|
||||||
You are solely responsible for:
|
|
||||||
1. Ensuring your use of this software complies with your local laws.
|
|
||||||
2. Reading and adhering to the Terms of Service of the respective platforms.
|
|
||||||
3. Any legal consequences resulting from the misuse of this tool.
|
|
||||||
|
|
||||||
The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use.
|
|
||||||
|
|
||||||
|
Interested in contributing? Check out our [Contributing Guide](CONTRIBUTING.md) to get started!
|
||||||
|
|
||||||
## API Credits
|
## API Credits
|
||||||
|
|
||||||
@@ -99,4 +108,4 @@ The software is provided "as is", without warranty of any kind. The author assum
|
|||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
>
|
>
|
||||||
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
> **Star Us**, You will receive all release notifications from GitHub without any delay
|
||||||
|
|||||||
@@ -115,10 +115,8 @@ class DownloadService : Service() {
|
|||||||
* We must call stopSelf() within a few seconds to avoid a crash.
|
* 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "SpotiFLAC Source",
|
||||||
|
"identifier": "com.zarzet.spotiflac.source",
|
||||||
|
"subtitle": "FLAC Downloader for iOS",
|
||||||
|
"apps": [
|
||||||
|
{
|
||||||
|
"name": "SpotiFLAC",
|
||||||
|
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||||
|
"developerName": "zarzet",
|
||||||
|
"version": "3.8.6",
|
||||||
|
"versionDate": "2026-03-16",
|
||||||
|
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.8.6/SpotiFLAC-v3.8.6-ios-unsigned.ipa",
|
||||||
|
"localizedDescription": "Mobile version of SpotiFLAC written in Flutter. Download Tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
|
||||||
|
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
||||||
|
"size": 33676960
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
+103
@@ -0,0 +1,103 @@
|
|||||||
|
# git-cliff configuration for SpotiFLAC Mobile
|
||||||
|
# https://git-cliff.org/docs/configuration
|
||||||
|
|
||||||
|
[changelog]
|
||||||
|
# Template for the changelog body
|
||||||
|
body = """
|
||||||
|
{%- macro remote_url() -%}
|
||||||
|
https://github.com/zarzet/SpotiFLAC-Mobile
|
||||||
|
{%- endmacro -%}
|
||||||
|
|
||||||
|
{% if version %}\
|
||||||
|
## {{ version | trim_start_matches(pat="v") }}
|
||||||
|
{% else %}\
|
||||||
|
## Unreleased
|
||||||
|
{% endif %}\
|
||||||
|
|
||||||
|
{% for group, commits in commits | group_by(attribute="group") %}
|
||||||
|
### {{ group | striptags | trim | upper_first }}
|
||||||
|
{% for commit in commits %}
|
||||||
|
- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}\
|
||||||
|
{{ commit.message | upper_first }}\
|
||||||
|
{% if commit.github.pr_number %} \
|
||||||
|
([#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}))\
|
||||||
|
{% endif %}\
|
||||||
|
{%- if commit.github.username and commit.github.username != "zarzet" %} by [@{{ commit.github.username }}](https://github.com/{{ commit.github.username }}){%- endif %}
|
||||||
|
{%- endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{%- if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %}
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
{%- for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}
|
||||||
|
* @{{ contributor.username }} made their first contribution
|
||||||
|
{%- if contributor.pr_number %} in \
|
||||||
|
[#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \
|
||||||
|
{%- endif %}
|
||||||
|
{%- endfor %}
|
||||||
|
{%- endif -%}
|
||||||
|
|
||||||
|
{% if version %}
|
||||||
|
{% if previous.version %}
|
||||||
|
**Full Changelog**: [{{ previous.version }}...{{ version }}]({{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }})
|
||||||
|
{% endif %}
|
||||||
|
{% else -%}
|
||||||
|
{% raw %}\n{% endraw %}
|
||||||
|
{% endif %}
|
||||||
|
"""
|
||||||
|
# Remove leading and trailing whitespace
|
||||||
|
trim = true
|
||||||
|
|
||||||
|
[git]
|
||||||
|
# Parse conventional commits
|
||||||
|
conventional_commits = true
|
||||||
|
filter_unconventional = true
|
||||||
|
|
||||||
|
# Process each line of a commit as an individual commit
|
||||||
|
split_commits = false
|
||||||
|
|
||||||
|
# Regex for preprocessing the commit messages
|
||||||
|
commit_preprocessors = [
|
||||||
|
# Strip conventional commit prefix for cleaner messages
|
||||||
|
# (group header already shows the type)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Regex for parsing and grouping commits
|
||||||
|
commit_parsers = [
|
||||||
|
# Skip noise: translation commits from Crowdin
|
||||||
|
{ message = "^New translations", skip = true },
|
||||||
|
{ message = "^Update source file", skip = true },
|
||||||
|
# Skip merge commits
|
||||||
|
{ message = "^Merge", skip = true },
|
||||||
|
# Skip version bump commits
|
||||||
|
{ message = "^v\\d+", skip = true },
|
||||||
|
{ message = "^chore: update VirusTotal", skip = true },
|
||||||
|
|
||||||
|
# Group by conventional commit type
|
||||||
|
{ message = "^feat", group = "<!-- 0 -->New Features" },
|
||||||
|
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
|
||||||
|
{ message = "^perf", group = "<!-- 2 -->Performance" },
|
||||||
|
{ message = "^refactor", group = "<!-- 3 -->Refactoring" },
|
||||||
|
{ message = "^doc", group = "<!-- 4 -->Documentation" },
|
||||||
|
{ message = "^style", group = "<!-- 5 -->Styling" },
|
||||||
|
{ message = "^test", group = "<!-- 6 -->Testing" },
|
||||||
|
{ message = "^chore\\(deps\\)", group = "<!-- 7 -->Dependencies" },
|
||||||
|
{ message = "^chore\\(l10n\\)", skip = true },
|
||||||
|
{ message = "^chore|^ci", group = "<!-- 8 -->Chores" },
|
||||||
|
]
|
||||||
|
|
||||||
|
# Protect breaking changes from being skipped
|
||||||
|
protect_breaking_commits = true
|
||||||
|
|
||||||
|
# Filter out commits by matching patterns
|
||||||
|
filter_commits = false
|
||||||
|
|
||||||
|
# Tag pattern for version detection
|
||||||
|
tag_pattern = "v[0-9].*"
|
||||||
|
|
||||||
|
# Sort commits by newest first
|
||||||
|
sort_commits = "newest"
|
||||||
|
|
||||||
|
[remote.github]
|
||||||
|
owner = "zarzet"
|
||||||
|
repo = "SpotiFLAC-Mobile"
|
||||||
@@ -1,692 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Amazon API timeout and retry configuration for mobile networks
|
|
||||||
const (
|
|
||||||
amazonAPITimeoutMobile = 30 * time.Second // Longer timeout for unstable mobile networks
|
|
||||||
amazonMaxRetries = 2 // Number of retry attempts
|
|
||||||
amazonRetryDelay = 500 * time.Millisecond
|
|
||||||
)
|
|
||||||
|
|
||||||
type AmazonDownloader struct {
|
|
||||||
client *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
globalAmazonDownloader *AmazonDownloader
|
|
||||||
amazonDownloaderOnce sync.Once
|
|
||||||
amazonASINRegex = regexp.MustCompile(`(?i)^B[0-9A-Z]{9}$`)
|
|
||||||
amazonASINFindRegex = regexp.MustCompile(`(?i)B[0-9A-Z]{9}`)
|
|
||||||
)
|
|
||||||
|
|
||||||
// AfkarXYZResponse is the response from AfkarXYZ API
|
|
||||||
type AfkarXYZResponse struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Data struct {
|
|
||||||
DirectLink string `json:"direct_link"`
|
|
||||||
FileName string `json:"file_name"`
|
|
||||||
FileSize int64 `json:"file_size"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AmazonStreamResponse is the new response format from amzn.afkarxyz.fun/api/track/{asin}
|
|
||||||
type AmazonStreamResponse struct {
|
|
||||||
StreamURL string `json:"streamUrl"`
|
|
||||||
DecryptionKey string `json:"decryptionKey"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAmazonDownloader() *AmazonDownloader {
|
|
||||||
amazonDownloaderOnce.Do(func() {
|
|
||||||
globalAmazonDownloader = &AmazonDownloader{
|
|
||||||
client: NewHTTPClientWithTimeout(120 * time.Second),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return globalAmazonDownloader
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks.
|
|
||||||
// Returns downloadURL, suggested fileName, optional decryptionKey.
|
|
||||||
func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, string, error) {
|
|
||||||
var lastErr error
|
|
||||||
for attempt := 0; attempt <= amazonMaxRetries; attempt++ {
|
|
||||||
if attempt > 0 {
|
|
||||||
delay := amazonRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
|
|
||||||
GoLog("[Amazon] Retry %d/%d after %v...\n", attempt, amazonMaxRetries, delay)
|
|
||||||
time.Sleep(delay)
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadURL, fileName, decryptionKey, err := a.doAfkarXYZRequest(amazonURL)
|
|
||||||
if err == nil {
|
|
||||||
return downloadURL, fileName, decryptionKey, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
lastErr = err
|
|
||||||
errStr := strings.ToLower(err.Error())
|
|
||||||
|
|
||||||
// Check if error is retryable
|
|
||||||
isRetryable := strings.Contains(errStr, "timeout") ||
|
|
||||||
strings.Contains(errStr, "connection reset") ||
|
|
||||||
strings.Contains(errStr, "connection refused") ||
|
|
||||||
strings.Contains(errStr, "eof") ||
|
|
||||||
strings.Contains(errStr, "status 5") ||
|
|
||||||
strings.Contains(errStr, "status 429") ||
|
|
||||||
strings.Contains(errStr, "http 429")
|
|
||||||
|
|
||||||
if !isRetryable {
|
|
||||||
return "", "", "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[Amazon] Attempt %d failed (retryable): %v\n", attempt+1, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", "", "", fmt.Errorf("all %d attempts failed: %w", amazonMaxRetries+1, lastErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeAmazonASIN(candidate string) string {
|
|
||||||
trimmed := strings.TrimSpace(candidate)
|
|
||||||
if trimmed == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if decoded, err := url.QueryUnescape(trimmed); err == nil {
|
|
||||||
trimmed = decoded
|
|
||||||
}
|
|
||||||
|
|
||||||
trimmed = strings.ToUpper(trimmed)
|
|
||||||
if idx := strings.IndexAny(trimmed, "?#&/"); idx >= 0 {
|
|
||||||
trimmed = trimmed[:idx]
|
|
||||||
}
|
|
||||||
|
|
||||||
if amazonASINRegex.MatchString(trimmed) {
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractAmazonASIN(amazonURL string) string {
|
|
||||||
raw := strings.TrimSpace(amazonURL)
|
|
||||||
if raw == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed, err := url.Parse(raw)
|
|
||||||
if err == nil {
|
|
||||||
query := parsed.Query()
|
|
||||||
|
|
||||||
// Prefer track-level ASIN when URL also contains albumAsin.
|
|
||||||
for _, key := range []string{"trackAsin", "trackasin", "trackASIN", "asin", "ASIN", "i"} {
|
|
||||||
if asin := normalizeAmazonASIN(query.Get(key)); asin != "" {
|
|
||||||
return asin
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
path := strings.Trim(parsed.Path, "/")
|
|
||||||
if path != "" {
|
|
||||||
segments := strings.Split(path, "/")
|
|
||||||
|
|
||||||
for i := 0; i < len(segments)-1; i++ {
|
|
||||||
segment := strings.ToLower(strings.TrimSpace(segments[i]))
|
|
||||||
if segment == "track" || segment == "tracks" {
|
|
||||||
if asin := normalizeAmazonASIN(segments[i+1]); asin != "" {
|
|
||||||
return asin
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if asin := normalizeAmazonASIN(segments[len(segments)-1]); asin != "" {
|
|
||||||
return asin
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match := amazonASINFindRegex.FindString(strings.ToUpper(raw))
|
|
||||||
return normalizeAmazonASIN(match)
|
|
||||||
}
|
|
||||||
|
|
||||||
// doAfkarXYZRequest performs a single request to Amazon API.
|
|
||||||
// It tries new endpoint first, then falls back to legacy /convert endpoint.
|
|
||||||
func (a *AmazonDownloader) doAfkarXYZRequest(amazonURL string) (string, string, string, error) {
|
|
||||||
asin := extractAmazonASIN(amazonURL)
|
|
||||||
if asin != "" {
|
|
||||||
GoLog("[Amazon] Using ASIN: %s\n", asin)
|
|
||||||
downloadURL, fileName, decryptKey, err := a.doAfkarXYZRequestNew(asin)
|
|
||||||
if err == nil {
|
|
||||||
return downloadURL, fileName, decryptKey, nil
|
|
||||||
}
|
|
||||||
GoLog("[Amazon] New API failed for ASIN %s, trying legacy endpoint: %v\n", asin, err)
|
|
||||||
}
|
|
||||||
return a.doAfkarXYZRequestLegacy(amazonURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *AmazonDownloader) doAfkarXYZRequestNew(asin string) (string, string, string, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
apiURL := fmt.Sprintf("https://amzn.afkarxyz.fun/api/track/%s", asin)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
|
||||||
|
|
||||||
resp, err := a.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", fmt.Errorf("failed to call Amazon API: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, readErr := io.ReadAll(resp.Body)
|
|
||||||
if readErr != nil {
|
|
||||||
return "", "", "", fmt.Errorf("failed to read response: %w", readErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return "", "", "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
var apiResp AmazonStreamResponse
|
|
||||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
|
||||||
return "", "", "", fmt.Errorf("failed to decode response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.TrimSpace(apiResp.StreamURL) == "" {
|
|
||||||
return "", "", "", fmt.Errorf("Amazon API returned empty stream URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
fileName := asin + ".m4a"
|
|
||||||
return apiResp.StreamURL, fileName, strings.TrimSpace(apiResp.DecryptionKey), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *AmazonDownloader) doAfkarXYZRequestLegacy(amazonURL string) (string, string, string, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
apiURL := "https://amzn.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", fmt.Errorf("failed to create legacy request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
|
|
||||||
resp, err := a.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", fmt.Errorf("failed to call legacy AfkarXYZ API: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return "", "", "", fmt.Errorf("legacy AfkarXYZ API returned status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", fmt.Errorf("failed to read legacy response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var apiResp AfkarXYZResponse
|
|
||||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
|
||||||
return "", "", "", fmt.Errorf("failed to decode legacy response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !apiResp.Success || strings.TrimSpace(apiResp.Data.DirectLink) == "" {
|
|
||||||
return "", "", "", fmt.Errorf("legacy AfkarXYZ API failed or no download link found")
|
|
||||||
}
|
|
||||||
|
|
||||||
fileName := apiResp.Data.FileName
|
|
||||||
if fileName == "" {
|
|
||||||
fileName = "track.flac"
|
|
||||||
}
|
|
||||||
|
|
||||||
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
|
|
||||||
fileName = reg.ReplaceAllString(fileName, "")
|
|
||||||
|
|
||||||
return apiResp.Data.DirectLink, fileName, "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, string, error) {
|
|
||||||
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
|
|
||||||
|
|
||||||
downloadURL, fileName, decryptionKey, err := a.fetchAmazonURLWithRetry(amazonURL)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
if decryptionKey != "" {
|
|
||||||
GoLog("[Amazon] AfkarXYZ returned encrypted stream (decryption key available)\n")
|
|
||||||
}
|
|
||||||
GoLog("[Amazon] AfkarXYZ returned: %s\n", fileName)
|
|
||||||
return downloadURL, fileName, decryptionKey, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *AmazonDownloader) 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
|
|
||||||
resp, err := a.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
if isDownloadCancelled(itemID) {
|
|
||||||
return ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
return 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 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 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("[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AmazonDownloadResult contains download result with quality info
|
|
||||||
type AmazonDownloadResult struct {
|
|
||||||
FilePath string
|
|
||||||
BitDepth int
|
|
||||||
SampleRate int
|
|
||||||
Title string
|
|
||||||
Artist string
|
|
||||||
Album string
|
|
||||||
ReleaseDate string
|
|
||||||
TrackNumber int
|
|
||||||
DiscNumber int
|
|
||||||
ISRC string
|
|
||||||
LyricsLRC string
|
|
||||||
DecryptionKey string
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveAmazonURLForRequest(req DownloadRequest, logPrefix string) (string, error) {
|
|
||||||
if strings.TrimSpace(logPrefix) == "" {
|
|
||||||
logPrefix = "Amazon"
|
|
||||||
}
|
|
||||||
|
|
||||||
amazonURL := ""
|
|
||||||
if req.ISRC != "" {
|
|
||||||
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.AmazonURL != "" {
|
|
||||||
amazonURL = cached.AmazonURL
|
|
||||||
GoLog("[%s] Cache hit! Using cached Amazon URL for ISRC %s\n", logPrefix, req.ISRC)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if amazonURL != "" {
|
|
||||||
return amazonURL, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
songlink := NewSongLinkClient()
|
|
||||||
var availability *TrackAvailability
|
|
||||||
var err error
|
|
||||||
|
|
||||||
deezerID := strings.TrimSpace(req.DeezerID)
|
|
||||||
if prefixedDeezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found && strings.TrimSpace(prefixedDeezerID) != "" {
|
|
||||||
deezerID = strings.TrimSpace(prefixedDeezerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if deezerID != "" {
|
|
||||||
GoLog("[%s] Using Deezer ID for SongLink lookup: %s\n", logPrefix, deezerID)
|
|
||||||
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
|
||||||
} else if req.SpotifyID != "" {
|
|
||||||
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
|
||||||
} else {
|
|
||||||
return "", fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if availability == nil || !availability.Amazon || availability.AmazonURL == "" {
|
|
||||||
return "", fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
|
||||||
}
|
|
||||||
|
|
||||||
amazonURL = availability.AmazonURL
|
|
||||||
if req.ISRC != "" {
|
|
||||||
GetTrackIDCache().SetAmazonURL(req.ISRC, amazonURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
return amazonURL, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|
||||||
downloader := NewAmazonDownloader()
|
|
||||||
|
|
||||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
|
||||||
if !isSafOutput {
|
|
||||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
|
||||||
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
amazonURL, err := resolveAmazonURLForRequest(req, "Amazon")
|
|
||||||
if err != nil {
|
|
||||||
return AmazonDownloadResult{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isSafOutput && req.OutputDir != "." {
|
|
||||||
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
|
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download using AfkarXYZ API
|
|
||||||
downloadURL, afkarFileName, decryptionKey, err := downloader.downloadFromAfkarXYZ(amazonURL)
|
|
||||||
if err != nil {
|
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[Amazon] Match found: '%s' by '%s'\n", req.TrackName, req.ArtistName)
|
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
var outputPath string
|
|
||||||
if isSafOutput {
|
|
||||||
outputPath = strings.TrimSpace(req.OutputPath)
|
|
||||||
if outputPath == "" && isFDOutput(req.OutputFD) {
|
|
||||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
outputExt := strings.ToLower(filepath.Ext(afkarFileName))
|
|
||||||
if outputExt == "" {
|
|
||||||
outputExt = ".flac"
|
|
||||||
}
|
|
||||||
filename = sanitizeFilename(filename) + outputExt
|
|
||||||
outputPath = filepath.Join(req.OutputDir, filename)
|
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
|
||||||
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// START PARALLEL: Fetch cover and lyrics while downloading audio
|
|
||||||
var parallelResult *ParallelDownloadResult
|
|
||||||
parallelDone := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
defer close(parallelDone)
|
|
||||||
coverURL := req.CoverURL
|
|
||||||
embedLyrics := req.EmbedLyrics
|
|
||||||
if !req.EmbedMetadata {
|
|
||||||
coverURL = ""
|
|
||||||
embedLyrics = false
|
|
||||||
}
|
|
||||||
parallelResult = FetchCoverAndLyricsParallel(
|
|
||||||
coverURL,
|
|
||||||
req.EmbedMaxQualityCover,
|
|
||||||
req.SpotifyID,
|
|
||||||
req.TrackName,
|
|
||||||
req.ArtistName,
|
|
||||||
embedLyrics,
|
|
||||||
int64(req.DurationMS),
|
|
||||||
)
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Download audio file with item ID for progress tracking
|
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
|
||||||
if errors.Is(err, ErrDownloadCancelled) {
|
|
||||||
return AmazonDownloadResult{}, ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
actualOutputPath := outputPath
|
|
||||||
needsDecryption := strings.TrimSpace(decryptionKey) != ""
|
|
||||||
if needsDecryption {
|
|
||||||
GoLog("[Amazon] Download requires decryption; deferring decrypt to Flutter FFmpeg path\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for parallel operations to complete
|
|
||||||
<-parallelDone
|
|
||||||
|
|
||||||
if req.ItemID != "" {
|
|
||||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
|
||||||
SetItemFinalizing(req.ItemID)
|
|
||||||
}
|
|
||||||
|
|
||||||
actualTrackNum := req.TrackNumber
|
|
||||||
actualDiscNum := req.DiscNumber
|
|
||||||
actualDate := req.ReleaseDate
|
|
||||||
actualAlbum := req.AlbumName
|
|
||||||
actualTitle := req.TrackName
|
|
||||||
actualArtist := req.ArtistName
|
|
||||||
|
|
||||||
if !needsDecryption {
|
|
||||||
existingMeta, metaErr := ReadMetadata(actualOutputPath)
|
|
||||||
if metaErr == nil && existingMeta != nil {
|
|
||||||
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
|
||||||
actualTrackNum = existingMeta.TrackNumber
|
|
||||||
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
|
|
||||||
}
|
|
||||||
if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) {
|
|
||||||
actualDiscNum = existingMeta.DiscNumber
|
|
||||||
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
|
|
||||||
}
|
|
||||||
if existingMeta.Date != "" && req.ReleaseDate == "" {
|
|
||||||
actualDate = existingMeta.Date
|
|
||||||
GoLog("[Amazon] Using release date from file: %s\n", actualDate)
|
|
||||||
}
|
|
||||||
if existingMeta.Album != "" && req.AlbumName == "" {
|
|
||||||
actualAlbum = existingMeta.Album
|
|
||||||
GoLog("[Amazon] Using album from file: %s\n", actualAlbum)
|
|
||||||
}
|
|
||||||
GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n",
|
|
||||||
existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata := Metadata{
|
|
||||||
Title: actualTitle,
|
|
||||||
Artist: actualArtist,
|
|
||||||
Album: actualAlbum,
|
|
||||||
AlbumArtist: req.AlbumArtist,
|
|
||||||
Date: actualDate,
|
|
||||||
TrackNumber: actualTrackNum,
|
|
||||||
TotalTracks: req.TotalTracks,
|
|
||||||
DiscNumber: actualDiscNum,
|
|
||||||
ISRC: req.ISRC,
|
|
||||||
Genre: req.Genre,
|
|
||||||
Label: req.Label,
|
|
||||||
Copyright: req.Copyright,
|
|
||||||
}
|
|
||||||
|
|
||||||
var coverData []byte
|
|
||||||
if parallelResult != nil && parallelResult.CoverData != nil && len(parallelResult.CoverData) > 0 {
|
|
||||||
coverData = parallelResult.CoverData
|
|
||||||
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
|
||||||
} else {
|
|
||||||
existingCover, coverErr := ExtractCoverArt(actualOutputPath)
|
|
||||||
if coverErr == nil && len(existingCover) > 0 {
|
|
||||||
coverData = existingCover
|
|
||||||
GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData))
|
|
||||||
} else {
|
|
||||||
GoLog("[Amazon] No cover available (parallel fetch failed and no existing cover)\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if isSafOutput || needsDecryption || !req.EmbedMetadata {
|
|
||||||
if !req.EmbedMetadata {
|
|
||||||
GoLog("[Amazon] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n")
|
|
||||||
} else {
|
|
||||||
GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
isFlacOutput := strings.HasSuffix(strings.ToLower(actualOutputPath), ".flac")
|
|
||||||
if isFlacOutput {
|
|
||||||
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
|
|
||||||
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
GoLog("[Amazon] Non-FLAC output detected (%s), skipping native metadata embedding\n", filepath.Ext(actualOutputPath))
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
|
||||||
lyricsMode := req.LyricsMode
|
|
||||||
if lyricsMode == "" {
|
|
||||||
lyricsMode = "embed"
|
|
||||||
}
|
|
||||||
|
|
||||||
if lyricsMode == "external" || lyricsMode == "both" {
|
|
||||||
GoLog("[Amazon] Saving external LRC file...\n")
|
|
||||||
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
|
||||||
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
|
|
||||||
} else {
|
|
||||||
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lyricsMode == "embed" || lyricsMode == "both") && isFlacOutput {
|
|
||||||
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
|
||||||
if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil {
|
|
||||||
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
|
||||||
} else {
|
|
||||||
GoLog("[Amazon] Lyrics embedded successfully\n")
|
|
||||||
}
|
|
||||||
} else if (lyricsMode == "embed" || lyricsMode == "both") && !isFlacOutput {
|
|
||||||
GoLog("[Amazon] Skipping embedded lyrics for non-FLAC output\n")
|
|
||||||
}
|
|
||||||
} else if req.EmbedLyrics {
|
|
||||||
GoLog("[Amazon] No lyrics available from parallel fetch\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[Amazon] Downloaded successfully from Amazon Music\n")
|
|
||||||
|
|
||||||
quality := AudioQuality{}
|
|
||||||
if isSafOutput || needsDecryption {
|
|
||||||
GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n")
|
|
||||||
} else {
|
|
||||||
quality, err = GetAudioQuality(actualOutputPath)
|
|
||||||
if err != nil {
|
|
||||||
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
|
||||||
} else {
|
|
||||||
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
|
||||||
}
|
|
||||||
|
|
||||||
finalMeta, metaReadErr := ReadMetadata(actualOutputPath)
|
|
||||||
if metaReadErr == nil && finalMeta != nil {
|
|
||||||
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
|
||||||
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
|
|
||||||
actualTrackNum = finalMeta.TrackNumber
|
|
||||||
actualDiscNum = finalMeta.DiscNumber
|
|
||||||
if finalMeta.Date != "" {
|
|
||||||
req.ReleaseDate = finalMeta.Date
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to ISRC index for fast duplicate checking.
|
|
||||||
// When decryption is pending in Flutter, postpone indexing until final file is settled.
|
|
||||||
if !isSafOutput && !needsDecryption {
|
|
||||||
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
bitDepth := 0
|
|
||||||
sampleRate := 0
|
|
||||||
if err == nil {
|
|
||||||
bitDepth = quality.BitDepth
|
|
||||||
sampleRate = quality.SampleRate
|
|
||||||
}
|
|
||||||
|
|
||||||
lyricsLRC := ""
|
|
||||||
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
|
||||||
lyricsLRC = parallelResult.LyricsLRC
|
|
||||||
}
|
|
||||||
|
|
||||||
return AmazonDownloadResult{
|
|
||||||
FilePath: outputPath,
|
|
||||||
BitDepth: bitDepth,
|
|
||||||
SampleRate: sampleRate,
|
|
||||||
Title: req.TrackName,
|
|
||||||
Artist: req.ArtistName,
|
|
||||||
Album: req.AlbumName,
|
|
||||||
ReleaseDate: req.ReleaseDate,
|
|
||||||
TrackNumber: actualTrackNum,
|
|
||||||
DiscNumber: actualDiscNum,
|
|
||||||
ISRC: req.ISRC,
|
|
||||||
LyricsLRC: lyricsLRC,
|
|
||||||
DecryptionKey: decryptionKey,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestExtractAmazonASIN(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
url string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "prefers trackAsin over albumAsin",
|
|
||||||
url: "https://music.amazon.com/albums/B0ALBUM123?trackAsin=B0TRACK456&musicTerritory=US",
|
|
||||||
want: "B0TRACK456",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "extract from tracks path",
|
|
||||||
url: "https://music.amazon.com/tracks/B0CYQHGWZJ?musicTerritory=US",
|
|
||||||
want: "B0CYQHGWZJ",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "extract from plain query asin",
|
|
||||||
url: "https://example.com/?asin=B0CYQHGWZJ",
|
|
||||||
want: "B0CYQHGWZJ",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "fallback regex",
|
|
||||||
url: "https://example.com/path/B0CYQHGWZJ",
|
|
||||||
want: "B0CYQHGWZJ",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid url",
|
|
||||||
url: "https://music.amazon.com/tracks/not-valid",
|
|
||||||
want: "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := extractAmazonASIN(tt.url)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Fatalf("extractAmazonASIN() = %q, want %q", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AudioMetadata represents common audio file metadata
|
|
||||||
type AudioMetadata struct {
|
type AudioMetadata struct {
|
||||||
Title string
|
Title string
|
||||||
Artist string
|
Artist string
|
||||||
@@ -31,7 +30,6 @@ type AudioMetadata struct {
|
|||||||
Comment string
|
Comment string
|
||||||
}
|
}
|
||||||
|
|
||||||
// MP3Quality represents MP3 specific quality info
|
|
||||||
type MP3Quality struct {
|
type MP3Quality struct {
|
||||||
SampleRate int
|
SampleRate int
|
||||||
BitDepth int
|
BitDepth int
|
||||||
@@ -39,7 +37,6 @@ type MP3Quality struct {
|
|||||||
Bitrate int
|
Bitrate int
|
||||||
}
|
}
|
||||||
|
|
||||||
// OggQuality represents Ogg/Opus specific quality info
|
|
||||||
type OggQuality struct {
|
type OggQuality struct {
|
||||||
SampleRate int
|
SampleRate int
|
||||||
BitDepth int
|
BitDepth int
|
||||||
@@ -47,10 +44,6 @@ type OggQuality struct {
|
|||||||
Bitrate int // estimated bitrate in bps
|
Bitrate int // estimated bitrate in bps
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// ID3 Tag Reading (MP3)
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
func ReadID3Tags(filePath string) (*AudioMetadata, error) {
|
func ReadID3Tags(filePath string) (*AudioMetadata, error) {
|
||||||
file, err := os.Open(filePath)
|
file, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1210,10 +1203,6 @@ func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// ID3v1 Genre List
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
var id3v1Genres = []string{
|
var id3v1Genres = []string{
|
||||||
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge",
|
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge",
|
||||||
"Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B",
|
"Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B",
|
||||||
@@ -1244,10 +1233,6 @@ var id3v1Genres = []string{
|
|||||||
"Thrash Metal", "Anime", "J-Pop", "Synthpop",
|
"Thrash Metal", "Anime", "J-Pop", "Synthpop",
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Cover Art Extraction
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
func extractMP3CoverArt(filePath string) ([]byte, string, error) {
|
func extractMP3CoverArt(filePath string) ([]byte, string, error) {
|
||||||
file, err := os.Open(filePath)
|
file, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1581,7 +1566,14 @@ func base64StdDecode(dst, src []byte) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func extractAnyCoverArt(filePath string) ([]byte, string, error) {
|
func extractAnyCoverArt(filePath string) ([]byte, string, error) {
|
||||||
|
return extractAnyCoverArtWithHint(filePath, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, string, error) {
|
||||||
ext := strings.ToLower(filepath.Ext(filePath))
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
if ext == "" {
|
||||||
|
ext = strings.ToLower(filepath.Ext(displayNameHint))
|
||||||
|
}
|
||||||
|
|
||||||
switch ext {
|
switch ext {
|
||||||
case ".flac":
|
case ".flac":
|
||||||
@@ -1610,6 +1602,10 @@ func extractAnyCoverArt(filePath string) ([]byte, string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SaveCoverToCache(filePath, cacheDir string) (string, error) {
|
func SaveCoverToCache(filePath, cacheDir string) (string, error) {
|
||||||
|
return SaveCoverToCacheWithHint(filePath, "", cacheDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveCoverToCacheWithHint(filePath, displayNameHint, cacheDir string) (string, error) {
|
||||||
cacheKey := filePath
|
cacheKey := filePath
|
||||||
if stat, err := os.Stat(filePath); err == nil {
|
if stat, err := os.Stat(filePath); err == nil {
|
||||||
cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano())
|
cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano())
|
||||||
@@ -1626,7 +1622,7 @@ func SaveCoverToCache(filePath, cacheDir string) (string, error) {
|
|||||||
return pngPath, nil
|
return pngPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
imageData, mimeType, err := extractAnyCoverArt(filePath)
|
imageData, mimeType, err := extractAnyCoverArtWithHint(filePath, displayNameHint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,6 @@ func upgradeDeezerCover(coverURL string) string {
|
|||||||
return coverURL
|
return coverURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace any size pattern with 1800x1800
|
|
||||||
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
|
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
|
||||||
if upgraded != coverURL {
|
if upgraded != coverURL {
|
||||||
GoLog("[Cover] Deezer: upgraded to 1800x1800")
|
GoLog("[Cover] Deezer: upgraded to 1800x1800")
|
||||||
|
|||||||
@@ -0,0 +1,590 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CueSheet represents a parsed .cue file
|
||||||
|
type CueSheet struct {
|
||||||
|
// Album-level metadata
|
||||||
|
Performer string `json:"performer"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
FileName string `json:"file_name"`
|
||||||
|
FileType string `json:"file_type"` // WAVE, FLAC, MP3, AIFF, etc.
|
||||||
|
Genre string `json:"genre,omitempty"`
|
||||||
|
Date string `json:"date,omitempty"`
|
||||||
|
Comment string `json:"comment,omitempty"`
|
||||||
|
Composer string `json:"composer,omitempty"`
|
||||||
|
Tracks []CueTrack `json:"tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CueTrack represents a single track in a cue sheet
|
||||||
|
type CueTrack struct {
|
||||||
|
Number int `json:"number"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Performer string `json:"performer"`
|
||||||
|
ISRC string `json:"isrc,omitempty"`
|
||||||
|
Composer string `json:"composer,omitempty"`
|
||||||
|
// Index positions in seconds (fractional)
|
||||||
|
StartTime float64 `json:"start_time"` // INDEX 01 in seconds
|
||||||
|
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 {
|
||||||
|
CuePath string `json:"cue_path"`
|
||||||
|
AudioPath string `json:"audio_path"`
|
||||||
|
Album string `json:"album"`
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
Genre string `json:"genre,omitempty"`
|
||||||
|
Date string `json:"date,omitempty"`
|
||||||
|
Tracks []CueSplitTrack `json:"tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CueSplitTrack has the FFmpeg split parameters for a single track
|
||||||
|
type CueSplitTrack struct {
|
||||||
|
Number int `json:"number"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
ISRC string `json:"isrc,omitempty"`
|
||||||
|
Composer string `json:"composer,omitempty"`
|
||||||
|
StartSec float64 `json:"start_sec"`
|
||||||
|
EndSec float64 `json:"end_sec"` // -1 means until end of file
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
reRemCommand = regexp.MustCompile(`^REM\s+(\S+)\s+(.+)$`)
|
||||||
|
reQuoted = regexp.MustCompile(`"([^"]*)"`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseCueFile parses a .cue file and returns a CueSheet
|
||||||
|
func ParseCueFile(cuePath string) (*CueSheet, error) {
|
||||||
|
f, err := os.Open(cuePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open cue file: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
sheet := &CueSheet{}
|
||||||
|
var currentTrack *CueTrack
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle BOM at start of file
|
||||||
|
if strings.HasPrefix(line, "\xef\xbb\xbf") {
|
||||||
|
line = strings.TrimPrefix(line, "\xef\xbb\xbf")
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
upper := strings.ToUpper(line)
|
||||||
|
|
||||||
|
// REM commands (album-level metadata)
|
||||||
|
if strings.HasPrefix(upper, "REM ") {
|
||||||
|
matches := reRemCommand.FindStringSubmatch(line)
|
||||||
|
if len(matches) == 3 {
|
||||||
|
key := strings.ToUpper(matches[1])
|
||||||
|
value := unquoteCue(matches[2])
|
||||||
|
switch key {
|
||||||
|
case "GENRE":
|
||||||
|
sheet.Genre = value
|
||||||
|
case "DATE":
|
||||||
|
sheet.Date = value
|
||||||
|
case "COMMENT":
|
||||||
|
sheet.Comment = value
|
||||||
|
case "COMPOSER":
|
||||||
|
if currentTrack != nil {
|
||||||
|
currentTrack.Composer = value
|
||||||
|
} else {
|
||||||
|
sheet.Composer = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(upper, "PERFORMER ") {
|
||||||
|
value := unquoteCue(line[len("PERFORMER "):])
|
||||||
|
if currentTrack != nil {
|
||||||
|
currentTrack.Performer = value
|
||||||
|
} else {
|
||||||
|
sheet.Performer = value
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(upper, "TITLE ") {
|
||||||
|
value := unquoteCue(line[len("TITLE "):])
|
||||||
|
if currentTrack != nil {
|
||||||
|
currentTrack.Title = value
|
||||||
|
} else {
|
||||||
|
sheet.Title = value
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(upper, "FILE ") {
|
||||||
|
rest := line[len("FILE "):]
|
||||||
|
// Extract filename and type
|
||||||
|
// Format: FILE "filename.flac" WAVE
|
||||||
|
// or: FILE filename.flac WAVE
|
||||||
|
fname, ftype := parseCueFileLine(rest)
|
||||||
|
sheet.FileName = fname
|
||||||
|
sheet.FileType = ftype
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(upper, "TRACK ") {
|
||||||
|
// Save previous track
|
||||||
|
if currentTrack != nil {
|
||||||
|
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
trackNum := 0
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
trackNum, _ = strconv.Atoi(parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTrack = &CueTrack{
|
||||||
|
Number: trackNum,
|
||||||
|
PreGap: -1,
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(upper, "INDEX ") && currentTrack != nil {
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
indexNum, _ := strconv.Atoi(parts[1])
|
||||||
|
timeSec := parseCueTimestamp(parts[2])
|
||||||
|
switch indexNum {
|
||||||
|
case 0:
|
||||||
|
currentTrack.PreGap = timeSec
|
||||||
|
case 1:
|
||||||
|
currentTrack.StartTime = timeSec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(upper, "ISRC ") && currentTrack != nil {
|
||||||
|
currentTrack.ISRC = strings.TrimSpace(line[len("ISRC "):])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// SONGWRITER (used as composer sometimes)
|
||||||
|
if strings.HasPrefix(upper, "SONGWRITER ") {
|
||||||
|
value := unquoteCue(line[len("SONGWRITER "):])
|
||||||
|
if currentTrack != nil {
|
||||||
|
currentTrack.Composer = value
|
||||||
|
} else {
|
||||||
|
sheet.Composer = value
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't forget the last track
|
||||||
|
if currentTrack != nil {
|
||||||
|
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("error reading cue file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sheet.Tracks) == 0 {
|
||||||
|
return nil, fmt.Errorf("no tracks found in cue file")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sheet, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCueTimestamp converts MM:SS:FF (frames at 75fps) to seconds
|
||||||
|
func parseCueTimestamp(ts string) float64 {
|
||||||
|
parts := strings.Split(ts, ":")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
minutes, _ := strconv.Atoi(parts[0])
|
||||||
|
seconds, _ := strconv.Atoi(parts[1])
|
||||||
|
frames, _ := strconv.Atoi(parts[2])
|
||||||
|
|
||||||
|
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 {
|
||||||
|
if seconds < 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
hours := int(seconds) / 3600
|
||||||
|
mins := (int(seconds) % 3600) / 60
|
||||||
|
secs := seconds - float64(hours*3600) - float64(mins*60)
|
||||||
|
return fmt.Sprintf("%02d:%02d:%06.3f", hours, mins, secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// unquoteCue removes surrounding quotes from a CUE value
|
||||||
|
func unquoteCue(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if matches := reQuoted.FindStringSubmatch(s); len(matches) == 2 {
|
||||||
|
return matches[1]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCueFileLine parses the FILE command's filename and type
|
||||||
|
func parseCueFileLine(rest string) (string, string) {
|
||||||
|
rest = strings.TrimSpace(rest)
|
||||||
|
|
||||||
|
var filename, ftype string
|
||||||
|
|
||||||
|
if strings.HasPrefix(rest, "\"") {
|
||||||
|
// Quoted filename
|
||||||
|
endQuote := strings.Index(rest[1:], "\"")
|
||||||
|
if endQuote >= 0 {
|
||||||
|
filename = rest[1 : endQuote+1]
|
||||||
|
remaining := strings.TrimSpace(rest[endQuote+2:])
|
||||||
|
ftype = remaining
|
||||||
|
} else {
|
||||||
|
filename = rest
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Unquoted filename - last word is the type
|
||||||
|
parts := strings.Fields(rest)
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
ftype = parts[len(parts)-1]
|
||||||
|
filename = strings.Join(parts[:len(parts)-1], " ")
|
||||||
|
} else if len(parts) == 1 {
|
||||||
|
filename = parts[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
cueDir := filepath.Dir(cuePath)
|
||||||
|
|
||||||
|
// 1. Try the exact filename from the .cue
|
||||||
|
candidate := filepath.Join(cueDir, cueFileName)
|
||||||
|
if _, err := os.Stat(candidate); err == nil {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try common case variations
|
||||||
|
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
|
||||||
|
commonExts := []string{".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
|
||||||
|
for _, ext := range commonExts {
|
||||||
|
candidate = filepath.Join(cueDir, baseName+ext)
|
||||||
|
if _, err := os.Stat(candidate); err == nil {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
// Try uppercase ext
|
||||||
|
candidate = filepath.Join(cueDir, baseName+strings.ToUpper(ext))
|
||||||
|
if _, err := os.Stat(candidate); err == nil {
|
||||||
|
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))
|
||||||
|
for _, ext := range commonExts {
|
||||||
|
candidate = filepath.Join(cueDir, cueBase+ext)
|
||||||
|
if _, err := os.Stat(candidate); err == nil {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. If there's only one audio file in the directory, use that
|
||||||
|
entries, err := os.ReadDir(cueDir)
|
||||||
|
if err == nil {
|
||||||
|
audioExts := map[string]bool{
|
||||||
|
".flac": true, ".wav": true, ".ape": true, ".mp3": true,
|
||||||
|
".ogg": true, ".wv": true, ".m4a": true, ".aiff": true,
|
||||||
|
}
|
||||||
|
var audioFiles []string
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(filepath.Ext(entry.Name()))
|
||||||
|
if audioExts[ext] {
|
||||||
|
audioFiles = append(audioFiles, filepath.Join(cueDir, entry.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(audioFiles) == 1 {
|
||||||
|
return audioFiles[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
resolveDir := cuePath
|
||||||
|
if audioDir != "" {
|
||||||
|
// Create a virtual path in audioDir so ResolveCueAudioPath looks there
|
||||||
|
resolveDir = filepath.Join(audioDir, filepath.Base(cuePath))
|
||||||
|
}
|
||||||
|
audioPath := ResolveCueAudioPath(resolveDir, sheet.FileName)
|
||||||
|
if audioPath == "" {
|
||||||
|
return nil, fmt.Errorf("audio file not found for cue sheet: %s (referenced: %s)", cuePath, sheet.FileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
info := &CueSplitInfo{
|
||||||
|
CuePath: cuePath,
|
||||||
|
AudioPath: audioPath,
|
||||||
|
Album: sheet.Title,
|
||||||
|
Artist: sheet.Performer,
|
||||||
|
Genre: sheet.Genre,
|
||||||
|
Date: sheet.Date,
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, track := range sheet.Tracks {
|
||||||
|
performer := track.Performer
|
||||||
|
if performer == "" {
|
||||||
|
performer = sheet.Performer
|
||||||
|
}
|
||||||
|
|
||||||
|
composer := track.Composer
|
||||||
|
if composer == "" {
|
||||||
|
composer = sheet.Composer
|
||||||
|
}
|
||||||
|
|
||||||
|
// End time is the start of the next track, or -1 for the last track
|
||||||
|
endSec := float64(-1)
|
||||||
|
if i+1 < len(sheet.Tracks) {
|
||||||
|
nextTrack := sheet.Tracks[i+1]
|
||||||
|
// Use pre-gap of next track if available, otherwise its start time
|
||||||
|
if nextTrack.PreGap >= 0 {
|
||||||
|
endSec = nextTrack.PreGap
|
||||||
|
} else {
|
||||||
|
endSec = nextTrack.StartTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Tracks = append(info.Tracks, CueSplitTrack{
|
||||||
|
Number: track.Number,
|
||||||
|
Title: track.Title,
|
||||||
|
Artist: performer,
|
||||||
|
ISRC: track.ISRC,
|
||||||
|
Composer: composer,
|
||||||
|
StartSec: track.StartTime,
|
||||||
|
EndSec: endSec,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
sheet, err := ParseCueFile(cuePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse cue file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := BuildCueSplitInfo(cuePath, sheet, audioDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(info)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to marshal cue split info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
sheet, err := ParseCueFile(cuePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
audioPath, err := resolveCueAudioPathForLibrary(cuePath, sheet, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return scanCueSheetForLibrary(cuePath, sheet, audioPath, "", 0, scanTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanCueFileForLibraryExt is like ScanCueFileForLibrary but with extra parameters
|
||||||
|
// 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) {
|
||||||
|
sheet, err := ParseCueFile(cuePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
audioPath, err := resolveCueAudioPathForLibrary(cuePath, sheet, audioDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return scanCueSheetForLibrary(cuePath, sheet, audioPath, virtualPathPrefix, fileModTime, scanTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveCueAudioPathForLibrary(cuePath string, sheet *CueSheet, audioDir string) (string, error) {
|
||||||
|
if sheet == nil {
|
||||||
|
return "", fmt.Errorf("cue sheet is nil for %s", cuePath)
|
||||||
|
}
|
||||||
|
resolveBase := cuePath
|
||||||
|
if audioDir != "" {
|
||||||
|
resolveBase = filepath.Join(audioDir, filepath.Base(cuePath))
|
||||||
|
}
|
||||||
|
audioPath := ResolveCueAudioPath(resolveBase, sheet.FileName)
|
||||||
|
if audioPath == "" {
|
||||||
|
return "", fmt.Errorf("audio file not found for cue: %s (referenced: %s)", cuePath, sheet.FileName)
|
||||||
|
}
|
||||||
|
return audioPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
|
||||||
|
if sheet == nil {
|
||||||
|
return nil, fmt.Errorf("cue sheet is nil for %s", cuePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get quality info from the audio file
|
||||||
|
var bitDepth, sampleRate int
|
||||||
|
var totalDurationSec float64
|
||||||
|
audioExt := strings.ToLower(filepath.Ext(audioPath))
|
||||||
|
switch audioExt {
|
||||||
|
case ".flac":
|
||||||
|
quality, qErr := GetAudioQuality(audioPath)
|
||||||
|
if qErr == nil {
|
||||||
|
bitDepth = quality.BitDepth
|
||||||
|
sampleRate = quality.SampleRate
|
||||||
|
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
||||||
|
totalDurationSec = float64(quality.TotalSamples) / float64(quality.SampleRate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case ".mp3":
|
||||||
|
quality, qErr := GetMP3Quality(audioPath)
|
||||||
|
if qErr == nil {
|
||||||
|
sampleRate = quality.SampleRate
|
||||||
|
totalDurationSec = float64(quality.Duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract cover from audio file for all tracks
|
||||||
|
var coverPath string
|
||||||
|
libraryCoverCacheMu.RLock()
|
||||||
|
coverCacheDir := libraryCoverCacheDir
|
||||||
|
libraryCoverCacheMu.RUnlock()
|
||||||
|
if coverCacheDir != "" {
|
||||||
|
cp, err := SaveCoverToCache(audioPath, coverCacheDir)
|
||||||
|
if err == nil && cp != "" {
|
||||||
|
coverPath = cp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the base path for virtual paths and IDs
|
||||||
|
pathBase := cuePath
|
||||||
|
if virtualPathPrefix != "" {
|
||||||
|
pathBase = virtualPathPrefix
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine fileModTime
|
||||||
|
modTime := fileModTime
|
||||||
|
if modTime <= 0 {
|
||||||
|
if info, err := os.Stat(cuePath); err == nil {
|
||||||
|
modTime = info.ModTime().UnixMilli()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []LibraryScanResult
|
||||||
|
for i, track := range sheet.Tracks {
|
||||||
|
performer := track.Performer
|
||||||
|
if performer == "" {
|
||||||
|
performer = sheet.Performer
|
||||||
|
}
|
||||||
|
if performer == "" {
|
||||||
|
performer = "Unknown Artist"
|
||||||
|
}
|
||||||
|
|
||||||
|
title := track.Title
|
||||||
|
if title == "" {
|
||||||
|
title = fmt.Sprintf("Track %02d", track.Number)
|
||||||
|
}
|
||||||
|
|
||||||
|
album := sheet.Title
|
||||||
|
if album == "" {
|
||||||
|
album = "Unknown Album"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate duration for this track
|
||||||
|
var duration int
|
||||||
|
if i+1 < len(sheet.Tracks) {
|
||||||
|
nextStart := sheet.Tracks[i+1].StartTime
|
||||||
|
if sheet.Tracks[i+1].PreGap >= 0 {
|
||||||
|
nextStart = sheet.Tracks[i+1].PreGap
|
||||||
|
}
|
||||||
|
duration = int(nextStart - track.StartTime)
|
||||||
|
} else if totalDurationSec > 0 {
|
||||||
|
duration = int(totalDurationSec - track.StartTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
result := LibraryScanResult{
|
||||||
|
ID: id,
|
||||||
|
TrackName: title,
|
||||||
|
ArtistName: performer,
|
||||||
|
AlbumName: album,
|
||||||
|
AlbumArtist: sheet.Performer,
|
||||||
|
FilePath: virtualFilePath,
|
||||||
|
CoverPath: coverPath,
|
||||||
|
ScannedAt: scanTime,
|
||||||
|
ISRC: track.ISRC,
|
||||||
|
TrackNumber: track.Number,
|
||||||
|
DiscNumber: 1,
|
||||||
|
Duration: duration,
|
||||||
|
ReleaseDate: sheet.Date,
|
||||||
|
BitDepth: bitDepth,
|
||||||
|
SampleRate: sampleRate,
|
||||||
|
Genre: sheet.Genre,
|
||||||
|
Format: "cue+" + strings.TrimPrefix(audioExt, "."),
|
||||||
|
}
|
||||||
|
|
||||||
|
result.FileModTime = modTime
|
||||||
|
|
||||||
|
results = append(results, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
@@ -256,6 +256,7 @@ type deezerAlbumFull struct {
|
|||||||
NbTracks int `json:"nb_tracks"`
|
NbTracks int `json:"nb_tracks"`
|
||||||
RecordType string `json:"record_type"`
|
RecordType string `json:"record_type"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
|
Copyright string `json:"copyright"`
|
||||||
Genres struct {
|
Genres struct {
|
||||||
Data []deezerGenre `json:"data"`
|
Data []deezerGenre `json:"data"`
|
||||||
} `json:"genres"`
|
} `json:"genres"`
|
||||||
@@ -1084,8 +1085,9 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AlbumExtendedMetadata struct {
|
type AlbumExtendedMetadata struct {
|
||||||
Genre string
|
Genre string
|
||||||
Label string
|
Label string
|
||||||
|
Copyright string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
|
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
|
||||||
@@ -1116,8 +1118,9 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
|
|||||||
}
|
}
|
||||||
|
|
||||||
result := &AlbumExtendedMetadata{
|
result := &AlbumExtendedMetadata{
|
||||||
Genre: strings.Join(genres, ", "),
|
Genre: strings.Join(genres, ", "),
|
||||||
Label: album.Label,
|
Label: album.Label,
|
||||||
|
Copyright: album.Copyright,
|
||||||
}
|
}
|
||||||
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
@@ -1129,7 +1132,7 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
|
|||||||
c.maybeCleanupCachesLocked(now)
|
c.maybeCleanupCachesLocked(now)
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
|
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s, Copyright: %s\n", result.Genre, result.Label, result.Copyright)
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+264
-10
@@ -15,6 +15,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const deezerYoinkifyURL = "https://yoinkify.lol/api/download"
|
const deezerYoinkifyURL = "https://yoinkify.lol/api/download"
|
||||||
|
const deezerMusicDLURL = "https://www.musicdl.me/api/download"
|
||||||
|
|
||||||
type YoinkifyRequest struct {
|
type YoinkifyRequest struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
@@ -119,7 +120,7 @@ func (c *DeezerClient) DownloadFromYoinkify(spotifyURL, outputPath string, outpu
|
|||||||
req.Header.Set("Accept", "*/*")
|
req.Header.Set("Accept", "*/*")
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := GetDownloadClient().Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isDownloadCancelled(itemID) {
|
if isDownloadCancelled(itemID) {
|
||||||
return ErrDownloadCancelled
|
return ErrDownloadCancelled
|
||||||
@@ -194,6 +195,234 @@ func (c *DeezerClient) DownloadFromYoinkify(spotifyURL, outputPath string, outpu
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
||||||
|
deezerID := strings.TrimSpace(req.DeezerID)
|
||||||
|
if deezerID == "" {
|
||||||
|
if prefixed, found := strings.CutPrefix(strings.TrimSpace(req.SpotifyID), "deezer:"); found {
|
||||||
|
deezerID = strings.TrimSpace(prefixed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if deezerID != "" {
|
||||||
|
trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID)
|
||||||
|
if err := verifyDeezerTrack(req, deezerID); err != nil {
|
||||||
|
GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err)
|
||||||
|
// Don't reject direct IDs from request payload — they're presumably correct.
|
||||||
|
}
|
||||||
|
return trackURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try SongLink
|
||||||
|
spotifyID := strings.TrimSpace(req.SpotifyID)
|
||||||
|
if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) {
|
||||||
|
songlink := NewSongLinkClient()
|
||||||
|
availability, err := songlink.CheckTrackAvailability(spotifyID, "")
|
||||||
|
if err == nil && availability.Deezer && availability.DeezerURL != "" {
|
||||||
|
resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
|
||||||
|
if resolvedID != "" {
|
||||||
|
if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil {
|
||||||
|
GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr)
|
||||||
|
// Fall through to ISRC search instead of using wrong track.
|
||||||
|
} else {
|
||||||
|
return availability.DeezerURL, nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return availability.DeezerURL, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try ISRC
|
||||||
|
isrc := strings.TrimSpace(req.ISRC)
|
||||||
|
if isrc != "" {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
||||||
|
defer cancel()
|
||||||
|
track, err := GetDeezerClient().SearchByISRC(ctx, isrc)
|
||||||
|
if err == nil && track != nil {
|
||||||
|
resolvedID := songLinkExtractDeezerTrackID(track)
|
||||||
|
if resolvedID != "" {
|
||||||
|
if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil {
|
||||||
|
GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr)
|
||||||
|
return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("https://www.deezer.com/track/%s", resolvedID), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("could not resolve Deezer track URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyDeezerTrack(req DownloadRequest, deezerID string) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
||||||
|
defer cancel()
|
||||||
|
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil // Can't verify — don't block the download.
|
||||||
|
}
|
||||||
|
resolved := resolvedTrackInfo{
|
||||||
|
Title: trackResp.Track.Name,
|
||||||
|
ArtistName: trackResp.Track.Artists,
|
||||||
|
Duration: trackResp.Track.DurationMS / 1000,
|
||||||
|
}
|
||||||
|
if !trackMatchesRequest(req, resolved, "Deezer") {
|
||||||
|
return fmt.Errorf("expected '%s - %s', got '%s - %s'",
|
||||||
|
req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
|
||||||
|
}
|
||||||
|
GoLog("[Deezer] Track %s verified: '%s - %s' ✓\n", deezerID, resolved.ArtistName, resolved.Title)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type deezerMusicDLRequest struct {
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, error) {
|
||||||
|
payload := deezerMusicDLRequest{
|
||||||
|
Platform: "deezer",
|
||||||
|
URL: deezerTrackURL,
|
||||||
|
}
|
||||||
|
jsonData, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to encode MusicDL request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, deezerMusicDLURL, bytes.NewReader(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create MusicDL request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Debug-Key", getQobuzDebugKey())
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("MusicDL request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read MusicDL response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("MusicDL returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw map[string]any
|
||||||
|
if err := json.Unmarshal(body, &raw); err != nil {
|
||||||
|
return "", fmt.Errorf("invalid MusicDL JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" {
|
||||||
|
return "", fmt.Errorf("MusicDL error: %s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try various response fields for download URL
|
||||||
|
for _, key := range []string{"download_url", "url", "link"} {
|
||||||
|
if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||||
|
return strings.TrimSpace(urlVal), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if data, ok := raw["data"].(map[string]any); ok {
|
||||||
|
for _, key := range []string{"download_url", "url", "link"} {
|
||||||
|
if urlVal, ok := data[key].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||||
|
return strings.TrimSpace(urlVal), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("no download URL found in MusicDL response")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) DownloadFromMusicDL(deezerTrackURL, outputPath string, outputFD int, itemID string) error {
|
||||||
|
GoLog("[Deezer] Resolving download URL via MusicDL for: %s\n", deezerTrackURL)
|
||||||
|
|
||||||
|
downloadURL, err := c.GetMusicDLDownloadURL(deezerTrackURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
GoLog("[Deezer] MusicDL returned download URL, starting download...\n")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if itemID != "" {
|
||||||
|
StartItemProgress(itemID)
|
||||||
|
defer CompleteItemProgress(itemID)
|
||||||
|
ctx = initDownloadCancel(itemID)
|
||||||
|
defer clearDownloadCancel(itemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create download request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
resp, err := GetDownloadClient().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
|
return fmt.Errorf("download request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedSize := resp.ContentLength
|
||||||
|
if expectedSize > 0 && itemID != "" {
|
||||||
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := openOutputForWrite(outputPath, outputFD)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||||
|
var written int64
|
||||||
|
if itemID != "" {
|
||||||
|
pw := NewItemProgressWriter(bufWriter, itemID)
|
||||||
|
written, err = io.Copy(pw, resp.Body)
|
||||||
|
} else {
|
||||||
|
written, err = io.Copy(bufWriter, resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
flushErr := bufWriter.Flush()
|
||||||
|
closeErr := out.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
|
return fmt.Errorf("download interrupted: %w", err)
|
||||||
|
}
|
||||||
|
if flushErr != nil {
|
||||||
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
|
return fmt.Errorf("failed to flush output: %w", flushErr)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
|
return fmt.Errorf("failed to close output: %w", closeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if expectedSize > 0 && written != expectedSize {
|
||||||
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
|
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Deezer] Downloaded via MusicDL: %.2f MB\n", float64(written)/(1024*1024))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
|
func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
|
||||||
deezerClient := GetDeezerClient()
|
deezerClient := GetDeezerClient()
|
||||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||||
@@ -204,11 +433,6 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
spotifyURL, err := resolveSpotifyURLForYoinkify(req)
|
|
||||||
if err != nil {
|
|
||||||
return DeezerDownloadResult{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
"artist": req.ArtistName,
|
"artist": req.ArtistName,
|
||||||
@@ -254,11 +478,41 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
|
|||||||
)
|
)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if err := deezerClient.DownloadFromYoinkify(spotifyURL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
// Try MusicDL first (better quality), fallback to Yoinkify
|
||||||
if errors.Is(err, ErrDownloadCancelled) {
|
var downloadErr error
|
||||||
return DeezerDownloadResult{}, ErrDownloadCancelled
|
deezerTrackURL, deezerURLErr := resolveDeezerTrackURL(req)
|
||||||
|
if deezerURLErr == nil {
|
||||||
|
GoLog("[Deezer] Trying MusicDL for: %s\n", deezerTrackURL)
|
||||||
|
downloadErr = deezerClient.DownloadFromMusicDL(deezerTrackURL, outputPath, req.OutputFD, req.ItemID)
|
||||||
|
if downloadErr != nil {
|
||||||
|
if errors.Is(downloadErr, ErrDownloadCancelled) {
|
||||||
|
return DeezerDownloadResult{}, ErrDownloadCancelled
|
||||||
|
}
|
||||||
|
GoLog("[Deezer] MusicDL failed: %v, falling back to Yoinkify\n", downloadErr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
GoLog("[Deezer] Could not resolve Deezer URL: %v, using Yoinkify directly\n", deezerURLErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if downloadErr != nil || deezerURLErr != nil {
|
||||||
|
spotifyURL, err := resolveSpotifyURLForYoinkify(req)
|
||||||
|
if err != nil {
|
||||||
|
if deezerURLErr != nil {
|
||||||
|
return DeezerDownloadResult{}, fmt.Errorf(
|
||||||
|
"deezer download failed: direct Deezer resolution error: %v; Yoinkify fallback error: %w",
|
||||||
|
deezerURLErr,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return DeezerDownloadResult{}, err
|
||||||
|
}
|
||||||
|
downloadErr = deezerClient.DownloadFromYoinkify(spotifyURL, outputPath, req.OutputFD, req.ItemID)
|
||||||
|
if downloadErr != nil {
|
||||||
|
if errors.Is(downloadErr, ErrDownloadCancelled) {
|
||||||
|
return DeezerDownloadResult{}, ErrDownloadCancelled
|
||||||
|
}
|
||||||
|
return DeezerDownloadResult{}, fmt.Errorf("deezer download failed (MusicDL + Yoinkify): %w", downloadErr)
|
||||||
}
|
}
|
||||||
return DeezerDownloadResult{}, fmt.Errorf("deezer yoinkify failed: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<-parallelDone
|
<-parallelDone
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
|
|||||||
return idx
|
return idx
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slow path: need to build index
|
|
||||||
// Use per-directory mutex to prevent multiple goroutines from building simultaneously
|
// Use per-directory mutex to prevent multiple goroutines from building simultaneously
|
||||||
buildLock, _ := isrcBuildingMu.LoadOrStore(outputDir, &sync.Mutex{})
|
buildLock, _ := isrcBuildingMu.LoadOrStore(outputDir, &sync.Mutex{})
|
||||||
mu := buildLock.(*sync.Mutex)
|
mu := buildLock.(*sync.Mutex)
|
||||||
|
|||||||
+365
-335
@@ -32,126 +32,6 @@ func ParseSpotifyURL(url string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetSpotifyAPICredentials(clientID, clientSecret string) {
|
|
||||||
SetSpotifyCredentials(clientID, clientSecret)
|
|
||||||
}
|
|
||||||
|
|
||||||
func CheckSpotifyCredentials() bool {
|
|
||||||
return HasSpotifyCredentials()
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
client, err := NewSpotifyMetadataClient()
|
|
||||||
if err != nil {
|
|
||||||
if shouldTrySpotFetchFallback(err) {
|
|
||||||
data, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
|
|
||||||
if apiErr == nil {
|
|
||||||
jsonBytes, marshalErr := json.Marshal(data)
|
|
||||||
if marshalErr != nil {
|
|
||||||
return "", marshalErr
|
|
||||||
}
|
|
||||||
return string(jsonBytes), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
|
||||||
if err != nil {
|
|
||||||
if shouldTrySpotFetchFallback(err) {
|
|
||||||
fallbackData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
|
|
||||||
if apiErr == nil {
|
|
||||||
jsonBytes, marshalErr := json.Marshal(fallbackData)
|
|
||||||
if marshalErr != nil {
|
|
||||||
return "", marshalErr
|
|
||||||
}
|
|
||||||
return string(jsonBytes), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(data)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func SearchSpotify(query string, limit int) (string, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
client, err := NewSpotifyMetadataClient()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
results, err := client.SearchTracks(ctx, query, limit)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(results)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
client, err := NewSpotifyMetadataClient()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(results)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetSpotifyRelatedArtists(artistID string, limit int) (string, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
client, err := NewSpotifyMetadataClient()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizedArtistID := strings.TrimSpace(strings.TrimPrefix(artistID, "spotify:"))
|
|
||||||
if normalizedArtistID == "" {
|
|
||||||
return "", fmt.Errorf("invalid Spotify artist ID")
|
|
||||||
}
|
|
||||||
|
|
||||||
artists, err := client.GetRelatedArtists(ctx, normalizedArtistID, limit)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := map[string]interface{}{
|
|
||||||
"artists": artists,
|
|
||||||
}
|
|
||||||
jsonBytes, err := json.Marshal(resp)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(jsonBytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func CheckAvailability(spotifyID, isrc string) (string, error) {
|
func CheckAvailability(spotifyID, isrc string) (string, error) {
|
||||||
client := NewSongLinkClient()
|
client := NewSongLinkClient()
|
||||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||||
@@ -255,6 +135,36 @@ type DownloadResult struct {
|
|||||||
DecryptionKey string
|
DecryptionKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func preferredReleaseMetadata(
|
||||||
|
req DownloadRequest,
|
||||||
|
album string,
|
||||||
|
releaseDate string,
|
||||||
|
trackNumber int,
|
||||||
|
discNumber int,
|
||||||
|
) (string, string, int, int) {
|
||||||
|
preferredAlbum := strings.TrimSpace(req.AlbumName)
|
||||||
|
if preferredAlbum == "" {
|
||||||
|
preferredAlbum = album
|
||||||
|
}
|
||||||
|
|
||||||
|
preferredReleaseDate := strings.TrimSpace(req.ReleaseDate)
|
||||||
|
if preferredReleaseDate == "" {
|
||||||
|
preferredReleaseDate = releaseDate
|
||||||
|
}
|
||||||
|
|
||||||
|
preferredTrackNumber := req.TrackNumber
|
||||||
|
if preferredTrackNumber == 0 {
|
||||||
|
preferredTrackNumber = trackNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
preferredDiscNumber := req.DiscNumber
|
||||||
|
if preferredDiscNumber == 0 {
|
||||||
|
preferredDiscNumber = discNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
return preferredAlbum, preferredReleaseDate, preferredTrackNumber, preferredDiscNumber
|
||||||
|
}
|
||||||
|
|
||||||
func buildDownloadSuccessResponse(
|
func buildDownloadSuccessResponse(
|
||||||
req DownloadRequest,
|
req DownloadRequest,
|
||||||
result DownloadResult,
|
result DownloadResult,
|
||||||
@@ -273,25 +183,16 @@ func buildDownloadSuccessResponse(
|
|||||||
artist = req.ArtistName
|
artist = req.ArtistName
|
||||||
}
|
}
|
||||||
|
|
||||||
album := result.Album
|
// Preserve requested release metadata when available so mixed-provider
|
||||||
if album == "" {
|
// fallback downloads from the same source album do not get split into
|
||||||
album = req.AlbumName
|
// different albums just because Tidal/Qobuz report variant titles/dates.
|
||||||
}
|
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
|
||||||
|
req,
|
||||||
releaseDate := result.ReleaseDate
|
result.Album,
|
||||||
if releaseDate == "" {
|
result.ReleaseDate,
|
||||||
releaseDate = req.ReleaseDate
|
result.TrackNumber,
|
||||||
}
|
result.DiscNumber,
|
||||||
|
)
|
||||||
trackNumber := result.TrackNumber
|
|
||||||
if trackNumber == 0 {
|
|
||||||
trackNumber = req.TrackNumber
|
|
||||||
}
|
|
||||||
|
|
||||||
discNumber := result.DiscNumber
|
|
||||||
if discNumber == 0 {
|
|
||||||
discNumber = req.DiscNumber
|
|
||||||
}
|
|
||||||
|
|
||||||
isrc := result.ISRC
|
isrc := result.ISRC
|
||||||
if isrc == "" {
|
if isrc == "" {
|
||||||
@@ -382,7 +283,7 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.ISRC == "" || (req.Genre != "" && req.Label != "") {
|
if req.ISRC == "" || (req.Genre != "" && req.Label != "" && req.Copyright != "") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,8 +305,11 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) {
|
|||||||
if req.Label == "" && extMeta.Label != "" {
|
if req.Label == "" && extMeta.Label != "" {
|
||||||
req.Label = extMeta.Label
|
req.Label = extMeta.Label
|
||||||
}
|
}
|
||||||
if req.Genre != "" || req.Label != "" {
|
if req.Copyright == "" && extMeta.Copyright != "" {
|
||||||
GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s\n", req.Genre, req.Label)
|
req.Copyright = extMeta.Copyright
|
||||||
|
}
|
||||||
|
if req.Genre != "" || req.Label != "" || req.Copyright != "" {
|
||||||
|
GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,25 +382,6 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = qobuzErr
|
err = qobuzErr
|
||||||
case "amazon":
|
|
||||||
amazonResult, amazonErr := downloadFromAmazon(req)
|
|
||||||
if amazonErr == nil {
|
|
||||||
result = DownloadResult{
|
|
||||||
FilePath: amazonResult.FilePath,
|
|
||||||
BitDepth: amazonResult.BitDepth,
|
|
||||||
SampleRate: amazonResult.SampleRate,
|
|
||||||
Title: amazonResult.Title,
|
|
||||||
Artist: amazonResult.Artist,
|
|
||||||
Album: amazonResult.Album,
|
|
||||||
ReleaseDate: amazonResult.ReleaseDate,
|
|
||||||
TrackNumber: amazonResult.TrackNumber,
|
|
||||||
DiscNumber: amazonResult.DiscNumber,
|
|
||||||
ISRC: amazonResult.ISRC,
|
|
||||||
LyricsLRC: amazonResult.LyricsLRC,
|
|
||||||
DecryptionKey: amazonResult.DecryptionKey,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
err = amazonErr
|
|
||||||
case "deezer":
|
case "deezer":
|
||||||
deezerResult, deezerErr := downloadFromDeezer(req)
|
deezerResult, deezerErr := downloadFromDeezer(req)
|
||||||
if deezerErr == nil {
|
if deezerErr == nil {
|
||||||
@@ -640,7 +525,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
|
|
||||||
enrichRequestExtendedMetadata(&req)
|
enrichRequestExtendedMetadata(&req)
|
||||||
|
|
||||||
allServices := []string{"tidal", "qobuz", "amazon", "deezer"}
|
allServices := []string{"tidal", "qobuz", "deezer"}
|
||||||
preferredService := req.Service
|
preferredService := req.Service
|
||||||
if preferredService == "" {
|
if preferredService == "" {
|
||||||
preferredService = "tidal"
|
preferredService = "tidal"
|
||||||
@@ -707,27 +592,6 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
|
GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
|
||||||
}
|
}
|
||||||
err = qobuzErr
|
err = qobuzErr
|
||||||
case "amazon":
|
|
||||||
amazonResult, amazonErr := downloadFromAmazon(req)
|
|
||||||
if amazonErr == nil {
|
|
||||||
result = DownloadResult{
|
|
||||||
FilePath: amazonResult.FilePath,
|
|
||||||
BitDepth: amazonResult.BitDepth,
|
|
||||||
SampleRate: amazonResult.SampleRate,
|
|
||||||
Title: amazonResult.Title,
|
|
||||||
Artist: amazonResult.Artist,
|
|
||||||
Album: amazonResult.Album,
|
|
||||||
ReleaseDate: amazonResult.ReleaseDate,
|
|
||||||
TrackNumber: amazonResult.TrackNumber,
|
|
||||||
DiscNumber: amazonResult.DiscNumber,
|
|
||||||
ISRC: amazonResult.ISRC,
|
|
||||||
LyricsLRC: amazonResult.LyricsLRC,
|
|
||||||
DecryptionKey: amazonResult.DecryptionKey,
|
|
||||||
}
|
|
||||||
} else if !errors.Is(amazonErr, ErrDownloadCancelled) {
|
|
||||||
GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
|
|
||||||
}
|
|
||||||
err = amazonErr
|
|
||||||
case "deezer":
|
case "deezer":
|
||||||
deezerResult, deezerErr := downloadFromDeezer(req)
|
deezerResult, deezerErr := downloadFromDeezer(req)
|
||||||
if deezerErr == nil {
|
if deezerErr == nil {
|
||||||
@@ -824,6 +688,7 @@ func CleanupConnections() {
|
|||||||
func ReadFileMetadata(filePath string) (string, error) {
|
func ReadFileMetadata(filePath string) (string, error) {
|
||||||
lower := strings.ToLower(filePath)
|
lower := strings.ToLower(filePath)
|
||||||
isFlac := strings.HasSuffix(lower, ".flac")
|
isFlac := strings.HasSuffix(lower, ".flac")
|
||||||
|
isM4A := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac")
|
||||||
isMp3 := strings.HasSuffix(lower, ".mp3")
|
isMp3 := strings.HasSuffix(lower, ".mp3")
|
||||||
isOgg := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg")
|
isOgg := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg")
|
||||||
|
|
||||||
@@ -873,6 +738,12 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
|
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if isM4A {
|
||||||
|
quality, qualityErr := GetM4AQuality(filePath)
|
||||||
|
if qualityErr == nil {
|
||||||
|
result["bit_depth"] = quality.BitDepth
|
||||||
|
result["sample_rate"] = quality.SampleRate
|
||||||
|
}
|
||||||
} else if isMp3 {
|
} else if isMp3 {
|
||||||
meta, err := ReadID3Tags(filePath)
|
meta, err := ReadID3Tags(filePath)
|
||||||
if err == nil && meta != nil {
|
if err == nil && meta != nil {
|
||||||
@@ -934,6 +805,32 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseCueSheet parses a .cue file and returns JSON with split information.
|
||||||
|
// This is called from Dart to get track listing and timing data for CUE splitting.
|
||||||
|
// audioDir, if non-empty, overrides the directory used for resolving the
|
||||||
|
// referenced audio file (useful for SAF temp file scenarios).
|
||||||
|
func ParseCueSheet(cuePath string, audioDir string) (string, error) {
|
||||||
|
return ParseCueFileJSON(cuePath, audioDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanCueSheetForLibrary parses a .cue file and returns a JSON array of
|
||||||
|
// LibraryScanResult entries (one per track). This is the SAF-friendly variant:
|
||||||
|
// - audioDir overrides where the referenced audio file is resolved
|
||||||
|
// - virtualPathPrefix replaces cuePath in filePath / id fields (e.g. a content:// URI)
|
||||||
|
// - fileModTime is stamped on every result (pass 0 to stat cuePath instead)
|
||||||
|
func ScanCueSheetForLibrary(cuePath, audioDir, virtualPathPrefix string, fileModTime int64) (string, error) {
|
||||||
|
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
results, err := ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix, fileModTime, scanTime)
|
||||||
|
if err != nil {
|
||||||
|
return "[]", err
|
||||||
|
}
|
||||||
|
jsonBytes, err := json.Marshal(results)
|
||||||
|
if err != nil {
|
||||||
|
return "[]", fmt.Errorf("failed to marshal cue scan results: %w", err)
|
||||||
|
}
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
// EditFileMetadata writes metadata to an audio file.
|
// EditFileMetadata writes metadata to an audio file.
|
||||||
// For FLAC files, uses native Go FLAC library.
|
// For FLAC files, uses native Go FLAC library.
|
||||||
// For MP3/Opus, returns the metadata map so Dart can use FFmpeg.
|
// For MP3/Opus, returns the metadata map so Dart can use FFmpeg.
|
||||||
@@ -1283,6 +1180,66 @@ func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetQobuzMetadata(resourceType, resourceID string) (string, error) {
|
||||||
|
downloader := NewQobuzDownloader()
|
||||||
|
|
||||||
|
var data interface{}
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch resourceType {
|
||||||
|
case "track":
|
||||||
|
data, err = downloader.GetTrackMetadata(resourceID)
|
||||||
|
case "album":
|
||||||
|
data, err = downloader.GetAlbumMetadata(resourceID)
|
||||||
|
case "artist":
|
||||||
|
data, err = downloader.GetArtistMetadata(resourceID)
|
||||||
|
case "playlist":
|
||||||
|
data, err = downloader.GetPlaylistMetadata(resourceID)
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported Qobuz resource type: %s", resourceType)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTidalMetadata(resourceType, resourceID string) (string, error) {
|
||||||
|
downloader := NewTidalDownloader()
|
||||||
|
|
||||||
|
var data interface{}
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch resourceType {
|
||||||
|
case "track":
|
||||||
|
data, err = downloader.GetTrackMetadata(resourceID)
|
||||||
|
case "album":
|
||||||
|
data, err = downloader.GetAlbumMetadata(resourceID)
|
||||||
|
case "artist":
|
||||||
|
data, err = downloader.GetArtistMetadata(resourceID)
|
||||||
|
case "playlist":
|
||||||
|
data, err = downloader.GetPlaylistMetadata(resourceID)
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported Tidal resource type: %s", resourceType)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
func ParseDeezerURLExport(url string) (string, error) {
|
func ParseDeezerURLExport(url string) (string, error) {
|
||||||
resourceType, resourceID, err := parseDeezerURL(url)
|
resourceType, resourceID, err := parseDeezerURL(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1302,6 +1259,25 @@ func ParseDeezerURLExport(url string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ParseQobuzURLExport(url string) (string, error) {
|
||||||
|
resourceType, resourceID, err := parseQobuzURL(url)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]string{
|
||||||
|
"type": resourceType,
|
||||||
|
"id": resourceID,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
func ParseTidalURLExport(url string) (string, error) {
|
func ParseTidalURLExport(url string) (string, error) {
|
||||||
resourceType, resourceID, err := parseTidalURL(url)
|
resourceType, resourceID, err := parseTidalURL(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1362,10 +1338,7 @@ func GetDeezerExtendedMetadata(trackID string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
result := map[string]string{
|
result := buildDeezerExtendedMetadataResult(metadata)
|
||||||
"genre": metadata.Genre,
|
|
||||||
"label": metadata.Label,
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(result)
|
jsonBytes, err := json.Marshal(result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1385,7 +1358,8 @@ func SearchDeezerByISRC(isrc string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(track)
|
result := buildDeezerISRCSearchResult(track)
|
||||||
|
jsonBytes, err := json.Marshal(result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -1393,6 +1367,55 @@ func SearchDeezerByISRC(isrc string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildDeezerExtendedMetadataResult(metadata *AlbumExtendedMetadata) map[string]string {
|
||||||
|
if metadata == nil {
|
||||||
|
return map[string]string{
|
||||||
|
"genre": "",
|
||||||
|
"label": "",
|
||||||
|
"copyright": "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]string{
|
||||||
|
"genre": metadata.Genre,
|
||||||
|
"label": metadata.Label,
|
||||||
|
"copyright": metadata.Copyright,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDeezerISRCSearchResult(track *TrackMetadata) map[string]interface{} {
|
||||||
|
if track == nil {
|
||||||
|
return map[string]interface{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"spotify_id": track.SpotifyID,
|
||||||
|
"artists": track.Artists,
|
||||||
|
"name": track.Name,
|
||||||
|
"album_name": track.AlbumName,
|
||||||
|
"album_artist": track.AlbumArtist,
|
||||||
|
"duration_ms": track.DurationMS,
|
||||||
|
"images": track.Images,
|
||||||
|
"release_date": track.ReleaseDate,
|
||||||
|
"track_number": track.TrackNumber,
|
||||||
|
"total_tracks": track.TotalTracks,
|
||||||
|
"disc_number": track.DiscNumber,
|
||||||
|
"external_urls": track.ExternalURL,
|
||||||
|
"isrc": track.ISRC,
|
||||||
|
"album_id": track.AlbumID,
|
||||||
|
"artist_id": track.ArtistID,
|
||||||
|
"album_type": track.AlbumType,
|
||||||
|
}
|
||||||
|
|
||||||
|
if deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:")); deezerID != "" {
|
||||||
|
result["id"] = deezerID
|
||||||
|
result["track_id"] = deezerID
|
||||||
|
result["success"] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -1438,7 +1461,6 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// For artists/playlists, SongLink doesn't provide direct mapping
|
|
||||||
return "", fmt.Errorf("Spotify to Deezer conversion only supported for tracks and albums. Please search by name for %s", resourceType)
|
return "", fmt.Errorf("Spotify to Deezer conversion only supported for tracks and albums. Please search by name for %s", resourceType)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1446,28 +1468,6 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
var spotifyErr error
|
|
||||||
|
|
||||||
client, err := NewSpotifyMetadataClient()
|
|
||||||
if err != nil {
|
|
||||||
LogWarn("Spotify", "Credentials not configured, falling back to Deezer")
|
|
||||||
spotifyErr = err
|
|
||||||
} else {
|
|
||||||
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
|
||||||
if err == nil {
|
|
||||||
jsonBytes, err := json.Marshal(data)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(jsonBytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
spotifyErr = err
|
|
||||||
if !shouldTrySpotFetchFallback(err) {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
spotFetchData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
|
spotFetchData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
|
||||||
if apiErr == nil {
|
if apiErr == nil {
|
||||||
GoLog("[Fallback] Spotify metadata fetched via SpotFetch API\n")
|
GoLog("[Fallback] Spotify metadata fetched via SpotFetch API\n")
|
||||||
@@ -1481,9 +1481,6 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
|||||||
|
|
||||||
parsed, parseErr := parseSpotifyURI(spotifyURL)
|
parsed, parseErr := parseSpotifyURI(spotifyURL)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
if spotifyErr != nil {
|
|
||||||
return "", fmt.Errorf("spotify failed (%v), SpotFetch fallback failed (%v), and URL parsing failed: %w", spotifyErr, apiErr, parseErr)
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("SpotFetch fallback failed (%v) and URL parsing failed: %w", apiErr, parseErr)
|
return "", fmt.Errorf("SpotFetch fallback failed (%v) and URL parsing failed: %w", apiErr, parseErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1494,15 +1491,9 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Type == "artist" {
|
if parsed.Type == "artist" {
|
||||||
if spotifyErr != nil {
|
return "", fmt.Errorf("SpotFetch fallback failed (%v). Artist pages now require SpotFetch or a metadata extension such as spotify-web", apiErr)
|
||||||
return "", fmt.Errorf("spotify metadata unavailable (%v) and SpotFetch fallback failed (%v). Artist pages require Spotify/SpotFetch API", spotifyErr, apiErr)
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("SpotFetch fallback failed (%v). Artist pages require Spotify/SpotFetch API", apiErr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if spotifyErr != nil {
|
|
||||||
return "", fmt.Errorf("spotify metadata unavailable (%v), SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", spotifyErr, apiErr)
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", apiErr)
|
return "", fmt.Errorf("SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", apiErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1579,11 +1570,6 @@ func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) {
|
|||||||
return client.GetTidalURLFromDeezer(deezerTrackID)
|
return client.GetTidalURLFromDeezer(deezerTrackID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetAmazonURLFromDeezerTrack(deezerTrackID string) (string, error) {
|
|
||||||
client := NewSongLinkClient()
|
|
||||||
return client.GetAmazonURLFromDeezer(deezerTrackID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func errorResponse(msg string) (string, error) {
|
func errorResponse(msg string) (string, error) {
|
||||||
errorType := "unknown"
|
errorType := "unknown"
|
||||||
lowerMsg := strings.ToLower(msg)
|
lowerMsg := strings.ToLower(msg)
|
||||||
@@ -1708,6 +1694,8 @@ func ExtractCoverToFile(audioPath string, outputPath string) error {
|
|||||||
|
|
||||||
if strings.HasSuffix(lower, ".flac") {
|
if strings.HasSuffix(lower, ".flac") {
|
||||||
coverData, err = ExtractCoverArt(audioPath)
|
coverData, err = ExtractCoverArt(audioPath)
|
||||||
|
} else if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") {
|
||||||
|
coverData, err = extractCoverFromM4A(audioPath)
|
||||||
} else if strings.HasSuffix(lower, ".mp3") {
|
} else if strings.HasSuffix(lower, ".mp3") {
|
||||||
coverData, _, err = extractMP3CoverArt(audioPath)
|
coverData, _, err = extractMP3CoverArt(audioPath)
|
||||||
} else if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
|
} else if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
|
||||||
@@ -1838,114 +1826,58 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
|
|
||||||
GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath)
|
GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath)
|
||||||
|
|
||||||
// When search_online is true, search for metadata from internet
|
// When search_online is true, search for metadata from internet using the
|
||||||
// Priority: 1) Deezer (reliable, no credentials) 2) Extension providers (spotify-web etc) 3) Spotify built-in API (last resort, deprecated)
|
// configured metadata-provider priority.
|
||||||
if req.SearchOnline && req.TrackName != "" && req.ArtistName != "" {
|
if req.SearchOnline && req.TrackName != "" && req.ArtistName != "" {
|
||||||
GoLog("[ReEnrich] Searching online metadata for: %s - %s\n", req.TrackName, req.ArtistName)
|
GoLog("[ReEnrich] Searching online metadata for: %s - %s\n", req.TrackName, req.ArtistName)
|
||||||
searchQuery := req.TrackName + " " + req.ArtistName
|
searchQuery := req.TrackName + " " + req.ArtistName
|
||||||
found := false
|
found := false
|
||||||
|
|
||||||
// 1) Try Deezer first (reliable, no credentials needed)
|
|
||||||
GoLog("[ReEnrich] Trying Deezer search...\n")
|
|
||||||
deezerClient := GetDeezerClient()
|
deezerClient := GetDeezerClient()
|
||||||
{
|
GoLog("[ReEnrich] Trying metadata providers in configured priority...\n")
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
manager := GetExtensionManager()
|
||||||
deezerResults, err := deezerClient.SearchAll(ctx, searchQuery, 5, 0, "track")
|
tracks, searchErr := manager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
|
||||||
cancel()
|
if searchErr == nil && len(tracks) > 0 {
|
||||||
if err == nil && len(deezerResults.Tracks) > 0 {
|
track := tracks[0]
|
||||||
track := deezerResults.Tracks[0]
|
GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s)\n", track.ProviderID, track.Name, track.Artists, track.AlbumName)
|
||||||
GoLog("[ReEnrich] Deezer match: %s - %s (album: %s)\n", track.Name, track.Artists, track.AlbumName)
|
if track.SpotifyID != "" {
|
||||||
req.SpotifyID = "deezer:" + track.SpotifyID
|
req.SpotifyID = track.SpotifyID
|
||||||
req.AlbumName = track.AlbumName
|
} else if track.DeezerID != "" {
|
||||||
req.AlbumArtist = track.AlbumArtist
|
req.SpotifyID = "deezer:" + track.DeezerID
|
||||||
req.TrackNumber = track.TrackNumber
|
} else if track.QobuzID != "" {
|
||||||
req.DiscNumber = track.DiscNumber
|
req.SpotifyID = "qobuz:" + track.QobuzID
|
||||||
req.ReleaseDate = track.ReleaseDate
|
} else if track.TidalID != "" {
|
||||||
req.ISRC = track.ISRC
|
req.SpotifyID = "tidal:" + track.TidalID
|
||||||
if track.Images != "" {
|
|
||||||
req.CoverURL = track.Images
|
|
||||||
}
|
|
||||||
req.DurationMs = int64(track.DurationMS)
|
|
||||||
found = true
|
|
||||||
} else if err != nil {
|
|
||||||
GoLog("[ReEnrich] Deezer search failed: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Try extension metadata providers (spotify-web etc) if Deezer failed
|
|
||||||
if !found {
|
|
||||||
GoLog("[ReEnrich] Trying extension metadata providers...\n")
|
|
||||||
manager := GetExtensionManager()
|
|
||||||
extTracks, extErr := manager.SearchTracksWithExtensions(searchQuery, 5)
|
|
||||||
if extErr == nil && len(extTracks) > 0 {
|
|
||||||
track := extTracks[0]
|
|
||||||
GoLog("[ReEnrich] Extension match (%s): %s - %s (album: %s)\n", track.ProviderID, track.Name, track.Artists, track.AlbumName)
|
|
||||||
if track.SpotifyID != "" {
|
|
||||||
req.SpotifyID = track.SpotifyID
|
|
||||||
} else if track.DeezerID != "" {
|
|
||||||
req.SpotifyID = "deezer:" + track.DeezerID
|
|
||||||
} else {
|
|
||||||
req.SpotifyID = track.ID
|
|
||||||
}
|
|
||||||
req.AlbumName = track.AlbumName
|
|
||||||
req.AlbumArtist = track.AlbumArtist
|
|
||||||
req.TrackNumber = track.TrackNumber
|
|
||||||
req.DiscNumber = track.DiscNumber
|
|
||||||
req.ReleaseDate = track.ReleaseDate
|
|
||||||
req.ISRC = track.ISRC
|
|
||||||
coverURL := track.ResolvedCoverURL()
|
|
||||||
if coverURL != "" {
|
|
||||||
req.CoverURL = coverURL
|
|
||||||
}
|
|
||||||
req.DurationMs = int64(track.DurationMS)
|
|
||||||
if track.Genre != "" {
|
|
||||||
req.Genre = track.Genre
|
|
||||||
}
|
|
||||||
if track.Label != "" {
|
|
||||||
req.Label = track.Label
|
|
||||||
}
|
|
||||||
if track.Copyright != "" {
|
|
||||||
req.Copyright = track.Copyright
|
|
||||||
}
|
|
||||||
found = true
|
|
||||||
} else if extErr != nil {
|
|
||||||
GoLog("[ReEnrich] Extension search failed: %v\n", extErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Try Spotify built-in API as last resort (will be deprecated)
|
|
||||||
if !found {
|
|
||||||
GoLog("[ReEnrich] Trying Spotify API (fallback)...\n")
|
|
||||||
spotifyClient, spotifyErr := NewSpotifyMetadataClient()
|
|
||||||
if spotifyErr == nil {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
||||||
results, err := spotifyClient.SearchTracks(ctx, searchQuery, 5)
|
|
||||||
cancel()
|
|
||||||
if err == nil && len(results.Tracks) > 0 {
|
|
||||||
track := results.Tracks[0]
|
|
||||||
GoLog("[ReEnrich] Spotify match: %s - %s (album: %s)\n", track.Name, track.Artists, track.AlbumName)
|
|
||||||
req.SpotifyID = track.SpotifyID
|
|
||||||
req.AlbumName = track.AlbumName
|
|
||||||
req.AlbumArtist = track.AlbumArtist
|
|
||||||
req.TrackNumber = track.TrackNumber
|
|
||||||
req.DiscNumber = track.DiscNumber
|
|
||||||
req.ReleaseDate = track.ReleaseDate
|
|
||||||
req.ISRC = track.ISRC
|
|
||||||
if track.Images != "" {
|
|
||||||
req.CoverURL = track.Images
|
|
||||||
}
|
|
||||||
req.DurationMs = int64(track.DurationMS)
|
|
||||||
found = true
|
|
||||||
} else if err != nil {
|
|
||||||
GoLog("[ReEnrich] Spotify search failed: %v\n", err)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
GoLog("[ReEnrich] Spotify client unavailable: %v\n", spotifyErr)
|
req.SpotifyID = track.ID
|
||||||
}
|
}
|
||||||
|
req.AlbumName = track.AlbumName
|
||||||
|
req.AlbumArtist = track.AlbumArtist
|
||||||
|
req.TrackNumber = track.TrackNumber
|
||||||
|
req.DiscNumber = track.DiscNumber
|
||||||
|
req.ReleaseDate = track.ReleaseDate
|
||||||
|
req.ISRC = track.ISRC
|
||||||
|
coverURL := track.ResolvedCoverURL()
|
||||||
|
if coverURL != "" {
|
||||||
|
req.CoverURL = coverURL
|
||||||
|
}
|
||||||
|
req.DurationMs = int64(track.DurationMS)
|
||||||
|
if track.Genre != "" {
|
||||||
|
req.Genre = track.Genre
|
||||||
|
}
|
||||||
|
if track.Label != "" {
|
||||||
|
req.Label = track.Label
|
||||||
|
}
|
||||||
|
if track.Copyright != "" {
|
||||||
|
req.Copyright = track.Copyright
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
} else if searchErr != nil {
|
||||||
|
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to get extended metadata (genre, label) from Deezer if not already set
|
// Try to get extended metadata from Deezer if not already set
|
||||||
if found && req.ISRC != "" && (req.Genre == "" || req.Label == "") {
|
if found && req.ISRC != "" && (req.Genre == "" || req.Label == "" || req.Copyright == "") {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
|
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
|
||||||
cancel()
|
cancel()
|
||||||
@@ -1956,7 +1888,10 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
if req.Label == "" && extMeta.Label != "" {
|
if req.Label == "" && extMeta.Label != "" {
|
||||||
req.Label = extMeta.Label
|
req.Label = extMeta.Label
|
||||||
}
|
}
|
||||||
GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s\n", req.Genre, req.Label)
|
if req.Copyright == "" && extMeta.Copyright != "" {
|
||||||
|
req.Copyright = extMeta.Copyright
|
||||||
|
}
|
||||||
|
GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2146,8 +2081,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== EXTENSION SYSTEM ====================
|
|
||||||
|
|
||||||
func InitExtensionSystem(extensionsDir, dataDir string) error {
|
func InitExtensionSystem(extensionsDir, dataDir string) error {
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
if err := manager.SetDirectories(extensionsDir, dataDir); err != nil {
|
if err := manager.SetDirectories(extensionsDir, dataDir); err != nil {
|
||||||
@@ -2345,6 +2278,21 @@ func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SearchTracksWithMetadataProvidersJSON(query string, limit int, includeExtensions bool) (string, error) {
|
||||||
|
manager := GetExtensionManager()
|
||||||
|
tracks, err := manager.SearchTracksWithMetadataProviders(query, limit, includeExtensions)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(tracks)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
|
func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
|
||||||
var req DownloadRequest
|
var req DownloadRequest
|
||||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||||
@@ -2519,8 +2467,6 @@ func GetAllPendingFFmpegCommandsJSON() (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== EXTENSION CUSTOM SEARCH ====================
|
|
||||||
|
|
||||||
func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) {
|
func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
ext, err := manager.GetExtension(extensionID)
|
ext, err := manager.GetExtension(extensionID)
|
||||||
@@ -2732,6 +2678,28 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
artistResponse["albums"] = albums
|
artistResponse["albums"] = albums
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(result.Artist.Releases) > 0 {
|
||||||
|
releases := make([]map[string]interface{}, len(result.Artist.Releases))
|
||||||
|
for i, release := range result.Artist.Releases {
|
||||||
|
releaseType := release.AlbumType
|
||||||
|
if releaseType == "" {
|
||||||
|
releaseType = "album"
|
||||||
|
}
|
||||||
|
releases[i] = map[string]interface{}{
|
||||||
|
"id": release.ID,
|
||||||
|
"name": release.Name,
|
||||||
|
"artists": release.Artists,
|
||||||
|
"images": release.CoverURL,
|
||||||
|
"cover_url": release.CoverURL,
|
||||||
|
"release_date": release.ReleaseDate,
|
||||||
|
"total_tracks": release.TotalTracks,
|
||||||
|
"album_type": releaseType,
|
||||||
|
"provider_id": release.ProviderID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
artistResponse["releases"] = releases
|
||||||
|
}
|
||||||
|
|
||||||
if len(result.Artist.TopTracks) > 0 {
|
if len(result.Artist.TopTracks) > 0 {
|
||||||
topTracks := make([]map[string]interface{}, len(result.Artist.TopTracks))
|
topTracks := make([]map[string]interface{}, len(result.Artist.TopTracks))
|
||||||
for i, track := range result.Artist.TopTracks {
|
for i, track := range result.Artist.TopTracks {
|
||||||
@@ -2981,6 +2949,27 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
|||||||
"provider_id": artist.ProviderID,
|
"provider_id": artist.ProviderID,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(artist.Releases) > 0 {
|
||||||
|
releases := make([]map[string]interface{}, len(artist.Releases))
|
||||||
|
for i, release := range artist.Releases {
|
||||||
|
releaseType := release.AlbumType
|
||||||
|
if releaseType == "" {
|
||||||
|
releaseType = "album"
|
||||||
|
}
|
||||||
|
releases[i] = map[string]interface{}{
|
||||||
|
"id": release.ID,
|
||||||
|
"name": release.Name,
|
||||||
|
"artists": release.Artists,
|
||||||
|
"cover_url": release.CoverURL,
|
||||||
|
"release_date": release.ReleaseDate,
|
||||||
|
"total_tracks": release.TotalTracks,
|
||||||
|
"album_type": releaseType,
|
||||||
|
"provider_id": release.ProviderID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response["releases"] = releases
|
||||||
|
}
|
||||||
|
|
||||||
if artist.HeaderImage != "" {
|
if artist.HeaderImage != "" {
|
||||||
response["header_image"] = artist.HeaderImage
|
response["header_image"] = artist.HeaderImage
|
||||||
}
|
}
|
||||||
@@ -3128,6 +3117,45 @@ func InitExtensionStoreJSON(cacheDir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SetStoreRegistryURLJSON(registryURL string) error {
|
||||||
|
store := GetExtensionStore()
|
||||||
|
if store == nil {
|
||||||
|
return fmt.Errorf("extension store not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := ResolveRegistryURL(registryURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := requireHTTPSURL(resolved, "registry"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
store.SetRegistryURL(resolved)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClearStoreRegistryURLJSON() error {
|
||||||
|
store := GetExtensionStore()
|
||||||
|
if store == nil {
|
||||||
|
return fmt.Errorf("extension store not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
store.SetRegistryURL("")
|
||||||
|
store.ClearCache()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetStoreRegistryURLJSON() (string, error) {
|
||||||
|
store := GetExtensionStore()
|
||||||
|
if store == nil {
|
||||||
|
return "", fmt.Errorf("extension store not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
return store.GetRegistryURL(), nil
|
||||||
|
}
|
||||||
|
|
||||||
func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
|
func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
|
||||||
store := GetExtensionStore()
|
store := GetExtensionStore()
|
||||||
if store == nil {
|
if store == nil {
|
||||||
@@ -3273,9 +3301,6 @@ func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) {
|
|||||||
return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second)
|
return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== LOCAL LIBRARY SCANNING ====================
|
|
||||||
|
|
||||||
// SetLibraryCoverCacheDirJSON sets the directory for caching extracted cover art
|
|
||||||
func SetLibraryCoverCacheDirJSON(cacheDir string) {
|
func SetLibraryCoverCacheDirJSON(cacheDir string) {
|
||||||
SetLibraryCoverCacheDir(cacheDir)
|
SetLibraryCoverCacheDir(cacheDir)
|
||||||
}
|
}
|
||||||
@@ -3284,13 +3309,14 @@ func ScanLibraryFolderJSON(folderPath string) (string, error) {
|
|||||||
return ScanLibraryFolder(folderPath)
|
return ScanLibraryFolder(folderPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScanLibraryFolderIncrementalJSON performs an incremental library scan
|
|
||||||
// existingFilesJSON: JSON object mapping filePath -> modTime (unix millis)
|
|
||||||
// Returns IncrementalScanResult as JSON
|
|
||||||
func ScanLibraryFolderIncrementalJSON(folderPath, existingFilesJSON string) (string, error) {
|
func ScanLibraryFolderIncrementalJSON(folderPath, existingFilesJSON string) (string, error) {
|
||||||
return ScanLibraryFolderIncremental(folderPath, existingFilesJSON)
|
return ScanLibraryFolderIncremental(folderPath, existingFilesJSON)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ScanLibraryFolderIncrementalFromSnapshotJSON(folderPath, snapshotPath string) (string, error) {
|
||||||
|
return ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath)
|
||||||
|
}
|
||||||
|
|
||||||
func GetLibraryScanProgressJSON() string {
|
func GetLibraryScanProgressJSON() string {
|
||||||
return GetLibraryScanProgress()
|
return GetLibraryScanProgress()
|
||||||
}
|
}
|
||||||
@@ -3302,3 +3328,7 @@ func CancelLibraryScanJSON() {
|
|||||||
func ReadAudioMetadataJSON(filePath string) (string, error) {
|
func ReadAudioMetadataJSON(filePath string) (string, error) {
|
||||||
return ReadAudioMetadata(filePath)
|
return ReadAudioMetadata(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ReadAudioMetadataWithHintJSON(filePath, displayName string) (string, error) {
|
||||||
|
return ReadAudioMetadataWithDisplayName(filePath, displayName)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestBuildDeezerExtendedMetadataResultHandlesNil(t *testing.T) {
|
||||||
|
result := buildDeezerExtendedMetadataResult(nil)
|
||||||
|
|
||||||
|
if result["genre"] != "" {
|
||||||
|
t.Fatalf("expected empty genre, got %q", result["genre"])
|
||||||
|
}
|
||||||
|
if result["label"] != "" {
|
||||||
|
t.Fatalf("expected empty label, got %q", result["label"])
|
||||||
|
}
|
||||||
|
if result["copyright"] != "" {
|
||||||
|
t.Fatalf("expected empty copyright, got %q", result["copyright"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDeezerExtendedMetadataResultIncludesCopyright(t *testing.T) {
|
||||||
|
result := buildDeezerExtendedMetadataResult(&AlbumExtendedMetadata{
|
||||||
|
Genre: "Rock",
|
||||||
|
Label: "EMI",
|
||||||
|
Copyright: "(C) Queen",
|
||||||
|
})
|
||||||
|
|
||||||
|
if result["genre"] != "Rock" {
|
||||||
|
t.Fatalf("unexpected genre: %q", result["genre"])
|
||||||
|
}
|
||||||
|
if result["label"] != "EMI" {
|
||||||
|
t.Fatalf("unexpected label: %q", result["label"])
|
||||||
|
}
|
||||||
|
if result["copyright"] != "(C) Queen" {
|
||||||
|
t.Fatalf("unexpected copyright: %q", result["copyright"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDeezerISRCSearchResultAddsCompatibilityIDs(t *testing.T) {
|
||||||
|
result := buildDeezerISRCSearchResult(&TrackMetadata{
|
||||||
|
SpotifyID: "deezer:3135556",
|
||||||
|
Name: "Love Of My Life",
|
||||||
|
Artists: "Queen",
|
||||||
|
AlbumName: "A Night at the Opera",
|
||||||
|
ISRC: "GBUM71029604",
|
||||||
|
ReleaseDate: "1975-11-21",
|
||||||
|
})
|
||||||
|
|
||||||
|
if result["spotify_id"] != "deezer:3135556" {
|
||||||
|
t.Fatalf("unexpected spotify_id: %v", result["spotify_id"])
|
||||||
|
}
|
||||||
|
if result["id"] != "3135556" {
|
||||||
|
t.Fatalf("unexpected id: %v", result["id"])
|
||||||
|
}
|
||||||
|
if result["track_id"] != "3135556" {
|
||||||
|
t.Fatalf("unexpected track_id: %v", result["track_id"])
|
||||||
|
}
|
||||||
|
if result["success"] != true {
|
||||||
|
t.Fatalf("expected success=true, got %v", result["success"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -151,7 +151,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
if exists {
|
if exists {
|
||||||
versionCompare := compareVersions(manifest.Version, existingVersion)
|
versionCompare := compareVersions(manifest.Version, existingVersion)
|
||||||
if versionCompare > 0 {
|
if versionCompare > 0 {
|
||||||
// This is an upgrade - call UpgradeExtension
|
|
||||||
return m.UpgradeExtension(filePath)
|
return m.UpgradeExtension(filePath)
|
||||||
} else if versionCompare == 0 {
|
} else if versionCompare == 0 {
|
||||||
return nil, fmt.Errorf("Extension '%s' v%s is already installed", existingDisplayName, existingVersion)
|
return nil, fmt.Errorf("Extension '%s' v%s is already installed", existingDisplayName, existingVersion)
|
||||||
@@ -401,7 +400,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
|||||||
return nil, fmt.Errorf("failed to read manifest.json: %w", err)
|
return nil, fmt.Errorf("failed to read manifest.json: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse and validate manifest
|
|
||||||
manifest, err := ParseManifest(manifestData)
|
manifest, err := ParseManifest(manifestData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
||||||
@@ -430,7 +428,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
|||||||
SourceDir: dirPath,
|
SourceDir: dirPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore enabled state from settings store
|
|
||||||
store := GetExtensionSettingsStore()
|
store := GetExtensionSettingsStore()
|
||||||
if enabledVal, err := store.Get(manifest.Name, "_enabled"); err == nil {
|
if enabledVal, err := store.Get(manifest.Name, "_enabled"); err == nil {
|
||||||
if enabled, ok := enabledVal.(bool); ok {
|
if enabled, ok := enabledVal.(bool); ok {
|
||||||
@@ -467,17 +464,11 @@ func (m *ExtensionManager) RemoveExtension(extensionID string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optionally remove data directory (keep for now to preserve settings)
|
|
||||||
// if ext.DataDir != "" {
|
|
||||||
// os.RemoveAll(ext.DataDir)
|
|
||||||
// }
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only allows upgrades (new version > current version), not downgrades
|
// Only allows upgrades (new version > current version), not downgrades
|
||||||
func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) {
|
func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) {
|
||||||
// Validate file extension
|
|
||||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||||
}
|
}
|
||||||
@@ -529,7 +520,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
return nil, fmt.Errorf("Extension '%s' is not installed. Use install instead of upgrade.", newManifest.DisplayName)
|
return nil, fmt.Errorf("Extension '%s' is not installed. Use install instead of upgrade.", newManifest.DisplayName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compare versions - only allow upgrade, not downgrade
|
|
||||||
versionCompare := compareVersions(newManifest.Version, existing.Manifest.Version)
|
versionCompare := compareVersions(newManifest.Version, existing.Manifest.Version)
|
||||||
if versionCompare < 0 {
|
if versionCompare < 0 {
|
||||||
return nil, fmt.Errorf("Cannot downgrade extension. Current version: %s, New version: %s", existing.Manifest.Version, newManifest.Version)
|
return nil, fmt.Errorf("Cannot downgrade extension. Current version: %s, New version: %s", existing.Manifest.Version, newManifest.Version)
|
||||||
@@ -540,7 +530,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
|
|
||||||
GoLog("[Extension] Upgrading %s from v%s to v%s\n", newManifest.DisplayName, existing.Manifest.Version, newManifest.Version)
|
GoLog("[Extension] Upgrading %s from v%s to v%s\n", newManifest.DisplayName, existing.Manifest.Version, newManifest.Version)
|
||||||
|
|
||||||
// Save data directory path and enabled state (we want to preserve them)
|
|
||||||
extDataDir := existing.DataDir
|
extDataDir := existing.DataDir
|
||||||
extDir := existing.SourceDir
|
extDir := existing.SourceDir
|
||||||
wasEnabled := existing.Enabled
|
wasEnabled := existing.Enabled
|
||||||
@@ -601,7 +590,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
SourceDir: extDir,
|
SourceDir: extDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Goja VM
|
|
||||||
if err := m.initializeVM(ext); err != nil {
|
if err := m.initializeVM(ext); err != nil {
|
||||||
ext.Error = err.Error()
|
ext.Error = err.Error()
|
||||||
ext.Enabled = false
|
ext.Enabled = false
|
||||||
@@ -626,7 +614,6 @@ type ExtensionUpgradeInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
||||||
// Validate file extension
|
|
||||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||||
}
|
}
|
||||||
@@ -675,7 +662,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
// Not installed - this is a new install, not upgrade
|
|
||||||
info.CurrentVersion = ""
|
info.CurrentVersion = ""
|
||||||
info.CanUpgrade = false
|
info.CanUpgrade = false
|
||||||
} else {
|
} else {
|
||||||
@@ -739,7 +725,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
permissions = append(permissions, "storage:enabled")
|
permissions = append(permissions, "storage:enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine status
|
|
||||||
status := "loaded"
|
status := "loaded"
|
||||||
if ext.Error != "" {
|
if ext.Error != "" {
|
||||||
status = "error"
|
status = "error"
|
||||||
@@ -940,7 +925,6 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
|
|||||||
return nil, fmt.Errorf("extension is disabled")
|
return nil, fmt.Errorf("extension is disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call the action function on the extension object
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides extension manifest parsing and validation
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -69,6 +70,7 @@ type ExtArtistMetadata struct {
|
|||||||
HeaderImage string `json:"header_image,omitempty"`
|
HeaderImage string `json:"header_image,omitempty"`
|
||||||
Listeners int `json:"listeners,omitempty"`
|
Listeners int `json:"listeners,omitempty"`
|
||||||
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
|
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
|
||||||
|
Releases []ExtAlbumMetadata `json:"releases,omitempty"`
|
||||||
TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"`
|
TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"`
|
||||||
ProviderID string `json:"provider_id"`
|
ProviderID string `json:"provider_id"`
|
||||||
}
|
}
|
||||||
@@ -99,15 +101,16 @@ type ExtDownloadResult struct {
|
|||||||
ErrorMessage string `json:"error_message,omitempty"`
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
ErrorType string `json:"error_type,omitempty"`
|
ErrorType string `json:"error_type,omitempty"`
|
||||||
|
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Artist string `json:"artist,omitempty"`
|
Artist string `json:"artist,omitempty"`
|
||||||
Album string `json:"album,omitempty"`
|
Album string `json:"album,omitempty"`
|
||||||
AlbumArtist string `json:"album_artist,omitempty"`
|
AlbumArtist string `json:"album_artist,omitempty"`
|
||||||
TrackNumber int `json:"track_number,omitempty"`
|
TrackNumber int `json:"track_number,omitempty"`
|
||||||
DiscNumber int `json:"disc_number,omitempty"`
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
ReleaseDate string `json:"release_date,omitempty"`
|
ReleaseDate string `json:"release_date,omitempty"`
|
||||||
CoverURL string `json:"cover_url,omitempty"`
|
CoverURL string `json:"cover_url,omitempty"`
|
||||||
ISRC string `json:"isrc,omitempty"`
|
ISRC string `json:"isrc,omitempty"`
|
||||||
|
DecryptionKey string `json:"decryption_key,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExtensionProviderWrapper struct {
|
type ExtensionProviderWrapper struct {
|
||||||
@@ -325,6 +328,12 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
|
|||||||
}
|
}
|
||||||
|
|
||||||
artist.ProviderID = p.extension.ID
|
artist.ProviderID = p.extension.ID
|
||||||
|
for i := range artist.Releases {
|
||||||
|
artist.Releases[i].ProviderID = p.extension.ID
|
||||||
|
for j := range artist.Releases[i].Tracks {
|
||||||
|
artist.Releases[i].Tracks[j].ProviderID = p.extension.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
return &artist, nil
|
return &artist, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,7 +397,7 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
|
|||||||
return &enrichedTrack, nil
|
return &enrichedTrack, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName string) (*ExtAvailabilityResult, error) {
|
func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName, spotifyID, deezerID string) (*ExtAvailabilityResult, 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)
|
||||||
}
|
}
|
||||||
@@ -403,11 +412,11 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
|
|||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
if (typeof extension !== 'undefined' && typeof extension.checkAvailability === 'function') {
|
if (typeof extension !== 'undefined' && typeof extension.checkAvailability === 'function') {
|
||||||
return extension.checkAvailability(%q, %q, %q);
|
return extension.checkAvailability(%q, %q, %q, {spotify_id: %q, deezer_id: %q});
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})()
|
})()
|
||||||
`, isrc, trackName, artistName)
|
`, isrc, trackName, artistName, spotifyID, deezerID)
|
||||||
|
|
||||||
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
|
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -482,7 +491,7 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
|
|||||||
return &urlResult, nil
|
return &urlResult, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const ExtDownloadTimeout = 5 * time.Minute
|
const ExtDownloadTimeout = DownloadTimeout
|
||||||
|
|
||||||
func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, onProgress func(percent int)) (*ExtDownloadResult, error) {
|
func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, onProgress func(percent int)) (*ExtDownloadResult, error) {
|
||||||
if !p.extension.Manifest.IsDownloadProvider() {
|
if !p.extension.Manifest.IsDownloadProvider() {
|
||||||
@@ -598,8 +607,30 @@ func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) (
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var allTracks []ExtTrackMetadata
|
providerByID := make(map[string]*ExtensionProviderWrapper, len(providers))
|
||||||
|
orderedProviders := make([]*ExtensionProviderWrapper, 0, len(providers))
|
||||||
for _, provider := range providers {
|
for _, provider := range providers {
|
||||||
|
providerByID[provider.extension.ID] = provider
|
||||||
|
}
|
||||||
|
for _, providerID := range GetMetadataProviderPriority() {
|
||||||
|
if provider := providerByID[providerID]; provider != nil {
|
||||||
|
orderedProviders = append(orderedProviders, provider)
|
||||||
|
delete(providerByID, providerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(providerByID) > 0 {
|
||||||
|
remainingIDs := make([]string, 0, len(providerByID))
|
||||||
|
for providerID := range providerByID {
|
||||||
|
remainingIDs = append(remainingIDs, providerID)
|
||||||
|
}
|
||||||
|
sort.Strings(remainingIDs)
|
||||||
|
for _, providerID := range remainingIDs {
|
||||||
|
orderedProviders = append(orderedProviders, providerByID[providerID])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var allTracks []ExtTrackMetadata
|
||||||
|
for _, provider := range orderedProviders {
|
||||||
result, err := provider.SearchTracks(query, limit)
|
result, err := provider.SearchTracks(query, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Extension] Search error from %s: %v\n", provider.extension.ID, err)
|
GoLog("[Extension] Search error from %s: %v\n", provider.extension.ID, err)
|
||||||
@@ -619,6 +650,8 @@ var providerPriorityMu sync.RWMutex
|
|||||||
var metadataProviderPriority []string
|
var metadataProviderPriority []string
|
||||||
var metadataProviderPriorityMu sync.RWMutex
|
var metadataProviderPriorityMu sync.RWMutex
|
||||||
|
|
||||||
|
var searchBuiltInMetadataTracksFunc = searchBuiltInMetadataTracks
|
||||||
|
|
||||||
func SetProviderPriority(providerIDs []string) {
|
func SetProviderPriority(providerIDs []string) {
|
||||||
providerPriorityMu.Lock()
|
providerPriorityMu.Lock()
|
||||||
defer providerPriorityMu.Unlock()
|
defer providerPriorityMu.Unlock()
|
||||||
@@ -631,7 +664,7 @@ func GetProviderPriority() []string {
|
|||||||
defer providerPriorityMu.RUnlock()
|
defer providerPriorityMu.RUnlock()
|
||||||
|
|
||||||
if len(providerPriority) == 0 {
|
if len(providerPriority) == 0 {
|
||||||
return []string{"tidal", "qobuz", "amazon", "deezer"}
|
return []string{"tidal", "qobuz", "deezer"}
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]string, len(providerPriority))
|
result := make([]string, len(providerPriority))
|
||||||
@@ -642,8 +675,30 @@ func GetProviderPriority() []string {
|
|||||||
func SetMetadataProviderPriority(providerIDs []string) {
|
func SetMetadataProviderPriority(providerIDs []string) {
|
||||||
metadataProviderPriorityMu.Lock()
|
metadataProviderPriorityMu.Lock()
|
||||||
defer metadataProviderPriorityMu.Unlock()
|
defer metadataProviderPriorityMu.Unlock()
|
||||||
metadataProviderPriority = providerIDs
|
|
||||||
GoLog("[Extension] Metadata provider priority set: %v\n", providerIDs)
|
sanitized := make([]string, 0, len(providerIDs)+3)
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, providerID := range providerIDs {
|
||||||
|
providerID = strings.TrimSpace(providerID)
|
||||||
|
if providerID == "" || providerID == "spotify" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[providerID]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[providerID] = struct{}{}
|
||||||
|
sanitized = append(sanitized, providerID)
|
||||||
|
}
|
||||||
|
for _, providerID := range []string{"deezer", "qobuz", "tidal"} {
|
||||||
|
if _, exists := seen[providerID]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[providerID] = struct{}{}
|
||||||
|
sanitized = append(sanitized, providerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataProviderPriority = sanitized
|
||||||
|
GoLog("[Extension] Metadata provider priority set: %v\n", sanitized)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetMetadataProviderPriority() []string {
|
func GetMetadataProviderPriority() []string {
|
||||||
@@ -651,7 +706,7 @@ func GetMetadataProviderPriority() []string {
|
|||||||
defer metadataProviderPriorityMu.RUnlock()
|
defer metadataProviderPriorityMu.RUnlock()
|
||||||
|
|
||||||
if len(metadataProviderPriority) == 0 {
|
if len(metadataProviderPriority) == 0 {
|
||||||
return []string{"deezer", "spotify"}
|
return []string{"deezer", "qobuz", "tidal"}
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]string, len(metadataProviderPriority))
|
result := make([]string, len(metadataProviderPriority))
|
||||||
@@ -661,13 +716,172 @@ func GetMetadataProviderPriority() []string {
|
|||||||
|
|
||||||
func isBuiltInProvider(providerID string) bool {
|
func isBuiltInProvider(providerID string) bool {
|
||||||
switch providerID {
|
switch providerID {
|
||||||
case "tidal", "qobuz", "amazon", "deezer":
|
case "tidal", "qobuz", "deezer":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -694,6 +908,27 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
priority = newPriority
|
priority = newPriority
|
||||||
GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority)
|
GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority)
|
||||||
|
} else if !strictMode && req.Service != "" && !isBuiltInProvider(strings.ToLower(req.Service)) {
|
||||||
|
found := false
|
||||||
|
for _, p := range priority {
|
||||||
|
if strings.EqualFold(p, req.Service) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newPriority := []string{req.Service}
|
||||||
|
for _, p := range priority {
|
||||||
|
if !strings.EqualFold(p, req.Service) {
|
||||||
|
newPriority = append(newPriority, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
priority = newPriority
|
||||||
|
if !found {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Extension service '%s' added to priority front\n", req.Service)
|
||||||
|
} else {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Extension service '%s' moved to priority front\n", req.Service)
|
||||||
|
}
|
||||||
|
GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority)
|
||||||
}
|
}
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
@@ -742,6 +977,24 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
if enrichedTrack.Artists != "" {
|
if enrichedTrack.Artists != "" {
|
||||||
req.ArtistName = enrichedTrack.Artists
|
req.ArtistName = enrichedTrack.Artists
|
||||||
}
|
}
|
||||||
|
if enrichedTrack.AlbumName != "" && req.AlbumName == "" {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] AlbumName from enrichment: %s\n", enrichedTrack.AlbumName)
|
||||||
|
req.AlbumName = enrichedTrack.AlbumName
|
||||||
|
}
|
||||||
|
if enrichedTrack.AlbumArtist != "" && req.AlbumArtist == "" {
|
||||||
|
req.AlbumArtist = enrichedTrack.AlbumArtist
|
||||||
|
}
|
||||||
|
if enrichedTrack.DurationMS > 0 && req.DurationMS == 0 {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] DurationMS from enrichment: %d\n", enrichedTrack.DurationMS)
|
||||||
|
req.DurationMS = enrichedTrack.DurationMS
|
||||||
|
}
|
||||||
|
if enrichedTrack.CoverURL != "" && req.CoverURL == "" {
|
||||||
|
req.CoverURL = enrichedTrack.CoverURL
|
||||||
|
}
|
||||||
|
if enrichedTrack.ID != "" && req.SpotifyID == "" {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Track ID from enrichment: %s\n", enrichedTrack.ID)
|
||||||
|
req.SpotifyID = enrichedTrack.ID
|
||||||
|
}
|
||||||
if enrichedTrack.Label != "" && req.Label == "" {
|
if enrichedTrack.Label != "" && req.Label == "" {
|
||||||
GoLog("[DownloadWithExtensionFallback] Label from enrichment: %s\n", enrichedTrack.Label)
|
GoLog("[DownloadWithExtensionFallback] Label from enrichment: %s\n", enrichedTrack.Label)
|
||||||
req.Label = enrichedTrack.Label
|
req.Label = enrichedTrack.Label
|
||||||
@@ -762,6 +1015,77 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If key metadata is still missing after extension enrichment, search
|
||||||
|
// configured metadata providers (Spotify/Deezer/Tidal/Qobuz) — same
|
||||||
|
// logic that ReEnrichFile uses.
|
||||||
|
if req.Source != "" && !isBuiltInProvider(strings.ToLower(req.Source)) &&
|
||||||
|
req.TrackName != "" && req.ArtistName != "" &&
|
||||||
|
(req.AlbumName == "" || req.ReleaseDate == "" || req.ISRC == "") {
|
||||||
|
|
||||||
|
searchQuery := req.TrackName + " " + req.ArtistName
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Metadata incomplete, searching providers for: %s\n", searchQuery)
|
||||||
|
|
||||||
|
tracks, searchErr := extManager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
|
||||||
|
if searchErr == nil && len(tracks) > 0 {
|
||||||
|
track := tracks[0]
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Metadata match (%s): %s - %s (album: %s, date: %s, isrc: %s)\n",
|
||||||
|
track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate, track.ISRC)
|
||||||
|
|
||||||
|
if track.AlbumName != "" && req.AlbumName == "" {
|
||||||
|
req.AlbumName = track.AlbumName
|
||||||
|
}
|
||||||
|
if track.AlbumArtist != "" && req.AlbumArtist == "" {
|
||||||
|
req.AlbumArtist = track.AlbumArtist
|
||||||
|
}
|
||||||
|
if track.ReleaseDate != "" && req.ReleaseDate == "" {
|
||||||
|
req.ReleaseDate = track.ReleaseDate
|
||||||
|
}
|
||||||
|
if track.ISRC != "" && req.ISRC == "" {
|
||||||
|
req.ISRC = track.ISRC
|
||||||
|
}
|
||||||
|
if track.TrackNumber > 0 && req.TrackNumber == 0 {
|
||||||
|
req.TrackNumber = track.TrackNumber
|
||||||
|
}
|
||||||
|
if track.DiscNumber > 0 && req.DiscNumber == 0 {
|
||||||
|
req.DiscNumber = track.DiscNumber
|
||||||
|
}
|
||||||
|
if track.CoverURL != "" && req.CoverURL == "" {
|
||||||
|
req.CoverURL = track.CoverURL
|
||||||
|
}
|
||||||
|
if track.Genre != "" && req.Genre == "" {
|
||||||
|
req.Genre = track.Genre
|
||||||
|
}
|
||||||
|
if track.Label != "" && req.Label == "" {
|
||||||
|
req.Label = track.Label
|
||||||
|
}
|
||||||
|
if track.Copyright != "" && req.Copyright == "" {
|
||||||
|
req.Copyright = track.Copyright
|
||||||
|
}
|
||||||
|
} else if searchErr != nil {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Metadata provider search failed (non-fatal): %v\n", searchErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Deezer extended metadata if we have ISRC
|
||||||
|
if req.ISRC != "" &&
|
||||||
|
(req.Genre == "" || req.Label == "" || req.Copyright == "") {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
extMeta, err := GetDeezerClient().GetExtendedMetadataByISRC(ctx, req.ISRC)
|
||||||
|
cancel()
|
||||||
|
if err == nil && extMeta != nil {
|
||||||
|
if req.Genre == "" && extMeta.Genre != "" {
|
||||||
|
req.Genre = extMeta.Genre
|
||||||
|
}
|
||||||
|
if req.Label == "" && extMeta.Label != "" {
|
||||||
|
req.Label = extMeta.Label
|
||||||
|
}
|
||||||
|
if req.Copyright == "" && extMeta.Copyright != "" {
|
||||||
|
req.Copyright = extMeta.Copyright
|
||||||
|
}
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Extended metadata from Deezer: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if req.Source != "" &&
|
if req.Source != "" &&
|
||||||
!isBuiltInProvider(strings.ToLower(req.Source)) &&
|
!isBuiltInProvider(strings.ToLower(req.Source)) &&
|
||||||
(!strictMode || selectedProvider == "" || strings.EqualFold(selectedProvider, req.Source)) {
|
(!strictMode || selectedProvider == "" || strings.EqualFold(selectedProvider, req.Source)) {
|
||||||
@@ -777,7 +1101,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn)
|
GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn)
|
||||||
|
|
||||||
outputPath := buildOutputPath(req)
|
outputPath := buildOutputPathForExtension(req, ext)
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
StartItemProgress(req.ItemID)
|
StartItemProgress(req.ItemID)
|
||||||
}
|
}
|
||||||
@@ -813,6 +1137,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
Genre: req.Genre,
|
Genre: req.Genre,
|
||||||
Label: req.Label,
|
Label: req.Label,
|
||||||
Copyright: req.Copyright,
|
Copyright: req.Copyright,
|
||||||
|
DecryptionKey: result.DecryptionKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
|
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
|
||||||
@@ -854,6 +1179,30 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always pass enriched metadata from req so Flutter can
|
||||||
|
// embed it — fills gaps from metadata provider search.
|
||||||
|
if req.AlbumName != "" && resp.Album == "" {
|
||||||
|
resp.Album = req.AlbumName
|
||||||
|
}
|
||||||
|
if req.AlbumArtist != "" && resp.AlbumArtist == "" {
|
||||||
|
resp.AlbumArtist = req.AlbumArtist
|
||||||
|
}
|
||||||
|
if req.ReleaseDate != "" && resp.ReleaseDate == "" {
|
||||||
|
resp.ReleaseDate = req.ReleaseDate
|
||||||
|
}
|
||||||
|
if req.ISRC != "" && resp.ISRC == "" {
|
||||||
|
resp.ISRC = req.ISRC
|
||||||
|
}
|
||||||
|
if req.TrackNumber > 0 && resp.TrackNumber == 0 {
|
||||||
|
resp.TrackNumber = req.TrackNumber
|
||||||
|
}
|
||||||
|
if req.DiscNumber > 0 && resp.DiscNumber == 0 {
|
||||||
|
resp.DiscNumber = req.DiscNumber
|
||||||
|
}
|
||||||
|
if req.CoverURL != "" && resp.CoverURL == "" {
|
||||||
|
resp.CoverURL = req.CoverURL
|
||||||
|
}
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -904,7 +1253,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
|
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
|
||||||
|
|
||||||
if isBuiltInProvider(providerIDNormalized) {
|
if isBuiltInProvider(providerIDNormalized) {
|
||||||
if (req.Genre == "" || req.Label == "") && req.ISRC != "" {
|
if (req.Genre == "" || req.Label == "" || req.Copyright == "") &&
|
||||||
|
req.ISRC != "" {
|
||||||
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
|
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
deezerClient := GetDeezerClient()
|
deezerClient := GetDeezerClient()
|
||||||
@@ -919,6 +1269,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
req.Label = extMeta.Label
|
req.Label = extMeta.Label
|
||||||
GoLog("[DownloadWithExtensionFallback] Label from Deezer: %s\n", req.Label)
|
GoLog("[DownloadWithExtensionFallback] Label from Deezer: %s\n", req.Label)
|
||||||
}
|
}
|
||||||
|
if req.Copyright == "" && extMeta.Copyright != "" {
|
||||||
|
req.Copyright = extMeta.Copyright
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Copyright from Deezer: %s\n", req.Copyright)
|
||||||
|
}
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
GoLog("[DownloadWithExtensionFallback] Failed to get extended metadata from Deezer: %v\n", err)
|
GoLog("[DownloadWithExtensionFallback] Failed to get extended metadata from Deezer: %v\n", err)
|
||||||
}
|
}
|
||||||
@@ -966,7 +1320,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
|
|
||||||
provider := NewExtensionProviderWrapper(ext)
|
provider := NewExtensionProviderWrapper(ext)
|
||||||
|
|
||||||
availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName)
|
availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID)
|
||||||
if err != nil || !availability.Available {
|
if err != nil || !availability.Available {
|
||||||
GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID)
|
GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -975,7 +1329,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
outputPath := buildOutputPath(req)
|
outputPath := buildOutputPathForExtension(req, ext)
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
StartItemProgress(req.ItemID)
|
StartItemProgress(req.ItemID)
|
||||||
}
|
}
|
||||||
@@ -1011,6 +1365,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
Genre: req.Genre,
|
Genre: req.Genre,
|
||||||
Label: req.Label,
|
Label: req.Label,
|
||||||
Copyright: req.Copyright,
|
Copyright: req.Copyright,
|
||||||
|
DecryptionKey: result.DecryptionKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
|
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
|
||||||
@@ -1128,25 +1483,6 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = qobuzErr
|
err = qobuzErr
|
||||||
case "amazon":
|
|
||||||
amazonResult, amazonErr := downloadFromAmazon(req)
|
|
||||||
if amazonErr == nil {
|
|
||||||
result = DownloadResult{
|
|
||||||
FilePath: amazonResult.FilePath,
|
|
||||||
BitDepth: amazonResult.BitDepth,
|
|
||||||
SampleRate: amazonResult.SampleRate,
|
|
||||||
Title: amazonResult.Title,
|
|
||||||
Artist: amazonResult.Artist,
|
|
||||||
Album: amazonResult.Album,
|
|
||||||
ReleaseDate: amazonResult.ReleaseDate,
|
|
||||||
TrackNumber: amazonResult.TrackNumber,
|
|
||||||
DiscNumber: amazonResult.DiscNumber,
|
|
||||||
ISRC: amazonResult.ISRC,
|
|
||||||
LyricsLRC: amazonResult.LyricsLRC,
|
|
||||||
DecryptionKey: amazonResult.DecryptionKey,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
err = amazonErr
|
|
||||||
case "deezer":
|
case "deezer":
|
||||||
deezerResult, deezerErr := downloadFromDeezer(req)
|
deezerResult, deezerErr := downloadFromDeezer(req)
|
||||||
if deezerErr == nil {
|
if deezerErr == nil {
|
||||||
@@ -1226,7 +1562,58 @@ func buildOutputPath(req DownloadRequest) string {
|
|||||||
ext = "." + ext
|
ext = "." + ext
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("%s/%s%s", req.OutputDir, filename, ext)
|
outputDir := req.OutputDir
|
||||||
|
if strings.TrimSpace(outputDir) == "" {
|
||||||
|
outputDir = filepath.Join(os.TempDir(), "spotiflac-downloads")
|
||||||
|
os.MkdirAll(outputDir, 0755)
|
||||||
|
AddAllowedDownloadDir(outputDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(outputDir, filename+ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildOutputPathForExtension(req DownloadRequest, ext *LoadedExtension) string {
|
||||||
|
if strings.TrimSpace(req.OutputPath) != "" {
|
||||||
|
return strings.TrimSpace(req.OutputPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(req.OutputDir) != "" {
|
||||||
|
return buildOutputPath(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAF mode: use extension's data dir as writable temp location
|
||||||
|
tempDir := filepath.Join(ext.DataDir, "downloads")
|
||||||
|
os.MkdirAll(tempDir, 0755)
|
||||||
|
AddAllowedDownloadDir(tempDir)
|
||||||
|
|
||||||
|
metadata := map[string]interface{}{
|
||||||
|
"title": req.TrackName,
|
||||||
|
"artist": req.ArtistName,
|
||||||
|
"album": req.AlbumName,
|
||||||
|
"album_artist": req.AlbumArtist,
|
||||||
|
"track": req.TrackNumber,
|
||||||
|
"track_number": req.TrackNumber,
|
||||||
|
"disc": req.DiscNumber,
|
||||||
|
"disc_number": req.DiscNumber,
|
||||||
|
"year": extractYear(req.ReleaseDate),
|
||||||
|
"date": req.ReleaseDate,
|
||||||
|
"release_date": req.ReleaseDate,
|
||||||
|
"isrc": req.ISRC,
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
|
||||||
|
if filename == "" {
|
||||||
|
filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName))
|
||||||
|
}
|
||||||
|
|
||||||
|
outputExt := strings.TrimSpace(req.OutputExt)
|
||||||
|
if outputExt == "" {
|
||||||
|
outputExt = ".flac"
|
||||||
|
} else if !strings.HasPrefix(outputExt, ".") {
|
||||||
|
outputExt = "." + outputExt
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(tempDir, filename+outputExt)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
|
func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
|
||||||
@@ -1374,6 +1761,12 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
|
|||||||
handleResult.Artist.Albums[i].Tracks[j].ProviderID = p.extension.ID
|
handleResult.Artist.Albums[i].Tracks[j].ProviderID = p.extension.ID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for i := range handleResult.Artist.Releases {
|
||||||
|
handleResult.Artist.Releases[i].ProviderID = p.extension.ID
|
||||||
|
for j := range handleResult.Artist.Releases[i].Tracks {
|
||||||
|
handleResult.Artist.Releases[i].Tracks[j].ProviderID = p.extension.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
for i := range handleResult.Artist.TopTracks {
|
for i := range handleResult.Artist.TopTracks {
|
||||||
handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID
|
handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID
|
||||||
}
|
}
|
||||||
@@ -1653,7 +2046,6 @@ func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResu
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPostProcessingProviders returns all extensions that provide post-processing
|
|
||||||
func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrapper {
|
func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrapper {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
@@ -1667,7 +2059,6 @@ func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrap
|
|||||||
return providers
|
return providers
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunPostProcessing runs all enabled post-processing hooks on a file
|
|
||||||
func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[string]interface{}) (*PostProcessResult, error) {
|
func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[string]interface{}) (*PostProcessResult, error) {
|
||||||
providers := m.GetPostProcessingProviders()
|
providers := m.GetPostProcessingProviders()
|
||||||
if len(providers) == 0 {
|
if len(providers) == 0 {
|
||||||
@@ -1713,7 +2104,6 @@ func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[strin
|
|||||||
return &PostProcessResult{Success: true, NewFilePath: currentPath}, nil
|
return &PostProcessResult{Success: true, NewFilePath: currentPath}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunPostProcessingV2 runs all enabled post-processing hooks on a file input.
|
|
||||||
func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]interface{}) (*PostProcessResult, error) {
|
func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]interface{}) (*PostProcessResult, error) {
|
||||||
providers := m.GetPostProcessingProviders()
|
providers := m.GetPostProcessingProviders()
|
||||||
if len(providers) == 0 {
|
if len(providers) == 0 {
|
||||||
@@ -1768,9 +2158,6 @@ func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata
|
|||||||
return &PostProcessResult{Success: true, NewFilePath: currentInput.Path, NewFileURI: currentInput.URI}, nil
|
return &PostProcessResult{Success: true, NewFilePath: currentInput.Path, NewFileURI: currentInput.URI}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Lyrics Provider ====================
|
|
||||||
|
|
||||||
// ExtLyricsResult represents lyrics data returned from an extension
|
|
||||||
type ExtLyricsResult struct {
|
type ExtLyricsResult struct {
|
||||||
Lines []ExtLyricsLine `json:"lines"`
|
Lines []ExtLyricsLine `json:"lines"`
|
||||||
SyncType string `json:"syncType"`
|
SyncType string `json:"syncType"`
|
||||||
@@ -1785,7 +2172,6 @@ type ExtLyricsLine struct {
|
|||||||
EndTimeMs int64 `json:"endTimeMs"`
|
EndTimeMs int64 `json:"endTimeMs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchLyrics calls the extension's fetchLyrics function
|
|
||||||
func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName string, durationSec float64) (*LyricsResponse, error) {
|
func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName string, durationSec float64) (*LyricsResponse, error) {
|
||||||
if !p.extension.Manifest.IsLyricsProvider() {
|
if !p.extension.Manifest.IsLyricsProvider() {
|
||||||
return nil, fmt.Errorf("extension '%s' is not a lyrics provider", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is not a lyrics provider", p.extension.ID)
|
||||||
@@ -1885,7 +2271,6 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLyricsProviders returns all enabled extensions that provide lyrics
|
|
||||||
func (m *ExtensionManager) GetLyricsProviders() []*ExtensionProviderWrapper {
|
func (m *ExtensionManager) GetLyricsProviders() []*ExtensionProviderWrapper {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
|
||||||
|
original := GetMetadataProviderPriority()
|
||||||
|
defer SetMetadataProviderPriority(original)
|
||||||
|
|
||||||
|
SetMetadataProviderPriority([]string{"tidal"})
|
||||||
|
got := GetMetadataProviderPriority()
|
||||||
|
want := []string{"tidal", "deezer", "qobuz"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("unexpected priority length: got %v want %v", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
|
||||||
|
originalPriority := GetMetadataProviderPriority()
|
||||||
|
originalSearch := searchBuiltInMetadataTracksFunc
|
||||||
|
defer func() {
|
||||||
|
SetMetadataProviderPriority(originalPriority)
|
||||||
|
searchBuiltInMetadataTracksFunc = originalSearch
|
||||||
|
}()
|
||||||
|
|
||||||
|
SetMetadataProviderPriority([]string{"qobuz", "tidal", "deezer"})
|
||||||
|
|
||||||
|
var calls []string
|
||||||
|
searchBuiltInMetadataTracksFunc = func(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
|
||||||
|
calls = append(calls, providerID)
|
||||||
|
switch providerID {
|
||||||
|
case "qobuz":
|
||||||
|
return []ExtTrackMetadata{
|
||||||
|
{ProviderID: "qobuz", SpotifyID: "qobuz:1", ISRC: "AAA111", Name: "First"},
|
||||||
|
}, nil
|
||||||
|
case "tidal":
|
||||||
|
return []ExtTrackMetadata{
|
||||||
|
{ProviderID: "tidal", SpotifyID: "tidal:2", ISRC: "AAA111", Name: "Duplicate"},
|
||||||
|
{ProviderID: "tidal", SpotifyID: "tidal:3", ISRC: "BBB222", Name: "Second"},
|
||||||
|
}, nil
|
||||||
|
case "deezer":
|
||||||
|
return []ExtTrackMetadata{
|
||||||
|
{ProviderID: "deezer", SpotifyID: "deezer:4", ISRC: "CCC333", Name: "Third"},
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := GetExtensionManager()
|
||||||
|
tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(tracks) != 3 {
|
||||||
|
t.Fatalf("unexpected track count: got %d want 3", len(tracks))
|
||||||
|
}
|
||||||
|
if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" || tracks[2].ProviderID != "deezer" {
|
||||||
|
t.Fatalf("unexpected track provider order: %+v", tracks)
|
||||||
|
}
|
||||||
|
if len(calls) != 3 || calls[0] != "qobuz" || calls[1] != "tidal" || calls[2] != "deezer" {
|
||||||
|
t.Fatalf("unexpected provider call order: %v", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides Auth API and PKCE support for extension runtime
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -16,8 +15,6 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== Auth API (OAuth Support) ====================
|
|
||||||
|
|
||||||
func validateExtensionAuthURL(urlStr string) error {
|
func validateExtensionAuthURL(urlStr string) error {
|
||||||
parsed, err := url.Parse(urlStr)
|
parsed, err := url.Parse(urlStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -204,9 +201,6 @@ func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(result)
|
return r.vm.ToValue(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== PKCE Support ====================
|
|
||||||
|
|
||||||
// generatePKCEVerifier generates a cryptographically random code verifier
|
|
||||||
// Length should be between 43-128 characters (RFC 7636)
|
// 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 {
|
||||||
@@ -394,9 +388,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// authExchangeCodeWithPKCE exchanges auth code for tokens using PKCE
|
|
||||||
// config: { tokenUrl, clientId, redirectUri, code, extraParams }
|
// config: { tokenUrl, clientId, redirectUri, code, extraParams }
|
||||||
// Uses the stored PKCE verifier automatically
|
|
||||||
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{}{
|
||||||
@@ -414,7 +406,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Required fields
|
|
||||||
tokenURL, _ := config["tokenUrl"].(string)
|
tokenURL, _ := config["tokenUrl"].(string)
|
||||||
clientID, _ := config["clientId"].(string)
|
clientID, _ := config["clientId"].(string)
|
||||||
redirectURI, _ := config["redirectUri"].(string)
|
redirectURI, _ := config["redirectUri"].(string)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides FFmpeg API for extension runtime
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -10,9 +9,7 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== FFmpeg API (Post-Processing) ====================
|
// FFmpegCommand holds a pending FFmpeg command for Flutter to execute.
|
||||||
|
|
||||||
// FFmpegCommand holds a pending FFmpeg command for Flutter to execute
|
|
||||||
type FFmpegCommand struct {
|
type FFmpegCommand struct {
|
||||||
ExtensionID string
|
ExtensionID string
|
||||||
Command string
|
Command string
|
||||||
@@ -24,7 +21,6 @@ type FFmpegCommand struct {
|
|||||||
Output string
|
Output string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global FFmpeg command queue
|
|
||||||
var (
|
var (
|
||||||
ffmpegCommands = make(map[string]*FFmpegCommand)
|
ffmpegCommands = make(map[string]*FFmpegCommand)
|
||||||
ffmpegCommandsMu sync.RWMutex
|
ffmpegCommandsMu sync.RWMutex
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides File API for extension runtime
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -13,8 +12,6 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== File API (Sandboxed) ====================
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
allowedDownloadDirs []string
|
allowedDownloadDirs []string
|
||||||
allowedDownloadDirsMu sync.RWMutex
|
allowedDownloadDirsMu sync.RWMutex
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides HTTP API for extension runtime
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -12,8 +11,6 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== HTTP API (Sandboxed) ====================
|
|
||||||
|
|
||||||
type HTTPResponse struct {
|
type HTTPResponse struct {
|
||||||
StatusCode int `json:"statusCode"`
|
StatusCode int `json:"statusCode"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides Track Matching API for extension runtime
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -7,8 +6,6 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== Track Matching API ====================
|
|
||||||
|
|
||||||
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(0.0)
|
return r.vm.ToValue(0.0)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides Browser-like Polyfills for extension runtime
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -13,12 +12,10 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== Browser-like Polyfills ====================
|
|
||||||
// These polyfills make porting browser/Node.js libraries easier
|
// These polyfills make porting browser/Node.js libraries easier
|
||||||
// without compromising sandbox security
|
// without compromising sandbox security.
|
||||||
|
|
||||||
// fetchPolyfill implements browser-compatible fetch() API
|
// Returns a Promise-like object with json(), text() methods.
|
||||||
// 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")
|
||||||
@@ -141,7 +138,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
return responseObj
|
return responseObj
|
||||||
}
|
}
|
||||||
|
|
||||||
// createFetchError creates a fetch error response
|
|
||||||
func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
|
func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
|
||||||
errorObj := r.vm.NewObject()
|
errorObj := r.vm.NewObject()
|
||||||
errorObj.Set("ok", false)
|
errorObj.Set("ok", false)
|
||||||
@@ -157,7 +153,6 @@ func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
|
|||||||
return errorObj
|
return errorObj
|
||||||
}
|
}
|
||||||
|
|
||||||
// atobPolyfill implements browser atob() - decode base64 to string
|
|
||||||
func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
@@ -174,7 +169,6 @@ func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(string(decoded))
|
return r.vm.ToValue(string(decoded))
|
||||||
}
|
}
|
||||||
|
|
||||||
// btoaPolyfill implements browser btoa() - encode string to base64
|
|
||||||
func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
@@ -183,7 +177,6 @@ func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerTextEncoderDecoder registers TextEncoder and TextDecoder classes
|
|
||||||
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||||
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
|
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
|
||||||
encoder := call.This
|
encoder := call.This
|
||||||
@@ -429,9 +422,8 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerJSONGlobal ensures JSON global is properly set up
|
// 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) {
|
||||||
// JSON is already built-in to Goja, but we can enhance it
|
|
||||||
jsonScript := `
|
jsonScript := `
|
||||||
if (typeof JSON === 'undefined') {
|
if (typeof JSON === 'undefined') {
|
||||||
var JSON = {
|
var JSON = {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides Storage and Credentials API for extension runtime
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -17,8 +16,6 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== Storage API ====================
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultStorageFlushDelay = 400 * time.Millisecond
|
defaultStorageFlushDelay = 400 * time.Millisecond
|
||||||
storageFlushRetryDelay = 2 * time.Second
|
storageFlushRetryDelay = 2 * time.Second
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides Utility functions for extension runtime
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -17,8 +16,6 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== Utility Functions ====================
|
|
||||||
|
|
||||||
func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides extension settings storage
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -129,9 +130,8 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultRegistryURL = "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Extension/main/registry.json"
|
cacheTTL = 30 * time.Minute
|
||||||
cacheTTL = 30 * time.Minute
|
cacheFileName = "store_cache.json"
|
||||||
cacheFileName = "store_cache.json"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func InitExtensionStore(cacheDir string) *ExtensionStore {
|
func InitExtensionStore(cacheDir string) *ExtensionStore {
|
||||||
@@ -140,7 +140,7 @@ func InitExtensionStore(cacheDir string) *ExtensionStore {
|
|||||||
|
|
||||||
if extensionStore == nil {
|
if extensionStore == nil {
|
||||||
extensionStore = &ExtensionStore{
|
extensionStore = &ExtensionStore{
|
||||||
registryURL: defaultRegistryURL,
|
registryURL: "", // No default - user must provide a registry URL
|
||||||
cacheDir: cacheDir,
|
cacheDir: cacheDir,
|
||||||
cacheTTL: cacheTTL,
|
cacheTTL: cacheTTL,
|
||||||
}
|
}
|
||||||
@@ -149,6 +149,36 @@ func InitExtensionStore(cacheDir string) *ExtensionStore {
|
|||||||
return extensionStore
|
return extensionStore
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetRegistryURL updates the registry URL and clears the in-memory cache
|
||||||
|
// so the next fetch will use the new URL. Disk cache is also cleared.
|
||||||
|
func (s *ExtensionStore) SetRegistryURL(registryURL string) {
|
||||||
|
s.cacheMu.Lock()
|
||||||
|
defer s.cacheMu.Unlock()
|
||||||
|
|
||||||
|
if s.registryURL == registryURL {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.registryURL = registryURL
|
||||||
|
s.cache = nil
|
||||||
|
s.cacheTime = time.Time{}
|
||||||
|
|
||||||
|
// Clear disk cache since it's from a different registry
|
||||||
|
if s.cacheDir != "" {
|
||||||
|
cachePath := filepath.Join(s.cacheDir, cacheFileName)
|
||||||
|
os.Remove(cachePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
LogInfo("ExtensionStore", "Registry URL updated to: %s", registryURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRegistryURL returns the currently configured registry URL.
|
||||||
|
func (s *ExtensionStore) GetRegistryURL() string {
|
||||||
|
s.cacheMu.RLock()
|
||||||
|
defer s.cacheMu.RUnlock()
|
||||||
|
return s.registryURL
|
||||||
|
}
|
||||||
|
|
||||||
func GetExtensionStore() *ExtensionStore {
|
func GetExtensionStore() *ExtensionStore {
|
||||||
extensionStoreMu.Lock()
|
extensionStoreMu.Lock()
|
||||||
defer extensionStoreMu.Unlock()
|
defer extensionStoreMu.Unlock()
|
||||||
@@ -206,6 +236,11 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
|
|||||||
s.cacheMu.Lock()
|
s.cacheMu.Lock()
|
||||||
defer s.cacheMu.Unlock()
|
defer s.cacheMu.Unlock()
|
||||||
|
|
||||||
|
// Check if a registry URL has been configured
|
||||||
|
if s.registryURL == "" {
|
||||||
|
return nil, fmt.Errorf("no registry URL configured. Please add a repository URL first")
|
||||||
|
}
|
||||||
|
|
||||||
if !forceRefresh && s.cache != nil && time.Since(s.cacheTime) < s.cacheTTL {
|
if !forceRefresh && s.cache != nil && time.Since(s.cacheTime) < s.cacheTTL {
|
||||||
LogDebug("ExtensionStore", "Using cached registry (%d extensions)", len(s.cache.Extensions))
|
LogDebug("ExtensionStore", "Using cached registry (%d extensions)", len(s.cache.Extensions))
|
||||||
return s.cache, nil
|
return s.cache, nil
|
||||||
@@ -336,6 +371,81 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolveRegistryURL normalises a user-supplied URL into a direct registry.json URL.
|
||||||
|
//
|
||||||
|
// Accepted formats:
|
||||||
|
// - https://raw.githubusercontent.com/owner/repo/<branch>/registry.json → returned as-is
|
||||||
|
// - https://github.com/owner/repo (with optional trailing path / .git) → resolved via
|
||||||
|
// the GitHub API to discover the default branch, then converted to the raw URL
|
||||||
|
// - Any other HTTPS URL → returned as-is (assumed to be a direct link)
|
||||||
|
func ResolveRegistryURL(input string) (string, error) {
|
||||||
|
input = strings.TrimSpace(input)
|
||||||
|
if input == "" {
|
||||||
|
return "", fmt.Errorf("registry URL is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already a fully-qualified raw URL – keep it.
|
||||||
|
if strings.Contains(input, "raw.githubusercontent.com") {
|
||||||
|
return input, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to match https://github.com/<owner>/<repo>[/...]
|
||||||
|
const ghPrefix = "https://github.com/"
|
||||||
|
if !strings.HasPrefix(input, ghPrefix) {
|
||||||
|
// Also accept http:// and upgrade silently.
|
||||||
|
const ghPrefixHTTP = "http://github.com/"
|
||||||
|
if strings.HasPrefix(input, ghPrefixHTTP) {
|
||||||
|
input = "https://github.com/" + input[len(ghPrefixHTTP):]
|
||||||
|
} else {
|
||||||
|
// Not a GitHub URL – return as-is.
|
||||||
|
return input, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path := input[len(ghPrefix):]
|
||||||
|
parts := strings.SplitN(path, "/", 3) // owner, repo, [rest]
|
||||||
|
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
|
||||||
|
return "", fmt.Errorf("invalid GitHub URL: expected github.com/<owner>/<repo>")
|
||||||
|
}
|
||||||
|
owner := parts[0]
|
||||||
|
repo := strings.TrimSuffix(parts[1], ".git")
|
||||||
|
|
||||||
|
branch := resolveGitHubDefaultBranch(owner, repo)
|
||||||
|
|
||||||
|
resolved := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/registry.json", owner, repo, branch)
|
||||||
|
LogInfo("ExtensionStore", "Resolved %s → %s (branch: %s)", input, resolved, branch)
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveGitHubDefaultBranch calls the GitHub API to discover the repository's
|
||||||
|
// default branch. Falls back to "main" on any error.
|
||||||
|
func resolveGitHubDefaultBranch(owner, repo string) string {
|
||||||
|
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repo)
|
||||||
|
client := NewHTTPClientWithTimeout(10 * time.Second)
|
||||||
|
|
||||||
|
resp, err := client.Get(apiURL)
|
||||||
|
if err != nil {
|
||||||
|
LogWarn("ExtensionStore", "GitHub API request failed for %s/%s: %v – falling back to main", owner, repo, err)
|
||||||
|
return "main"
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
LogWarn("ExtensionStore", "GitHub API returned %d for %s/%s – falling back to main", resp.StatusCode, owner, repo)
|
||||||
|
return "main"
|
||||||
|
}
|
||||||
|
|
||||||
|
var info struct {
|
||||||
|
DefaultBranch string `json:"default_branch"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil || info.DefaultBranch == "" {
|
||||||
|
LogWarn("ExtensionStore", "Could not parse default_branch for %s/%s – falling back to main", owner, repo)
|
||||||
|
return "main"
|
||||||
|
}
|
||||||
|
|
||||||
|
return info.DefaultBranch
|
||||||
|
}
|
||||||
|
|
||||||
func requireHTTPSURL(rawURL string, context string) error {
|
func requireHTTPSURL(rawURL string, context string) error {
|
||||||
if rawURL == "" {
|
if rawURL == "" {
|
||||||
return fmt.Errorf("%s URL is empty", context)
|
return fmt.Errorf("%s URL is empty", context)
|
||||||
@@ -374,12 +484,10 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
|
|||||||
queryLower := toLower(query)
|
queryLower := toLower(query)
|
||||||
|
|
||||||
for _, ext := range extensions {
|
for _, ext := range extensions {
|
||||||
// Filter by category
|
|
||||||
if category != "" && ext.Category != category {
|
if category != "" && ext.Category != category {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by query
|
|
||||||
if query != "" {
|
if query != "" {
|
||||||
if !containsIgnoreCase(ext.Name, queryLower) &&
|
if !containsIgnoreCase(ext.Name, queryLower) &&
|
||||||
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides timeout execution for extension JS code
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func getRandomUserAgent() string {
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
DefaultTimeout = 60 * time.Second
|
DefaultTimeout = 60 * time.Second
|
||||||
DownloadTimeout = 120 * time.Second
|
DownloadTimeout = 24 * time.Hour
|
||||||
SongLinkTimeout = 30 * time.Second
|
SongLinkTimeout = 30 * time.Second
|
||||||
DefaultMaxRetries = 3
|
DefaultMaxRetries = 3
|
||||||
DefaultRetryDelay = 1 * time.Second
|
DefaultRetryDelay = 1 * time.Second
|
||||||
@@ -350,7 +350,7 @@ func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Dur
|
|||||||
func getRetryAfterDuration(resp *http.Response) time.Duration {
|
func getRetryAfterDuration(resp *http.Response) time.Duration {
|
||||||
retryAfter := resp.Header.Get("Retry-After")
|
retryAfter := resp.Header.Get("Retry-After")
|
||||||
if retryAfter == "" {
|
if retryAfter == "" {
|
||||||
return 60 * time.Second // Default wait time
|
return 60 * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
if seconds, err := strconv.Atoi(retryAfter); err == nil {
|
if seconds, err := strconv.Atoi(retryAfter); err == nil {
|
||||||
@@ -364,7 +364,7 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 60 * time.Second // Default
|
return 60 * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
func ReadResponseBody(resp *http.Response) ([]byte, error) {
|
func ReadResponseBody(resp *http.Response) ([]byte, error) {
|
||||||
@@ -489,7 +489,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check error message patterns for common ISP blocking indicators
|
|
||||||
blockingPatterns := []struct {
|
blockingPatterns := []struct {
|
||||||
pattern string
|
pattern string
|
||||||
reason string
|
reason string
|
||||||
@@ -532,7 +531,6 @@ func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractDomain extracts the domain from a URL string
|
|
||||||
func extractDomain(rawURL string) string {
|
func extractDomain(rawURL string) string {
|
||||||
if rawURL == "" {
|
if rawURL == "" {
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|||||||
@@ -91,7 +91,6 @@ func (t *utlsTransport) getPort(u *url.URL) string {
|
|||||||
return "80"
|
return "80"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cloudflare bypass client using uTLS Chrome fingerprint
|
|
||||||
var cloudflareBypassTransport = newUTLSTransport()
|
var cloudflareBypassTransport = newUTLSTransport()
|
||||||
|
|
||||||
var cloudflareBypassClient = &http.Client{
|
var cloudflareBypassClient = &http.Client{
|
||||||
@@ -111,7 +110,6 @@ func GetCloudflareBypassClient() *http.Client {
|
|||||||
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())
|
||||||
|
|
||||||
// Try with standard client first
|
|
||||||
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)
|
// Check for Cloudflare challenge page (403 with specific markers)
|
||||||
@@ -138,11 +136,9 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
|||||||
if isCloudflare {
|
if isCloudflare {
|
||||||
LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...")
|
LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...")
|
||||||
|
|
||||||
// Clone request for retry
|
|
||||||
reqCopy := req.Clone(req.Context())
|
reqCopy := req.Clone(req.Context())
|
||||||
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
// Retry with uTLS Chrome fingerprint
|
|
||||||
return cloudflareBypassClient.Do(reqCopy)
|
return cloudflareBypassClient.Do(reqCopy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,11 +164,9 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
|||||||
if tlsRelated {
|
if tlsRelated {
|
||||||
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
|
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
|
||||||
|
|
||||||
// Clone request for retry
|
|
||||||
reqCopy := req.Clone(req.Context())
|
reqCopy := req.Clone(req.Context())
|
||||||
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
// Retry with uTLS Chrome fingerprint
|
|
||||||
return cloudflareBypassClient.Do(reqCopy)
|
return cloudflareBypassClient.Do(reqCopy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,13 +22,11 @@ var (
|
|||||||
idhsRateLimiter = NewRateLimiter(8, time.Minute) // 8 req/min (below 10 limit)
|
idhsRateLimiter = NewRateLimiter(8, time.Minute) // 8 req/min (below 10 limit)
|
||||||
)
|
)
|
||||||
|
|
||||||
// IDHSSearchRequest represents the request body for IDHS API
|
|
||||||
type IDHSSearchRequest struct {
|
type IDHSSearchRequest struct {
|
||||||
Link string `json:"link"`
|
Link string `json:"link"`
|
||||||
Adapters []string `json:"adapters,omitempty"`
|
Adapters []string `json:"adapters,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IDHSSearchResponse represents the response from IDHS API
|
|
||||||
type IDHSSearchResponse struct {
|
type IDHSSearchResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"` // song, album, artist, podcast, show
|
Type string `json:"type"` // song, album, artist, podcast, show
|
||||||
@@ -41,7 +39,6 @@ type IDHSSearchResponse struct {
|
|||||||
Links []IDHSLink `json:"links"`
|
Links []IDHSLink `json:"links"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IDHSLink represents a link to a streaming platform
|
|
||||||
type IDHSLink struct {
|
type IDHSLink struct {
|
||||||
Type string `json:"type"` // spotify, youTube, appleMusic, deezer, soundCloud, tidal
|
Type string `json:"type"` // spotify, youTube, appleMusic, deezer, soundCloud, tidal
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
@@ -49,7 +46,6 @@ type IDHSLink struct {
|
|||||||
NotAvailable bool `json:"notAvailable,omitempty"`
|
NotAvailable bool `json:"notAvailable,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewIDHSClient creates a new IDHS client
|
|
||||||
func NewIDHSClient() *IDHSClient {
|
func NewIDHSClient() *IDHSClient {
|
||||||
idhsClientOnce.Do(func() {
|
idhsClientOnce.Do(func() {
|
||||||
globalIDHSClient = &IDHSClient{
|
globalIDHSClient = &IDHSClient{
|
||||||
@@ -117,7 +113,6 @@ func (c *IDHSClient) Search(link string, adapters []string) (*IDHSSearchResponse
|
|||||||
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)
|
||||||
|
|
||||||
// Request only the platforms we need
|
|
||||||
adapters := []string{"tidal", "deezer"}
|
adapters := []string{"tidal", "deezer"}
|
||||||
|
|
||||||
result, err := c.Search(spotifyURL, adapters)
|
result, err := c.Search(spotifyURL, adapters)
|
||||||
@@ -151,11 +146,9 @@ func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAv
|
|||||||
return availability, nil
|
return availability, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAvailabilityFromDeezer checks track availability using IDHS
|
|
||||||
func (c *IDHSClient) GetAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
|
func (c *IDHSClient) GetAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
|
||||||
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
||||||
|
|
||||||
// Request only the platforms we need
|
|
||||||
adapters := []string{"spotify", "tidal"}
|
adapters := []string{"spotify", "tidal"}
|
||||||
|
|
||||||
result, err := c.Search(deezerURL, adapters)
|
result, err := c.Search(deezerURL, adapters)
|
||||||
|
|||||||
+262
-49
@@ -1,16 +1,17 @@
|
|||||||
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LibraryScanResult represents metadata from a scanned audio file
|
|
||||||
type LibraryScanResult struct {
|
type LibraryScanResult struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
TrackName string `json:"trackName"`
|
TrackName string `json:"trackName"`
|
||||||
@@ -42,7 +43,6 @@ type LibraryScanProgress struct {
|
|||||||
IsComplete bool `json:"is_complete"`
|
IsComplete bool `json:"is_complete"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IncrementalScanResult contains results of an incremental library scan
|
|
||||||
type IncrementalScanResult struct {
|
type IncrementalScanResult struct {
|
||||||
Scanned []LibraryScanResult `json:"scanned"` // New or updated files
|
Scanned []LibraryScanResult `json:"scanned"` // New or updated files
|
||||||
DeletedPaths []string `json:"deletedPaths"` // Files that no longer exist
|
DeletedPaths []string `json:"deletedPaths"` // Files that no longer exist
|
||||||
@@ -65,6 +65,7 @@ var supportedAudioFormats = map[string]bool{
|
|||||||
".mp3": true,
|
".mp3": true,
|
||||||
".opus": true,
|
".opus": true,
|
||||||
".ogg": true,
|
".ogg": true,
|
||||||
|
".cue": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
type libraryAudioFileInfo struct {
|
type libraryAudioFileInfo struct {
|
||||||
@@ -72,6 +73,11 @@ type libraryAudioFileInfo struct {
|
|||||||
modTime int64
|
modTime int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type scannedCueFileInfo struct {
|
||||||
|
sheet *CueSheet
|
||||||
|
audioPath string
|
||||||
|
}
|
||||||
|
|
||||||
func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) {
|
func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) {
|
||||||
var files []libraryAudioFileInfo
|
var files []libraryAudioFileInfo
|
||||||
|
|
||||||
@@ -145,12 +151,7 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
return "[]", err
|
return "[]", err
|
||||||
}
|
}
|
||||||
|
|
||||||
audioFiles := make([]string, 0, len(audioFileInfos))
|
totalFiles := len(audioFileInfos)
|
||||||
for _, fileInfo := range audioFileInfos {
|
|
||||||
audioFiles = append(audioFiles, fileInfo.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
totalFiles := len(audioFiles)
|
|
||||||
libraryScanProgressMu.Lock()
|
libraryScanProgressMu.Lock()
|
||||||
libraryScanProgress.TotalFiles = totalFiles
|
libraryScanProgress.TotalFiles = totalFiles
|
||||||
libraryScanProgressMu.Unlock()
|
libraryScanProgressMu.Unlock()
|
||||||
@@ -168,7 +169,31 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||||
errorCount := 0
|
errorCount := 0
|
||||||
|
|
||||||
for i, filePath := range audioFiles {
|
// Track audio files referenced by .cue sheets to avoid duplicates
|
||||||
|
cueReferencedAudioFiles := make(map[string]bool)
|
||||||
|
parsedCueFiles := make(map[string]scannedCueFileInfo)
|
||||||
|
|
||||||
|
// First pass: scan .cue files to collect referenced audio paths
|
||||||
|
for _, fileInfo := range audioFileInfos {
|
||||||
|
filePath := fileInfo.path
|
||||||
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
if ext == ".cue" {
|
||||||
|
sheet, err := ParseCueFile(filePath)
|
||||||
|
if err == nil && sheet.FileName != "" {
|
||||||
|
audioPath := ResolveCueAudioPath(filePath, sheet.FileName)
|
||||||
|
if audioPath != "" {
|
||||||
|
parsedCueFiles[filePath] = scannedCueFileInfo{
|
||||||
|
sheet: sheet,
|
||||||
|
audioPath: audioPath,
|
||||||
|
}
|
||||||
|
cueReferencedAudioFiles[audioPath] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, fileInfo := range audioFileInfos {
|
||||||
|
filePath := fileInfo.path
|
||||||
select {
|
select {
|
||||||
case <-cancelCh:
|
case <-cancelCh:
|
||||||
return "[]", fmt.Errorf("scan cancelled")
|
return "[]", fmt.Errorf("scan cancelled")
|
||||||
@@ -181,7 +206,42 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100
|
libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100
|
||||||
libraryScanProgressMu.Unlock()
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
result, err := scanAudioFile(filePath, scanTime)
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
|
||||||
|
// Handle .cue files: produce multiple track results
|
||||||
|
if ext == ".cue" {
|
||||||
|
var cueResults []LibraryScanResult
|
||||||
|
cueInfo, ok := parsedCueFiles[filePath]
|
||||||
|
if ok {
|
||||||
|
cueResults, err = scanCueSheetForLibrary(
|
||||||
|
filePath,
|
||||||
|
cueInfo.sheet,
|
||||||
|
cueInfo.audioPath,
|
||||||
|
"",
|
||||||
|
fileInfo.modTime,
|
||||||
|
scanTime,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
cueResults, err = ScanCueFileForLibrary(filePath, scanTime)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
errorCount++
|
||||||
|
GoLog("[LibraryScan] Error scanning cue %s: %v\n", filePath, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, cueResults...)
|
||||||
|
GoLog("[LibraryScan] CUE sheet %s: %d tracks\n", filepath.Base(filePath), len(cueResults))
|
||||||
|
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] {
|
||||||
|
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := scanAudioFileWithKnownModTime(filePath, scanTime, fileInfo.modTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorCount++
|
errorCount++
|
||||||
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
|
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
|
||||||
@@ -207,7 +267,15 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
||||||
ext := strings.ToLower(filepath.Ext(filePath))
|
return scanAudioFileWithKnownModTimeAndDisplayName(filePath, "", scanTime, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanAudioFileWithKnownModTime(filePath, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
||||||
|
return scanAudioFileWithKnownModTimeAndDisplayName(filePath, "", scanTime, knownModTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
||||||
|
ext := resolveLibraryAudioExt(filePath, displayNameHint)
|
||||||
|
|
||||||
result := &LibraryScanResult{
|
result := &LibraryScanResult{
|
||||||
ID: generateLibraryID(filePath),
|
ID: generateLibraryID(filePath),
|
||||||
@@ -216,8 +284,9 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
|||||||
Format: strings.TrimPrefix(ext, "."),
|
Format: strings.TrimPrefix(ext, "."),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get file modification time
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +294,7 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
|||||||
coverCacheDir := libraryCoverCacheDir
|
coverCacheDir := libraryCoverCacheDir
|
||||||
libraryCoverCacheMu.RUnlock()
|
libraryCoverCacheMu.RUnlock()
|
||||||
if coverCacheDir != "" && ext != ".m4a" {
|
if coverCacheDir != "" && ext != ".m4a" {
|
||||||
coverPath, err := SaveCoverToCache(filePath, coverCacheDir)
|
coverPath, err := SaveCoverToCacheWithHint(filePath, displayNameHint, coverCacheDir)
|
||||||
if err == nil && coverPath != "" {
|
if err == nil && coverPath != "" {
|
||||||
result.CoverPath = coverPath
|
result.CoverPath = coverPath
|
||||||
}
|
}
|
||||||
@@ -239,15 +308,31 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
|||||||
case ".mp3":
|
case ".mp3":
|
||||||
return scanMP3File(filePath, result)
|
return scanMP3File(filePath, result)
|
||||||
case ".opus", ".ogg":
|
case ".opus", ".ogg":
|
||||||
return scanOggFile(filePath, result)
|
return scanOggFile(filePath, result, displayNameHint)
|
||||||
default:
|
default:
|
||||||
return scanFromFilename(filePath, result)
|
return scanFromFilename(filePath, displayNameHint, result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyDefaultLibraryMetadata(filePath string, result *LibraryScanResult) {
|
func resolveLibraryAudioExt(filePath, displayNameHint string) string {
|
||||||
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
if ext != "" {
|
||||||
|
return ext
|
||||||
|
}
|
||||||
|
return strings.ToLower(filepath.Ext(displayNameHint))
|
||||||
|
}
|
||||||
|
|
||||||
|
func libraryDisplayNameOrPath(filePath, displayNameHint string) string {
|
||||||
|
if displayNameHint != "" {
|
||||||
|
return displayNameHint
|
||||||
|
}
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyDefaultLibraryMetadata(filePath, displayNameHint string, result *LibraryScanResult) {
|
||||||
|
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
|
||||||
if result.TrackName == "" {
|
if result.TrackName == "" {
|
||||||
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
result.TrackName = strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
|
||||||
}
|
}
|
||||||
if result.ArtistName == "" {
|
if result.ArtistName == "" {
|
||||||
result.ArtistName = "Unknown Artist"
|
result.ArtistName = "Unknown Artist"
|
||||||
@@ -260,7 +345,7 @@ func applyDefaultLibraryMetadata(filePath string, result *LibraryScanResult) {
|
|||||||
func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||||
metadata, err := ReadMetadata(filePath)
|
metadata, err := ReadMetadata(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return scanFromFilename(filePath, result)
|
return scanFromFilename(filePath, "", result)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.TrackName = metadata.Title
|
result.TrackName = metadata.Title
|
||||||
@@ -282,7 +367,7 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applyDefaultLibraryMetadata(filePath, result)
|
applyDefaultLibraryMetadata(filePath, "", result)
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -294,14 +379,14 @@ func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
|||||||
result.SampleRate = quality.SampleRate
|
result.SampleRate = quality.SampleRate
|
||||||
}
|
}
|
||||||
|
|
||||||
return scanFromFilename(filePath, result)
|
return scanFromFilename(filePath, "", result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||||
metadata, err := ReadID3Tags(filePath)
|
metadata, err := ReadID3Tags(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
|
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
|
||||||
return scanFromFilename(filePath, result)
|
return scanFromFilename(filePath, "", result)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.TrackName = metadata.Title
|
result.TrackName = metadata.Title
|
||||||
@@ -328,16 +413,16 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applyDefaultLibraryMetadata(filePath, result)
|
applyDefaultLibraryMetadata(filePath, "", result)
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||||
metadata, err := ReadOggVorbisComments(filePath)
|
metadata, err := ReadOggVorbisComments(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[LibraryScan] Ogg/Opus read error for %s: %v\n", filePath, err)
|
GoLog("[LibraryScan] Ogg/Opus read error for %s: %v\n", filePath, err)
|
||||||
return scanFromFilename(filePath, result)
|
return scanFromFilename(filePath, displayNameHint, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.TrackName = metadata.Title
|
result.TrackName = metadata.Title
|
||||||
@@ -360,13 +445,14 @@ func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applyDefaultLibraryMetadata(filePath, result)
|
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||||
filename := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
|
||||||
|
filename := strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
|
||||||
|
|
||||||
parts := strings.SplitN(filename, " - ", 2)
|
parts := strings.SplitN(filename, " - ", 2)
|
||||||
if len(parts) == 2 {
|
if len(parts) == 2 {
|
||||||
@@ -389,7 +475,7 @@ func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanR
|
|||||||
|
|
||||||
dir := filepath.Dir(filePath)
|
dir := filepath.Dir(filePath)
|
||||||
result.AlbumName = filepath.Base(dir)
|
result.AlbumName = filepath.Base(dir)
|
||||||
if result.AlbumName == "." || result.AlbumName == "" {
|
if result.AlbumName == "." || result.AlbumName == "" || result.AlbumName == "fd" || result.AlbumName == "self" {
|
||||||
result.AlbumName = "Unknown Album"
|
result.AlbumName = "Unknown Album"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,8 +522,12 @@ func CancelLibraryScan() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ReadAudioMetadata(filePath string) (string, error) {
|
func ReadAudioMetadata(filePath string) (string, error) {
|
||||||
|
return ReadAudioMetadataWithDisplayName(filePath, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string, error) {
|
||||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||||
result, err := scanAudioFile(filePath, scanTime)
|
result, err := scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -453,7 +543,43 @@ func ReadAudioMetadata(filePath string) (string, error) {
|
|||||||
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
|
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
|
||||||
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
|
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
|
||||||
// Only files that are new or have changed modification time will be scanned
|
// Only files that are new or have changed modification time will be scanned
|
||||||
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
|
func loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) {
|
||||||
|
existingFiles := make(map[string]int64)
|
||||||
|
if snapshotPath == "" {
|
||||||
|
return existingFiles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(snapshotPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(line, "\t", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
modTime, err := strconv.ParseInt(parts[0], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
existingFiles[parts[1]] = modTime
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return existingFiles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFiles map[string]int64) (string, error) {
|
||||||
if folderPath == "" {
|
if folderPath == "" {
|
||||||
return "{}", fmt.Errorf("folder path is empty")
|
return "{}", fmt.Errorf("folder path is empty")
|
||||||
}
|
}
|
||||||
@@ -466,22 +592,12 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
return "{}", fmt.Errorf("path is not a folder: %s", folderPath)
|
return "{}", fmt.Errorf("path is not a folder: %s", folderPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse existing files map
|
|
||||||
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))
|
||||||
|
|
||||||
// Reset progress
|
|
||||||
libraryScanProgressMu.Lock()
|
libraryScanProgressMu.Lock()
|
||||||
libraryScanProgress = LibraryScanProgress{}
|
libraryScanProgress = LibraryScanProgress{}
|
||||||
libraryScanProgressMu.Unlock()
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
// Setup cancellation
|
|
||||||
libraryScanCancelMu.Lock()
|
libraryScanCancelMu.Lock()
|
||||||
if libraryScanCancel != nil {
|
if libraryScanCancel != nil {
|
||||||
close(libraryScanCancel)
|
close(libraryScanCancel)
|
||||||
@@ -490,7 +606,6 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
cancelCh := libraryScanCancel
|
cancelCh := libraryScanCancel
|
||||||
libraryScanCancelMu.Unlock()
|
libraryScanCancelMu.Unlock()
|
||||||
|
|
||||||
// Collect all audio files with their mod times
|
|
||||||
currentFiles, err := collectLibraryAudioFiles(folderPath, cancelCh)
|
currentFiles, err := collectLibraryAudioFiles(folderPath, cancelCh)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "{}", err
|
return "{}", err
|
||||||
@@ -508,25 +623,51 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
// Find files to scan (new or modified)
|
// Find files to scan (new or modified)
|
||||||
var filesToScan []libraryAudioFileInfo
|
var filesToScan []libraryAudioFileInfo
|
||||||
skippedCount := 0
|
skippedCount := 0
|
||||||
|
existingCueTrackModTimes := make(map[string]int64)
|
||||||
|
for existingPath, modTime := range existingFiles {
|
||||||
|
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
|
||||||
|
baseCuePath := existingPath[:idx]
|
||||||
|
if _, exists := existingCueTrackModTimes[baseCuePath]; !exists {
|
||||||
|
existingCueTrackModTimes[baseCuePath] = modTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, f := range currentFiles {
|
for _, f := range currentFiles {
|
||||||
existingModTime, exists := existingFiles[f.path]
|
existingModTime, exists := existingFiles[f.path]
|
||||||
if !exists {
|
if !exists {
|
||||||
// New file
|
// For .cue files, also check if any virtual path entries exist
|
||||||
|
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
|
||||||
|
if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks {
|
||||||
|
// CUE file exists in DB via virtual paths; check if modTime changed
|
||||||
|
if f.modTime == cueTrackModTime {
|
||||||
|
skippedCount++
|
||||||
|
} else {
|
||||||
|
filesToScan = append(filesToScan, f)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
filesToScan = append(filesToScan, f)
|
filesToScan = append(filesToScan, f)
|
||||||
} else if f.modTime != existingModTime {
|
} else if f.modTime != existingModTime {
|
||||||
// Modified file
|
|
||||||
filesToScan = append(filesToScan, f)
|
filesToScan = append(filesToScan, f)
|
||||||
} else {
|
} else {
|
||||||
// Unchanged file - skip
|
|
||||||
skippedCount++
|
skippedCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find deleted files
|
|
||||||
var deletedPaths []string
|
var deletedPaths []string
|
||||||
for existingPath := range existingFiles {
|
for existingPath := range existingFiles {
|
||||||
if !currentPathSet[existingPath] {
|
// 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 {
|
||||||
|
baseCuePath := existingPath[:idx]
|
||||||
|
if currentPathSet[baseCuePath] {
|
||||||
|
continue // Base .cue file still exists, not deleted
|
||||||
|
}
|
||||||
|
// Base CUE file is gone, mark virtual path as deleted
|
||||||
|
deletedPaths = append(deletedPaths, existingPath)
|
||||||
|
} else if !currentPathSet[existingPath] {
|
||||||
deletedPaths = append(deletedPaths, existingPath)
|
deletedPaths = append(deletedPaths, existingPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -551,11 +692,30 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan the files that need scanning
|
|
||||||
results := make([]LibraryScanResult, 0, len(filesToScan))
|
results := make([]LibraryScanResult, 0, len(filesToScan))
|
||||||
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)
|
||||||
|
parsedCueFiles := make(map[string]scannedCueFileInfo)
|
||||||
|
for _, f := range filesToScan {
|
||||||
|
ext := strings.ToLower(filepath.Ext(f.path))
|
||||||
|
if ext == ".cue" {
|
||||||
|
sheet, err := ParseCueFile(f.path)
|
||||||
|
if err == nil && sheet.FileName != "" {
|
||||||
|
audioPath := ResolveCueAudioPath(f.path, sheet.FileName)
|
||||||
|
if audioPath != "" {
|
||||||
|
parsedCueFiles[f.path] = scannedCueFileInfo{
|
||||||
|
sheet: sheet,
|
||||||
|
audioPath: audioPath,
|
||||||
|
}
|
||||||
|
cueReferencedAudioFilesInc[audioPath] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for i, f := range filesToScan {
|
for i, f := range filesToScan {
|
||||||
select {
|
select {
|
||||||
case <-cancelCh:
|
case <-cancelCh:
|
||||||
@@ -569,7 +729,39 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100
|
libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100
|
||||||
libraryScanProgressMu.Unlock()
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
result, err := scanAudioFile(f.path, scanTime)
|
ext := strings.ToLower(filepath.Ext(f.path))
|
||||||
|
|
||||||
|
// Handle .cue files: produce multiple track results
|
||||||
|
if ext == ".cue" {
|
||||||
|
var cueResults []LibraryScanResult
|
||||||
|
cueInfo, ok := parsedCueFiles[f.path]
|
||||||
|
if ok {
|
||||||
|
cueResults, err = scanCueSheetForLibrary(
|
||||||
|
f.path,
|
||||||
|
cueInfo.sheet,
|
||||||
|
cueInfo.audioPath,
|
||||||
|
"",
|
||||||
|
f.modTime,
|
||||||
|
scanTime,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
cueResults, err = ScanCueFileForLibrary(f.path, scanTime)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
errorCount++
|
||||||
|
GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, cueResults...)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip audio files referenced by .cue sheets
|
||||||
|
if cueReferencedAudioFilesInc[f.path] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := scanAudioFileWithKnownModTime(f.path, scanTime, f.modTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorCount++
|
errorCount++
|
||||||
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
|
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
|
||||||
@@ -603,3 +795,24 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
|
||||||
|
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
|
||||||
|
// Only files that are new or have changed modification time will be scanned
|
||||||
|
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
|
||||||
|
existingFiles := make(map[string]int64)
|
||||||
|
if existingFilesJSON != "" && existingFilesJSON != "{}" {
|
||||||
|
if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil {
|
||||||
|
GoLog("[LibraryScan] Warning: failed to parse existing files JSON: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath string) (string, error) {
|
||||||
|
existingFiles, err := loadExistingFilesSnapshot(snapshotPath)
|
||||||
|
if err != nil {
|
||||||
|
return "{}", fmt.Errorf("failed to load incremental snapshot: %w", err)
|
||||||
|
}
|
||||||
|
return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles)
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ var DefaultLyricsProviders = []string{
|
|||||||
LyricsProviderQQMusic,
|
LyricsProviderQQMusic,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global lyrics provider configuration
|
|
||||||
var (
|
var (
|
||||||
lyricsProvidersMu sync.RWMutex
|
lyricsProvidersMu sync.RWMutex
|
||||||
lyricsProviders []string // ordered list of enabled providers
|
lyricsProviders []string // ordered list of enabled providers
|
||||||
@@ -598,7 +597,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
|||||||
return lyricsHasUsableText(l)
|
return lyricsHasUsableText(l)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try extension lyrics providers first
|
|
||||||
if len(extensionProviders) > 0 {
|
if len(extensionProviders) > 0 {
|
||||||
for _, provider := range extensionProviders {
|
for _, provider := range extensionProviders {
|
||||||
GoLog("[Lyrics] Trying extension lyrics provider: %s\n", provider.extension.ID)
|
GoLog("[Lyrics] Trying extension lyrics provider: %s\n", provider.extension.ID)
|
||||||
@@ -621,7 +619,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
|||||||
return &cachedCopy, nil
|
return &cachedCopy, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get configured provider order
|
|
||||||
providerOrder := GetLyricsProviderOrder()
|
providerOrder := GetLyricsProviderOrder()
|
||||||
simplifiedTrack := simplifyTrackName(trackName)
|
simplifiedTrack := simplifyTrackName(trackName)
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ func (m *appleTokenManager) clearToken() {
|
|||||||
m.token = ""
|
m.token = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apple Music API response models
|
|
||||||
type appleMusicSearchResponse struct {
|
type appleMusicSearchResponse struct {
|
||||||
Results struct {
|
Results struct {
|
||||||
Songs *struct {
|
Songs *struct {
|
||||||
@@ -239,15 +238,12 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
|
|||||||
return bodyStr, nil
|
return bodyStr, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatPaxLyricsToLRC converts a pax proxy response to standard LRC format.
|
|
||||||
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
|
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
|
||||||
// Try to parse as PaxResponse first
|
|
||||||
var paxResp paxResponse
|
var paxResp paxResponse
|
||||||
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil && paxResp.Content != nil {
|
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil && paxResp.Content != nil {
|
||||||
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord), nil
|
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to parse as a direct list of PaxLyrics
|
|
||||||
var directLyrics []paxLyrics
|
var directLyrics []paxLyrics
|
||||||
if err := json.Unmarshal([]byte(rawJSON), &directLyrics); err == nil && len(directLyrics) > 0 {
|
if err := json.Unmarshal([]byte(rawJSON), &directLyrics); err == nil && len(directLyrics) > 0 {
|
||||||
return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord), nil
|
return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord), nil
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ type MusixmatchClient struct {
|
|||||||
baseURL string
|
baseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Musixmatch proxy response models
|
|
||||||
type musixmatchSearchResponse struct {
|
type musixmatchSearchResponse struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
SongName string `json:"songName"`
|
SongName string `json:"songName"`
|
||||||
@@ -116,7 +115,6 @@ func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string)
|
|||||||
return nil, fmt.Errorf("failed to decode musixmatch language response: %w", err)
|
return nil, fmt.Errorf("failed to decode musixmatch language response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer synced lyrics for selected language
|
|
||||||
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
||||||
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
||||||
if len(lines) > 0 {
|
if len(lines) > 0 {
|
||||||
@@ -129,7 +127,6 @@ func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to unsynced lyrics for selected language
|
|
||||||
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
||||||
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
|
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
|
||||||
|
|
||||||
@@ -162,7 +159,6 @@ func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec
|
|||||||
GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr)
|
GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer synced lyrics
|
|
||||||
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
||||||
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
||||||
if len(lines) > 0 {
|
if len(lines) > 0 {
|
||||||
@@ -175,7 +171,6 @@ func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to unsynced lyrics
|
|
||||||
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
||||||
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
|
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ type NeteaseClient struct {
|
|||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// Netease API response models
|
|
||||||
type neteaseSearchResponse struct {
|
type neteaseSearchResponse struct {
|
||||||
Result struct {
|
Result struct {
|
||||||
Songs []struct {
|
Songs []struct {
|
||||||
@@ -172,7 +171,6 @@ func (c *NeteaseClient) FetchLyrics(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the LRC text into LyricsResponse
|
|
||||||
lines := parseSyncedLyrics(lrcText)
|
lines := parseSyncedLyrics(lrcText)
|
||||||
if len(lines) == 0 {
|
if len(lines) == 0 {
|
||||||
// May be plain text lyrics without timestamps
|
// May be plain text lyrics without timestamps
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ type QQMusicClient struct {
|
|||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// QQ Music search response models
|
|
||||||
type qqMusicSearchResponse struct {
|
type qqMusicSearchResponse struct {
|
||||||
Data struct {
|
Data struct {
|
||||||
Song struct {
|
Song struct {
|
||||||
@@ -184,7 +183,6 @@ func (c *QQMusicClient) FetchLyrics(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to plain text
|
|
||||||
resultLines := plainTextLyricsLines(lrcText)
|
resultLines := plainTextLyricsLines(lrcText)
|
||||||
|
|
||||||
if len(resultLines) > 0 {
|
if len(resultLines) > 0 {
|
||||||
|
|||||||
+174
-6
@@ -552,6 +552,14 @@ func ExtractLyrics(filePath string) (string, error) {
|
|||||||
return extractLyricsFromSidecarLRC(filePath)
|
return extractLyricsFromSidecarLRC(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") {
|
||||||
|
lyrics, err := extractLyricsFromM4A(filePath)
|
||||||
|
if err == nil && strings.TrimSpace(lyrics) != "" {
|
||||||
|
return lyrics, nil
|
||||||
|
}
|
||||||
|
return extractLyricsFromSidecarLRC(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
if strings.HasSuffix(lower, ".mp3") {
|
if strings.HasSuffix(lower, ".mp3") {
|
||||||
meta, err := ReadID3Tags(filePath)
|
meta, err := ReadID3Tags(filePath)
|
||||||
if err == nil && meta != nil {
|
if err == nil && meta != nil {
|
||||||
@@ -581,6 +589,153 @@ func ExtractLyrics(filePath string) (string, error) {
|
|||||||
return extractLyricsFromSidecarLRC(filePath)
|
return extractLyricsFromSidecarLRC(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractLyricsFromM4A(filePath string) (string, error) {
|
||||||
|
f, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
fi, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
fileSize := fi.Size()
|
||||||
|
|
||||||
|
moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
|
||||||
|
if err != nil || !found {
|
||||||
|
return "", fmt.Errorf("moov not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyStart := moov.offset + moov.headerSize
|
||||||
|
bodySize := moov.size - moov.headerSize
|
||||||
|
|
||||||
|
udta, found, err := findAtomInRange(f, bodyStart, bodySize, "udta", fileSize)
|
||||||
|
if err != nil || !found {
|
||||||
|
return "", fmt.Errorf("udta not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyStart = udta.offset + udta.headerSize
|
||||||
|
bodySize = udta.size - udta.headerSize
|
||||||
|
|
||||||
|
meta, found, err := findAtomInRange(f, bodyStart, bodySize, "meta", fileSize)
|
||||||
|
if err != nil || !found {
|
||||||
|
return "", fmt.Errorf("meta not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// meta atom has 4-byte version/flags after the header
|
||||||
|
bodyStart = meta.offset + meta.headerSize + 4
|
||||||
|
bodySize = meta.size - meta.headerSize - 4
|
||||||
|
|
||||||
|
ilst, found, err := findAtomInRange(f, bodyStart, bodySize, "ilst", fileSize)
|
||||||
|
if err != nil || !found {
|
||||||
|
return "", fmt.Errorf("ilst not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyStart = ilst.offset + ilst.headerSize
|
||||||
|
bodySize = ilst.size - ilst.headerSize
|
||||||
|
|
||||||
|
lyr, found, err := findAtomInRange(f, bodyStart, bodySize, "\xa9lyr", fileSize)
|
||||||
|
if err != nil || !found {
|
||||||
|
return "", fmt.Errorf("lyrics atom not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
dataStart := lyr.offset + lyr.headerSize
|
||||||
|
dataSize := lyr.size - lyr.headerSize
|
||||||
|
|
||||||
|
dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize)
|
||||||
|
if err != nil || !found {
|
||||||
|
return "", fmt.Errorf("data atom not found in lyrics")
|
||||||
|
}
|
||||||
|
|
||||||
|
// data atom: 8 bytes header + 4 bytes type indicator + 4 bytes locale = skip 8
|
||||||
|
textStart := dataAtom.offset + dataAtom.headerSize + 8
|
||||||
|
textLen := dataAtom.size - dataAtom.headerSize - 8
|
||||||
|
if textLen <= 0 {
|
||||||
|
return "", fmt.Errorf("empty lyrics")
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := make([]byte, textLen)
|
||||||
|
if _, err := f.ReadAt(buf, textStart); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractCoverFromM4A(filePath string) ([]byte, error) {
|
||||||
|
f, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
fi, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fileSize := fi.Size()
|
||||||
|
|
||||||
|
moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
|
||||||
|
if err != nil || !found {
|
||||||
|
return nil, fmt.Errorf("moov not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyStart := moov.offset + moov.headerSize
|
||||||
|
bodySize := moov.size - moov.headerSize
|
||||||
|
|
||||||
|
udta, found, err := findAtomInRange(f, bodyStart, bodySize, "udta", fileSize)
|
||||||
|
if err != nil || !found {
|
||||||
|
return nil, fmt.Errorf("udta not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyStart = udta.offset + udta.headerSize
|
||||||
|
bodySize = udta.size - udta.headerSize
|
||||||
|
|
||||||
|
meta, found, err := findAtomInRange(f, bodyStart, bodySize, "meta", fileSize)
|
||||||
|
if err != nil || !found {
|
||||||
|
return nil, fmt.Errorf("meta not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyStart = meta.offset + meta.headerSize + 4
|
||||||
|
bodySize = meta.size - meta.headerSize - 4
|
||||||
|
|
||||||
|
ilst, found, err := findAtomInRange(f, bodyStart, bodySize, "ilst", fileSize)
|
||||||
|
if err != nil || !found {
|
||||||
|
return nil, fmt.Errorf("ilst not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyStart = ilst.offset + ilst.headerSize
|
||||||
|
bodySize = ilst.size - ilst.headerSize
|
||||||
|
|
||||||
|
covr, found, err := findAtomInRange(f, bodyStart, bodySize, "covr", fileSize)
|
||||||
|
if err != nil || !found {
|
||||||
|
return nil, fmt.Errorf("cover atom not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
dataStart := covr.offset + covr.headerSize
|
||||||
|
dataSize := covr.size - covr.headerSize
|
||||||
|
|
||||||
|
dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize)
|
||||||
|
if err != nil || !found {
|
||||||
|
return nil, fmt.Errorf("data atom not found in cover")
|
||||||
|
}
|
||||||
|
|
||||||
|
// data atom: header + 4 bytes type indicator + 4 bytes locale
|
||||||
|
imgStart := dataAtom.offset + dataAtom.headerSize + 8
|
||||||
|
imgLen := dataAtom.size - dataAtom.headerSize - 8
|
||||||
|
if imgLen <= 0 {
|
||||||
|
return nil, fmt.Errorf("empty cover data")
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := make([]byte, imgLen)
|
||||||
|
if _, err := f.ReadAt(buf, imgStart); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf, nil
|
||||||
|
}
|
||||||
|
|
||||||
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
|
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
|
||||||
ext := filepath.Ext(filePath)
|
ext := filepath.Ext(filePath)
|
||||||
base := strings.TrimSuffix(filePath, ext)
|
base := strings.TrimSuffix(filePath, ext)
|
||||||
@@ -743,15 +898,28 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
|
|||||||
return AudioQuality{}, err
|
return AudioQuality{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
buf := make([]byte, 24)
|
buf := make([]byte, 32)
|
||||||
if _, err := f.ReadAt(buf, sampleOffset); err != nil {
|
if _, err := f.ReadAt(buf, sampleOffset); err != nil {
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read audio sample entry: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to read audio sample entry: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sampleRate := int(buf[22])<<8 | int(buf[23])
|
// AudioSampleEntry layout from the box type field:
|
||||||
bitDepth := 16
|
// [0:4] type ("mp4a"/"alac")
|
||||||
if atomType == "alac" {
|
// [4:10] SampleEntry.reserved
|
||||||
bitDepth = 24
|
// [10:12] data_reference_index
|
||||||
|
// [12:20] reserved[8]
|
||||||
|
// [20:22] channelcount
|
||||||
|
// [22:24] samplesize (bit depth)
|
||||||
|
// [24:26] pre_defined
|
||||||
|
// [26:28] reserved
|
||||||
|
// [28:32] samplerate (16.16 fixed-point)
|
||||||
|
sampleRate := int(buf[28])<<8 | int(buf[29])
|
||||||
|
bitDepth := int(buf[22])<<8 | int(buf[23])
|
||||||
|
if bitDepth <= 0 {
|
||||||
|
bitDepth = 16
|
||||||
|
if atomType == "alac" {
|
||||||
|
bitDepth = 24
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
|
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
|
||||||
@@ -874,7 +1042,7 @@ func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string
|
|||||||
|
|
||||||
if bestIdx >= 0 {
|
if bestIdx >= 0 {
|
||||||
absolute := readPos - int64(len(tail)) + int64(bestIdx)
|
absolute := readPos - int64(len(tail)) + int64(bestIdx)
|
||||||
if absolute+24 > fileSize {
|
if absolute+32 > fileSize {
|
||||||
return 0, "", fmt.Errorf("audio info not found in M4A file")
|
return 0, "", fmt.Errorf("audio info not found in M4A file")
|
||||||
}
|
}
|
||||||
return absolute, bestType, nil
|
return absolute, bestType, nil
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
// mobile_deps.go
|
|
||||||
// This file ensures gomobile dependencies are not removed by go mod tidy.
|
// This file ensures gomobile dependencies are not removed by go mod tidy.
|
||||||
// These packages are required by gomobile bind but not directly imported in code.
|
// These packages are required by gomobile bind but not directly imported in code.
|
||||||
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
// Required for gomobile bind to work
|
|
||||||
_ "golang.org/x/mobile/bind"
|
_ "golang.org/x/mobile/bind"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
type TrackIDCacheEntry struct {
|
type TrackIDCacheEntry struct {
|
||||||
TidalTrackID int64
|
TidalTrackID int64
|
||||||
QobuzTrackID int64
|
QobuzTrackID int64
|
||||||
AmazonURL string
|
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,25 +106,6 @@ func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *TrackIDCache) SetAmazonURL(isrc string, amazonURL string) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
entry, exists := c.cache[isrc]
|
|
||||||
if !exists {
|
|
||||||
entry = &TrackIDCacheEntry{}
|
|
||||||
c.cache[isrc] = entry
|
|
||||||
}
|
|
||||||
entry.AmazonURL = amazonURL
|
|
||||||
now := time.Now()
|
|
||||||
entry.ExpiresAt = now.Add(c.ttl)
|
|
||||||
|
|
||||||
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
|
|
||||||
c.pruneExpiredLocked(now)
|
|
||||||
c.lastCleanup = now
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TrackIDCache) Clear() {
|
func (c *TrackIDCache) Clear() {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
@@ -235,8 +215,6 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
|||||||
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
|
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
|
||||||
case "qobuz":
|
case "qobuz":
|
||||||
preWarmQobuzCache(r.ISRC, r.SpotifyID)
|
preWarmQobuzCache(r.ISRC, r.SpotifyID)
|
||||||
case "amazon":
|
|
||||||
preWarmAmazonCache(r.ISRC, r.SpotifyID)
|
|
||||||
}
|
}
|
||||||
}(req)
|
}(req)
|
||||||
}
|
}
|
||||||
@@ -256,12 +234,10 @@ func preWarmTidalCache(isrc, _, _ string) {
|
|||||||
// 1. From SongLink (fast, no Qobuz API call needed)
|
// 1. From SongLink (fast, no Qobuz API call needed)
|
||||||
// 2. Direct ISRC search on Qobuz API (slower, may fail if ISRC not in Qobuz database)
|
// 2. Direct ISRC search on Qobuz API (slower, may fail if ISRC not in Qobuz database)
|
||||||
func preWarmQobuzCache(isrc, spotifyID string) {
|
func preWarmQobuzCache(isrc, spotifyID string) {
|
||||||
// First, try to get QobuzID from SongLink - this is faster and more reliable
|
|
||||||
if spotifyID != "" {
|
if spotifyID != "" {
|
||||||
client := NewSongLinkClient()
|
client := NewSongLinkClient()
|
||||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||||
if err == nil && availability != nil && availability.QobuzID != "" {
|
if err == nil && availability != nil && availability.QobuzID != "" {
|
||||||
// Parse QobuzID to int64
|
|
||||||
var trackID int64
|
var trackID int64
|
||||||
if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||||
GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from SongLink for ISRC %s\n", trackID, isrc)
|
GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from SongLink for ISRC %s\n", trackID, isrc)
|
||||||
@@ -271,7 +247,6 @@ func preWarmQobuzCache(isrc, spotifyID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: Direct ISRC search on Qobuz API
|
|
||||||
downloader := NewQobuzDownloader()
|
downloader := NewQobuzDownloader()
|
||||||
track, err := downloader.SearchTrackByISRC(isrc)
|
track, err := downloader.SearchTrackByISRC(isrc)
|
||||||
if err == nil && track != nil {
|
if err == nil && track != nil {
|
||||||
@@ -280,14 +255,6 @@ func preWarmQobuzCache(isrc, spotifyID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func preWarmAmazonCache(isrc, spotifyID string) {
|
|
||||||
client := NewSongLinkClient()
|
|
||||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
|
||||||
if err == nil && availability != nil && availability.AmazonURL != "" {
|
|
||||||
GetTrackIDCache().SetAmazonURL(isrc, availability.AmazonURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func PreWarmCache(tracksJSON string) error {
|
func PreWarmCache(tracksJSON string) error {
|
||||||
var tracks []struct {
|
var tracks []struct {
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
|
|||||||
+31
-4
@@ -34,10 +34,16 @@ var (
|
|||||||
downloadDir string
|
downloadDir string
|
||||||
downloadDirMu sync.RWMutex
|
downloadDirMu sync.RWMutex
|
||||||
|
|
||||||
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
||||||
multiMu sync.RWMutex
|
multiMu sync.RWMutex
|
||||||
|
multiProgressDirty = true
|
||||||
|
cachedMultiProgress = "{\"items\":{}}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func markMultiProgressDirtyLocked() {
|
||||||
|
multiProgressDirty = true
|
||||||
|
}
|
||||||
|
|
||||||
func getProgress() DownloadProgress {
|
func getProgress() DownloadProgress {
|
||||||
multiMu.RLock()
|
multiMu.RLock()
|
||||||
defer multiMu.RUnlock()
|
defer multiMu.RUnlock()
|
||||||
@@ -58,13 +64,25 @@ func getProgress() DownloadProgress {
|
|||||||
|
|
||||||
func GetMultiProgress() string {
|
func GetMultiProgress() string {
|
||||||
multiMu.RLock()
|
multiMu.RLock()
|
||||||
defer multiMu.RUnlock()
|
if !multiProgressDirty {
|
||||||
|
cached := cachedMultiProgress
|
||||||
|
multiMu.RUnlock()
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
multiMu.RUnlock()
|
||||||
|
|
||||||
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
if !multiProgressDirty {
|
||||||
|
return cachedMultiProgress
|
||||||
|
}
|
||||||
jsonBytes, err := json.Marshal(multiProgress)
|
jsonBytes, err := json.Marshal(multiProgress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "{\"items\":{}}"
|
return "{\"items\":{}}"
|
||||||
}
|
}
|
||||||
return string(jsonBytes)
|
cachedMultiProgress = string(jsonBytes)
|
||||||
|
multiProgressDirty = false
|
||||||
|
return cachedMultiProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetItemProgress(itemID string) string {
|
func GetItemProgress(itemID string) string {
|
||||||
@@ -90,6 +108,7 @@ func StartItemProgress(itemID string) {
|
|||||||
IsDownloading: true,
|
IsDownloading: true,
|
||||||
Status: "downloading",
|
Status: "downloading",
|
||||||
}
|
}
|
||||||
|
markMultiProgressDirtyLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetItemBytesTotal(itemID string, total int64) {
|
func SetItemBytesTotal(itemID string, total int64) {
|
||||||
@@ -98,6 +117,7 @@ func SetItemBytesTotal(itemID string, total int64) {
|
|||||||
|
|
||||||
if item, ok := multiProgress.Items[itemID]; ok {
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
item.BytesTotal = total
|
item.BytesTotal = total
|
||||||
|
markMultiProgressDirtyLocked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +130,7 @@ func SetItemBytesReceived(itemID string, received int64) {
|
|||||||
if item.BytesTotal > 0 {
|
if item.BytesTotal > 0 {
|
||||||
item.Progress = float64(received) / float64(item.BytesTotal)
|
item.Progress = float64(received) / float64(item.BytesTotal)
|
||||||
}
|
}
|
||||||
|
markMultiProgressDirtyLocked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +144,7 @@ func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps floa
|
|||||||
if item.BytesTotal > 0 {
|
if item.BytesTotal > 0 {
|
||||||
item.Progress = float64(received) / float64(item.BytesTotal)
|
item.Progress = float64(received) / float64(item.BytesTotal)
|
||||||
}
|
}
|
||||||
|
markMultiProgressDirtyLocked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +156,7 @@ func CompleteItemProgress(itemID string) {
|
|||||||
item.Progress = 1.0
|
item.Progress = 1.0
|
||||||
item.IsDownloading = false
|
item.IsDownloading = false
|
||||||
item.Status = "completed"
|
item.Status = "completed"
|
||||||
|
markMultiProgressDirtyLocked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +172,7 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal
|
|||||||
if bytesTotal > 0 {
|
if bytesTotal > 0 {
|
||||||
item.BytesTotal = bytesTotal
|
item.BytesTotal = bytesTotal
|
||||||
}
|
}
|
||||||
|
markMultiProgressDirtyLocked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +183,7 @@ func SetItemFinalizing(itemID string) {
|
|||||||
if item, ok := multiProgress.Items[itemID]; ok {
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
item.Progress = 1.0
|
item.Progress = 1.0
|
||||||
item.Status = "finalizing"
|
item.Status = "finalizing"
|
||||||
|
markMultiProgressDirtyLocked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +192,7 @@ func RemoveItemProgress(itemID string) {
|
|||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
delete(multiProgress.Items, itemID)
|
delete(multiProgress.Items, itemID)
|
||||||
|
markMultiProgressDirtyLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
func ClearAllItemProgress() {
|
func ClearAllItemProgress() {
|
||||||
@@ -174,6 +200,7 @@ func ClearAllItemProgress() {
|
|||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
multiProgress.Items = make(map[string]*ItemProgress)
|
multiProgress.Items = make(map[string]*ItemProgress)
|
||||||
|
markMultiProgressDirtyLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setDownloadDir(path string) error {
|
func setDownloadDir(path string) error {
|
||||||
|
|||||||
+914
-127
File diff suppressed because it is too large
Load Diff
+280
-2
@@ -2,6 +2,95 @@ package gobackend
|
|||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseQobuzURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantType string
|
||||||
|
wantID string
|
||||||
|
expectErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "store album url",
|
||||||
|
input: "https://www.qobuz.com/us-en/album/harry-styles-harry-styles/0886446451985",
|
||||||
|
wantType: "album",
|
||||||
|
wantID: "0886446451985",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "store playlist url",
|
||||||
|
input: "https://www.qobuz.com/us-en/playlists/new-releases/2049430",
|
||||||
|
wantType: "playlist",
|
||||||
|
wantID: "2049430",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "store artist url",
|
||||||
|
input: "https://www.qobuz.com/us-en/interpreter/harry-styles/729886",
|
||||||
|
wantType: "artist",
|
||||||
|
wantID: "729886",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "play track url",
|
||||||
|
input: "https://play.qobuz.com/track/40681594",
|
||||||
|
wantType: "track",
|
||||||
|
wantID: "40681594",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom scheme playlist url",
|
||||||
|
input: "qobuzapp://playlist/2049430",
|
||||||
|
wantType: "playlist",
|
||||||
|
wantID: "2049430",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unsupported url",
|
||||||
|
input: "https://example.com/not-qobuz",
|
||||||
|
expectErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
gotType, gotID, err := parseQobuzURL(test.input)
|
||||||
|
if test.expectErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, got none")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if gotType != test.wantType || gotID != test.wantID {
|
||||||
|
t.Fatalf("parseQobuzURL(%q) = (%q, %q), want (%q, %q)", test.input, gotType, gotID, test.wantType, test.wantID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractQobuzArtistAlbumIDs(t *testing.T) {
|
||||||
|
body := []byte(`
|
||||||
|
<div class="product__item">
|
||||||
|
<button data-itemtype="album" data-itemId="yrpbt0lwm3g0y"></button>
|
||||||
|
</div>
|
||||||
|
<div class="product__item">
|
||||||
|
<button data-itemtype="album" data-itemId="yrpbt0lwm3g0y"></button>
|
||||||
|
</div>
|
||||||
|
<div class="product__item">
|
||||||
|
<button data-itemtype="album" data-itemId="0886446451985"></button>
|
||||||
|
</div>
|
||||||
|
`)
|
||||||
|
|
||||||
|
matches := qobuzArtistAlbumIDRegex.FindAllSubmatch(body, -1)
|
||||||
|
if len(matches) != 3 {
|
||||||
|
t.Fatalf("expected 3 regex matches, got %d", len(matches))
|
||||||
|
}
|
||||||
|
if string(matches[0][1]) != "yrpbt0lwm3g0y" {
|
||||||
|
t.Fatalf("unexpected first album id: %q", matches[0][1])
|
||||||
|
}
|
||||||
|
if string(matches[2][1]) != "0886446451985" {
|
||||||
|
t.Fatalf("unexpected last album id: %q", matches[2][1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExtractQobuzDownloadURLFromBody(t *testing.T) {
|
func TestExtractQobuzDownloadURLFromBody(t *testing.T) {
|
||||||
t.Run("reads top-level download_url and quality metadata", func(t *testing.T) {
|
t.Run("reads top-level download_url and quality metadata", func(t *testing.T) {
|
||||||
body := []byte(`{"success":true,"download_url":"https://example.test/new.flac","bit_depth":24,"sampling_rate":96}`)
|
body := []byte(`{"success":true,"download_url":"https://example.test/new.flac","bit_depth":24,"sampling_rate":96}`)
|
||||||
@@ -106,16 +195,34 @@ func TestGetQobuzDebugKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) {
|
||||||
|
body := []byte(`
|
||||||
|
<button data-itemtype="album" data-itemId="0886446451985"></button>
|
||||||
|
<button data-itemtype="album" data-itemId="0886446451985"></button>
|
||||||
|
<button data-itemtype="album" data-itemId="pvv406bth40ya"></button>
|
||||||
|
`)
|
||||||
|
|
||||||
|
got := extractQobuzAlbumIDsFromArtistHTML(body)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("expected 2 unique album IDs, got %d (%v)", len(got), got)
|
||||||
|
}
|
||||||
|
if got[0] != "0886446451985" || got[1] != "pvv406bth40ya" {
|
||||||
|
t.Fatalf("unexpected album IDs: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestQobuzAvailableProviders(t *testing.T) {
|
func TestQobuzAvailableProviders(t *testing.T) {
|
||||||
providers := NewQobuzDownloader().GetAvailableProviders()
|
providers := NewQobuzDownloader().GetAvailableProviders()
|
||||||
if len(providers) != 3 {
|
if len(providers) != 5 {
|
||||||
t.Fatalf("expected 3 Qobuz providers, got %d", len(providers))
|
t.Fatalf("expected 5 Qobuz providers, got %d", len(providers))
|
||||||
}
|
}
|
||||||
|
|
||||||
want := map[string]string{
|
want := map[string]string{
|
||||||
"musicdl": qobuzAPIKindMusicDL,
|
"musicdl": qobuzAPIKindMusicDL,
|
||||||
"dabmusic": qobuzAPIKindStandard,
|
"dabmusic": qobuzAPIKindStandard,
|
||||||
"deeb": qobuzAPIKindStandard,
|
"deeb": qobuzAPIKindStandard,
|
||||||
|
"qbz": qobuzAPIKindStandard,
|
||||||
|
"squid": qobuzAPIKindStandard,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, provider := range providers {
|
for _, provider := range providers {
|
||||||
@@ -133,3 +240,174 @@ func TestQobuzAvailableProviders(t *testing.T) {
|
|||||||
t.Fatalf("missing providers: %v", want)
|
t.Fatalf("missing providers: %v", want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testQobuzTrack(id int64, title, artist string, duration int) *QobuzTrack {
|
||||||
|
track := &QobuzTrack{
|
||||||
|
ID: id,
|
||||||
|
Title: title,
|
||||||
|
Duration: duration,
|
||||||
|
}
|
||||||
|
track.Performer.Name = artist
|
||||||
|
return track
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveQobuzTrackForRequestRejectsSongLinkMismatch(t *testing.T) {
|
||||||
|
origGetTrackByID := qobuzGetTrackByIDFunc
|
||||||
|
origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
|
||||||
|
origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc
|
||||||
|
origSongLinkCheck := songLinkCheckTrackAvailabilityFunc
|
||||||
|
t.Cleanup(func() {
|
||||||
|
qobuzGetTrackByIDFunc = origGetTrackByID
|
||||||
|
qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC
|
||||||
|
qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata
|
||||||
|
songLinkCheckTrackAvailabilityFunc = origSongLinkCheck
|
||||||
|
GetTrackIDCache().Clear()
|
||||||
|
})
|
||||||
|
GetTrackIDCache().Clear()
|
||||||
|
|
||||||
|
qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) {
|
||||||
|
if trackID != 111 {
|
||||||
|
t.Fatalf("unexpected track ID lookup: %d", trackID)
|
||||||
|
}
|
||||||
|
return testQobuzTrack(111, "Aperture", "Harry Styles", 180), nil
|
||||||
|
}
|
||||||
|
qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||||
|
if isrc != "TESTISRC1" {
|
||||||
|
t.Fatalf("unexpected ISRC lookup: %q", isrc)
|
||||||
|
}
|
||||||
|
if expectedDurationSec != 180 {
|
||||||
|
t.Fatalf("unexpected duration: %d", expectedDurationSec)
|
||||||
|
}
|
||||||
|
return testQobuzTrack(222, "Taste Back", "Harry Styles", 180), nil
|
||||||
|
}
|
||||||
|
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, _, _ string, _ int) (*QobuzTrack, error) {
|
||||||
|
t.Fatal("metadata fallback should not run when ISRC fallback succeeds")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||||
|
if spotifyTrackID != "spotify-track-id" {
|
||||||
|
t.Fatalf("unexpected spotify ID: %q", spotifyTrackID)
|
||||||
|
}
|
||||||
|
if isrc != "TESTISRC1" {
|
||||||
|
t.Fatalf("unexpected SongLink ISRC: %q", isrc)
|
||||||
|
}
|
||||||
|
return &TrackAvailability{QobuzID: "111"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
req := DownloadRequest{
|
||||||
|
ISRC: "TESTISRC1",
|
||||||
|
SpotifyID: "spotify-track-id",
|
||||||
|
TrackName: "Taste Back",
|
||||||
|
ArtistName: "Harry Styles",
|
||||||
|
DurationMS: 180000,
|
||||||
|
}
|
||||||
|
|
||||||
|
track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if track == nil || track.ID != 222 || track.Title != "Taste Back" {
|
||||||
|
t.Fatalf("unexpected resolved track: %+v", track)
|
||||||
|
}
|
||||||
|
|
||||||
|
cached := GetTrackIDCache().Get(req.ISRC)
|
||||||
|
if cached == nil || cached.QobuzTrackID != 222 {
|
||||||
|
t.Fatalf("expected validated fallback track to be cached, got %+v", cached)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveQobuzTrackForRequestRejectsOdesliMismatch(t *testing.T) {
|
||||||
|
origGetTrackByID := qobuzGetTrackByIDFunc
|
||||||
|
origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
|
||||||
|
origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc
|
||||||
|
origSongLinkCheck := songLinkCheckTrackAvailabilityFunc
|
||||||
|
t.Cleanup(func() {
|
||||||
|
qobuzGetTrackByIDFunc = origGetTrackByID
|
||||||
|
qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC
|
||||||
|
qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata
|
||||||
|
songLinkCheckTrackAvailabilityFunc = origSongLinkCheck
|
||||||
|
})
|
||||||
|
|
||||||
|
qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) {
|
||||||
|
if trackID != 333 {
|
||||||
|
t.Fatalf("unexpected track ID lookup: %d", trackID)
|
||||||
|
}
|
||||||
|
return testQobuzTrack(333, "American Girls", "Harry Styles", 181), nil
|
||||||
|
}
|
||||||
|
qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, _ string, _ int) (*QobuzTrack, error) {
|
||||||
|
t.Fatal("ISRC fallback should not run without an ISRC")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||||
|
if trackName != "Taste Back" || artistName != "Harry Styles" || expectedDurationSec != 181 {
|
||||||
|
t.Fatalf("unexpected metadata fallback arguments: %q / %q / %d", trackName, artistName, expectedDurationSec)
|
||||||
|
}
|
||||||
|
return testQobuzTrack(444, "Taste Back", "Harry Styles", 181), nil
|
||||||
|
}
|
||||||
|
songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, _, _ string) (*TrackAvailability, error) {
|
||||||
|
t.Fatal("SongLink should not run when Odesli QobuzID is provided")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
req := DownloadRequest{
|
||||||
|
QobuzID: "333",
|
||||||
|
TrackName: "Taste Back",
|
||||||
|
ArtistName: "Harry Styles",
|
||||||
|
DurationMS: 181000,
|
||||||
|
}
|
||||||
|
|
||||||
|
track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if track == nil || track.ID != 444 || track.Title != "Taste Back" {
|
||||||
|
t.Fatalf("unexpected resolved track: %+v", track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveQobuzTrackForRequestUsesPrefixedQobuzIDWithoutSongLink(t *testing.T) {
|
||||||
|
origGetTrackByID := qobuzGetTrackByIDFunc
|
||||||
|
origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
|
||||||
|
origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc
|
||||||
|
origSongLinkCheck := songLinkCheckTrackAvailabilityFunc
|
||||||
|
t.Cleanup(func() {
|
||||||
|
qobuzGetTrackByIDFunc = origGetTrackByID
|
||||||
|
qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC
|
||||||
|
qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata
|
||||||
|
songLinkCheckTrackAvailabilityFunc = origSongLinkCheck
|
||||||
|
})
|
||||||
|
|
||||||
|
qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) {
|
||||||
|
if trackID != 40681594 {
|
||||||
|
t.Fatalf("unexpected track ID lookup: %d", trackID)
|
||||||
|
}
|
||||||
|
return testQobuzTrack(40681594, "Sign of the Times", "Harry Styles", 341), nil
|
||||||
|
}
|
||||||
|
qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, _ string, _ int) (*QobuzTrack, error) {
|
||||||
|
t.Fatal("ISRC fallback should not run when request qobuz id succeeds")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, _, _ string, _ int) (*QobuzTrack, error) {
|
||||||
|
t.Fatal("metadata fallback should not run when request qobuz id succeeds")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, _, _ string) (*TrackAvailability, error) {
|
||||||
|
t.Fatal("SongLink should not run when request qobuz id is provided")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
req := DownloadRequest{
|
||||||
|
QobuzID: "qobuz:40681594",
|
||||||
|
TrackName: "Sign of the Times",
|
||||||
|
ArtistName: "Harry Styles",
|
||||||
|
DurationMS: 341000,
|
||||||
|
}
|
||||||
|
|
||||||
|
track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if track == nil || track.ID != 40681594 {
|
||||||
|
t.Fatalf("unexpected resolved track: %+v", track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ func (r *RateLimiter) WaitForSlot() {
|
|||||||
r.timestamps = append(r.timestamps, time.Now())
|
r.timestamps = append(r.timestamps, time.Now())
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanOldTimestamps removes timestamps that are outside the current window
|
|
||||||
func (r *RateLimiter) cleanOldTimestamps(now time.Time) {
|
func (r *RateLimiter) cleanOldTimestamps(now time.Time) {
|
||||||
cutoff := now.Add(-r.window)
|
cutoff := now.Add(-r.window)
|
||||||
validStart := 0
|
validStart := 0
|
||||||
|
|||||||
@@ -170,11 +170,9 @@ func JapaneseToRomaji(text string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func BuildSearchQuery(trackName, artistName string) string {
|
func BuildSearchQuery(trackName, artistName string) string {
|
||||||
// Convert Japanese to romaji
|
|
||||||
trackRomaji := JapaneseToRomaji(trackName)
|
trackRomaji := JapaneseToRomaji(trackName)
|
||||||
artistRomaji := JapaneseToRomaji(artistName)
|
artistRomaji := JapaneseToRomaji(artistName)
|
||||||
|
|
||||||
// Clean up the query - remove special characters that might interfere with search
|
|
||||||
trackClean := cleanSearchQuery(trackRomaji)
|
trackClean := cleanSearchQuery(trackRomaji)
|
||||||
artistClean := cleanSearchQuery(artistRomaji)
|
artistClean := cleanSearchQuery(artistRomaji)
|
||||||
|
|
||||||
@@ -196,16 +194,13 @@ func cleanSearchQuery(s string) string {
|
|||||||
func CleanToASCII(s string) string {
|
func CleanToASCII(s string) string {
|
||||||
var result strings.Builder
|
var result strings.Builder
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
// Keep only ASCII letters, numbers, spaces, and basic punctuation
|
|
||||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||||
(r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' {
|
(r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' {
|
||||||
result.WriteRune(r)
|
result.WriteRune(r)
|
||||||
} else if r == ',' || r == '.' {
|
} else if r == ',' || r == '.' {
|
||||||
// Convert punctuation to space
|
|
||||||
result.WriteRune(' ')
|
result.WriteRune(' ')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Clean up multiple spaces
|
|
||||||
cleaned := strings.Join(strings.Fields(result.String()), " ")
|
cleaned := strings.Join(strings.Fields(result.String()), " ")
|
||||||
return strings.TrimSpace(cleaned)
|
return strings.TrimSpace(cleaned)
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-12
@@ -1,6 +1,7 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -36,6 +37,12 @@ var (
|
|||||||
songLinkClientOnce sync.Once
|
songLinkClientOnce sync.Once
|
||||||
songLinkRegion = "US"
|
songLinkRegion = "US"
|
||||||
songLinkRegionMu sync.RWMutex
|
songLinkRegionMu sync.RWMutex
|
||||||
|
songLinkSearchByISRC = func(ctx context.Context, isrc string) (*TrackMetadata, error) {
|
||||||
|
return GetDeezerClient().SearchByISRC(ctx, isrc)
|
||||||
|
}
|
||||||
|
songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) {
|
||||||
|
return s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewSongLinkClient() *SongLinkClient {
|
func NewSongLinkClient() *SongLinkClient {
|
||||||
@@ -109,6 +116,20 @@ func buildSongLinkURLByPlatform(platform, entityType, entityID, userCountry stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||||
|
spotifyTrackID = strings.TrimSpace(spotifyTrackID)
|
||||||
|
isrc = strings.ToUpper(strings.TrimSpace(isrc))
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case spotifyTrackID != "":
|
||||||
|
return s.checkTrackAvailabilityFromSpotify(spotifyTrackID)
|
||||||
|
case isrc != "":
|
||||||
|
return s.checkTrackAvailabilityFromISRC(isrc)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("spotify track ID and ISRC are empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
|
||||||
songLinkRateLimiter.WaitForSlot()
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
||||||
@@ -200,6 +221,47 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
return availability, nil
|
return availability, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SongLinkClient) checkTrackAvailabilityFromISRC(isrc string) (*TrackAvailability, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
track, err := songLinkSearchByISRC(ctx, isrc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to resolve Deezer track from ISRC %s: %w", isrc, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deezerTrackID := songLinkExtractDeezerTrackID(track)
|
||||||
|
if deezerTrackID == "" {
|
||||||
|
return nil, fmt.Errorf("failed to resolve Deezer track ID from ISRC %s", isrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
availability, err := songLinkCheckAvailabilityFromDeezer(s, deezerTrackID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to resolve SongLink availability from ISRC %s via Deezer %s: %w", isrc, deezerTrackID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func songLinkExtractDeezerTrackID(track *TrackMetadata) string {
|
||||||
|
if track == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if deezerID, ok := strings.CutPrefix(strings.TrimSpace(track.SpotifyID), "deezer:"); ok {
|
||||||
|
deezerID = strings.TrimSpace(deezerID)
|
||||||
|
if deezerID != "" {
|
||||||
|
return deezerID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if deezerID := extractDeezerIDFromURL(strings.TrimSpace(track.ExternalURL)); deezerID != "" {
|
||||||
|
return deezerID
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]string, error) {
|
func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]string, error) {
|
||||||
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -229,7 +291,7 @@ func extractDeezerIDFromURL(deezerURL string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractQobuzIDFromURL extracts Qobuz track ID from URL
|
// extractQobuzIDFromURL extracts Qobuz track ID from URL.
|
||||||
// URL formats:
|
// URL formats:
|
||||||
// - https://www.qobuz.com/us-en/album/.../12345678 (album page with track highlight)
|
// - https://www.qobuz.com/us-en/album/.../12345678 (album page with track highlight)
|
||||||
// - https://open.qobuz.com/track/12345678
|
// - https://open.qobuz.com/track/12345678
|
||||||
@@ -240,29 +302,24 @@ func extractQobuzIDFromURL(qobuzURL string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to find /track/ID pattern first
|
|
||||||
if strings.Contains(qobuzURL, "/track/") {
|
if strings.Contains(qobuzURL, "/track/") {
|
||||||
parts := strings.Split(qobuzURL, "/track/")
|
parts := strings.Split(qobuzURL, "/track/")
|
||||||
if len(parts) > 1 {
|
if len(parts) > 1 {
|
||||||
idPart := parts[1]
|
idPart := parts[1]
|
||||||
// Remove query parameters
|
|
||||||
if idx := strings.Index(idPart, "?"); idx > 0 {
|
if idx := strings.Index(idPart, "?"); idx > 0 {
|
||||||
idPart = idPart[:idx]
|
idPart = idPart[:idx]
|
||||||
}
|
}
|
||||||
// Remove trailing slash or path
|
|
||||||
if idx := strings.Index(idPart, "/"); idx > 0 {
|
if idx := strings.Index(idPart, "/"); idx > 0 {
|
||||||
idPart = idPart[:idx]
|
idPart = idPart[:idx]
|
||||||
}
|
}
|
||||||
idPart = strings.TrimSpace(idPart)
|
idPart = strings.TrimSpace(idPart)
|
||||||
// Validate it's a number
|
|
||||||
if idPart != "" && isNumeric(idPart) {
|
if idPart != "" && isNumeric(idPart) {
|
||||||
return idPart
|
return idPart
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to extract from album URL with track highlight
|
// Try to extract from album URL with track highlight (e.g. ?trackId=12345678)
|
||||||
// Format: /album/albumname/trackid or ?trackId=12345678
|
|
||||||
if strings.Contains(qobuzURL, "trackId=") {
|
if strings.Contains(qobuzURL, "trackId=") {
|
||||||
parts := strings.Split(qobuzURL, "trackId=")
|
parts := strings.Split(qobuzURL, "trackId=")
|
||||||
if len(parts) > 1 {
|
if len(parts) > 1 {
|
||||||
@@ -281,7 +338,6 @@ func extractQobuzIDFromURL(qobuzURL string) string {
|
|||||||
parts := strings.Split(qobuzURL, "/")
|
parts := strings.Split(qobuzURL, "/")
|
||||||
for i := len(parts) - 1; i >= 0; i-- {
|
for i := len(parts) - 1; i >= 0; i-- {
|
||||||
part := parts[i]
|
part := parts[i]
|
||||||
// Remove query parameters
|
|
||||||
if idx := strings.Index(part, "?"); idx > 0 {
|
if idx := strings.Index(part, "?"); idx > 0 {
|
||||||
part = part[:idx]
|
part = part[:idx]
|
||||||
}
|
}
|
||||||
@@ -324,7 +380,6 @@ func extractYouTubeIDFromURL(youtubeURL string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle youtu.be short URLs
|
|
||||||
if strings.Contains(youtubeURL, "youtu.be/") {
|
if strings.Contains(youtubeURL, "youtu.be/") {
|
||||||
parts := strings.Split(youtubeURL, "youtu.be/")
|
parts := strings.Split(youtubeURL, "youtu.be/")
|
||||||
if len(parts) >= 2 {
|
if len(parts) >= 2 {
|
||||||
@@ -339,7 +394,6 @@ func extractYouTubeIDFromURL(youtubeURL string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle youtube.com URLs with ?v= parameter
|
|
||||||
parsed, err := url.Parse(youtubeURL)
|
parsed, err := url.Parse(youtubeURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -349,7 +403,6 @@ func extractYouTubeIDFromURL(youtubeURL string) string {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle /embed/ format
|
|
||||||
if strings.Contains(parsed.Path, "/embed/") {
|
if strings.Contains(parsed.Path, "/embed/") {
|
||||||
parts := strings.Split(parsed.Path, "/embed/")
|
parts := strings.Split(parsed.Path, "/embed/")
|
||||||
if len(parts) >= 2 {
|
if len(parts) >= 2 {
|
||||||
@@ -478,7 +531,6 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
|
|||||||
return availability, nil
|
return availability, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkAvailabilityFromDeezerSongLink is the original SongLink implementation for Deezer
|
|
||||||
func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID string) (*TrackAvailability, error) {
|
func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID string) (*TrackAvailability, error) {
|
||||||
songLinkRateLimiter.WaitForSlot()
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const DefaultSpotFetchAPIBaseURL = "https://spotify.afkarxyz.fun/api"
|
const DefaultSpotFetchAPIBaseURL = "https://sp.afkarxyz.qzz.io/api"
|
||||||
|
|
||||||
// GetSpotifyDataWithAPI fetches Spotify metadata through SpotFetch-compatible API.
|
// GetSpotifyDataWithAPI fetches Spotify metadata through SpotFetch-compatible API.
|
||||||
// This is used as a fallback when direct Spotify API access is blocked/limited.
|
// This is used as a fallback when direct Spotify API access is blocked/limited.
|
||||||
|
|||||||
+5
-29
@@ -9,7 +9,6 @@ import (
|
|||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -64,45 +63,20 @@ var (
|
|||||||
credentialsMu sync.RWMutex
|
credentialsMu sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrNoSpotifyCredentials = errors.New("Spotify credentials not configured. Please set your own Client ID and Secret in Settings, or use Deezer as metadata source (free, no credentials required)")
|
var ErrNoSpotifyCredentials = errors.New("built-in Spotify API metadata provider has been removed; use Deezer or the spotify-web extension instead")
|
||||||
|
|
||||||
func SetSpotifyCredentials(clientID, clientSecret string) {
|
func SetSpotifyCredentials(clientID, clientSecret string) {
|
||||||
credentialsMu.Lock()
|
credentialsMu.Lock()
|
||||||
defer credentialsMu.Unlock()
|
defer credentialsMu.Unlock()
|
||||||
customClientID = clientID
|
customClientID = ""
|
||||||
customClientSecret = clientSecret
|
customClientSecret = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func HasSpotifyCredentials() bool {
|
func HasSpotifyCredentials() bool {
|
||||||
credentialsMu.RLock()
|
|
||||||
defer credentialsMu.RUnlock()
|
|
||||||
|
|
||||||
if customClientID != "" && customClientSecret != "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func getCredentials() (string, string, error) {
|
func getCredentials() (string, string, error) {
|
||||||
credentialsMu.RLock()
|
|
||||||
defer credentialsMu.RUnlock()
|
|
||||||
|
|
||||||
if customClientID != "" && customClientSecret != "" {
|
|
||||||
return customClientID, customClientSecret, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
|
||||||
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
|
||||||
|
|
||||||
if clientID != "" && clientSecret != "" {
|
|
||||||
return clientID, clientSecret, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", "", ErrNoSpotifyCredentials
|
return "", "", ErrNoSpotifyCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +157,8 @@ type AlbumResponsePayload struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PlaylistInfoMetadata struct {
|
type PlaylistInfoMetadata struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Images string `json:"images,omitempty"`
|
||||||
Tracks struct {
|
Tracks struct {
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
|
|||||||
+810
-69
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,222 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseTidalURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantType string
|
||||||
|
wantID string
|
||||||
|
expectErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "track url",
|
||||||
|
input: "https://tidal.com/track/77616174",
|
||||||
|
wantType: "track",
|
||||||
|
wantID: "77616174",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "browse album url",
|
||||||
|
input: "https://listen.tidal.com/browse/album/77616169",
|
||||||
|
wantType: "album",
|
||||||
|
wantID: "77616169",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "artist url",
|
||||||
|
input: "https://www.tidal.com/artist/3852143",
|
||||||
|
wantType: "artist",
|
||||||
|
wantID: "3852143",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "playlist url",
|
||||||
|
input: "https://tidal.com/playlist/edf3b7d2-cb42-41d7-93c0-afa2a395521b",
|
||||||
|
wantType: "playlist",
|
||||||
|
wantID: "edf3b7d2-cb42-41d7-93c0-afa2a395521b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unsupported host",
|
||||||
|
input: "https://example.com/track/123",
|
||||||
|
expectErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
gotType, gotID, err := parseTidalURL(test.input)
|
||||||
|
if test.expectErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, got none")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if gotType != test.wantType || gotID != test.wantID {
|
||||||
|
t.Fatalf("parseTidalURL(%q) = (%q, %q), want (%q, %q)", test.input, gotType, gotID, test.wantType, test.wantID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTidalRequestTrackID(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want int64
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{input: "40681594", want: 40681594, ok: true},
|
||||||
|
{input: "tidal:40681594", want: 40681594, ok: true},
|
||||||
|
{input: " tidal:40681594 ", want: 40681594, ok: true},
|
||||||
|
{input: "", want: 0, ok: false},
|
||||||
|
{input: "tidal:not-a-number", want: 0, ok: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
got, ok := parseTidalRequestTrackID(test.input)
|
||||||
|
if got != test.want || ok != test.ok {
|
||||||
|
t.Fatalf("parseTidalRequestTrackID(%q) = (%d, %v), want (%d, %v)", test.input, got, ok, test.want, test.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTidalImageURL(t *testing.T) {
|
||||||
|
got := tidalImageURL("fc18a64b-d76b-4582-962a-224cb05193f3", "1280x1280")
|
||||||
|
want := "https://resources.tidal.com/images/fc18a64b/d76b/4582/962a/224cb05193f3/1280x1280.jpg"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("tidalImageURL() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTidalTrackToTrackMetadata(t *testing.T) {
|
||||||
|
track := &TidalTrack{
|
||||||
|
ID: 77616174,
|
||||||
|
Title: "Bruckner: Symphony No. 5",
|
||||||
|
ISRC: "GBUM71507433",
|
||||||
|
Duration: 1172,
|
||||||
|
TrackNumber: 5,
|
||||||
|
VolumeNumber: 1,
|
||||||
|
URL: "http://www.tidal.com/track/77616174",
|
||||||
|
}
|
||||||
|
track.Artist.ID = 3852143
|
||||||
|
track.Artist.Name = "Staatskapelle Berlin"
|
||||||
|
track.Artists = []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Picture string `json:"picture"`
|
||||||
|
}{
|
||||||
|
{ID: 3852143, Name: "Staatskapelle Berlin", Type: "MAIN"},
|
||||||
|
{ID: 12430, Name: "Daniel Barenboim", Type: "FEATURED"},
|
||||||
|
}
|
||||||
|
track.Album.ID = 77616169
|
||||||
|
track.Album.Title = "Bruckner: Symphonies 4-9"
|
||||||
|
track.Album.Cover = "fc18a64b-d76b-4582-962a-224cb05193f3"
|
||||||
|
track.Album.ReleaseDate = "2016-02-26"
|
||||||
|
|
||||||
|
got := tidalTrackToTrackMetadata(track)
|
||||||
|
if got.SpotifyID != "tidal:77616174" {
|
||||||
|
t.Fatalf("unexpected track ID: %q", got.SpotifyID)
|
||||||
|
}
|
||||||
|
if got.Artists != "Staatskapelle Berlin, Daniel Barenboim" {
|
||||||
|
t.Fatalf("unexpected artists: %q", got.Artists)
|
||||||
|
}
|
||||||
|
if got.AlbumID != "tidal:77616169" {
|
||||||
|
t.Fatalf("unexpected album ID: %q", got.AlbumID)
|
||||||
|
}
|
||||||
|
if got.ArtistID != "tidal:3852143" {
|
||||||
|
t.Fatalf("unexpected artist ID: %q", got.ArtistID)
|
||||||
|
}
|
||||||
|
if got.Images == "" || got.ExternalURL != "https://www.tidal.com/track/77616174" {
|
||||||
|
t.Fatalf("unexpected image/url: %q / %q", got.Images, got.ExternalURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTidalAlbumToArtistAlbum(t *testing.T) {
|
||||||
|
album := &tidalPublicAlbum{
|
||||||
|
ID: 77616169,
|
||||||
|
Title: "Bruckner: Symphonies 4-9",
|
||||||
|
Type: "ALBUM",
|
||||||
|
Cover: "fc18a64b-d76b-4582-962a-224cb05193f3",
|
||||||
|
ReleaseDate: "2016-02-26",
|
||||||
|
NumberOfTracks: 23,
|
||||||
|
Artists: []tidalPublicArtist{
|
||||||
|
{ID: 3852143, Name: "Staatskapelle Berlin", Type: "MAIN"},
|
||||||
|
{ID: 12430, Name: "Daniel Barenboim", Type: "FEATURED"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := tidalAlbumToArtistAlbum(album)
|
||||||
|
if got.ID != "tidal:77616169" {
|
||||||
|
t.Fatalf("unexpected album ID: %q", got.ID)
|
||||||
|
}
|
||||||
|
if got.AlbumType != "album" {
|
||||||
|
t.Fatalf("unexpected album type: %q", got.AlbumType)
|
||||||
|
}
|
||||||
|
if got.Artists != "Staatskapelle Berlin, Daniel Barenboim" {
|
||||||
|
t.Fatalf("unexpected artists: %q", got.Artists)
|
||||||
|
}
|
||||||
|
if got.Images == "" {
|
||||||
|
t.Fatalf("expected image URL, got empty string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTidalAlbumToArtistAlbumWithFallbackType(t *testing.T) {
|
||||||
|
album := &tidalPublicAlbum{
|
||||||
|
ID: 490623904,
|
||||||
|
Title: "LET 'EM KNOW",
|
||||||
|
Cover: "fc18a64b-d76b-4582-962a-224cb05193f3",
|
||||||
|
NumberOfTracks: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
got := tidalAlbumToArtistAlbumWithType(album, "single")
|
||||||
|
if got.AlbumType != "single" {
|
||||||
|
t.Fatalf("unexpected fallback album type: %q", got.AlbumType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTidalArtistAlbumTypeFromModuleTitle(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
title string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{title: "Albums", want: "album"},
|
||||||
|
{title: "EP & Singles", want: "single"},
|
||||||
|
{title: "Compilations", want: "album"},
|
||||||
|
{title: "Appears On", want: "album"},
|
||||||
|
{title: "Unknown", want: ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
if got := tidalArtistAlbumTypeFromModuleTitle(test.title); got != test.want {
|
||||||
|
t.Fatalf("tidalArtistAlbumTypeFromModuleTitle(%q) = %q, want %q", test.title, got, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTidalPlaylistImageUsesOrigin(t *testing.T) {
|
||||||
|
got := tidalImageURL("e6b59fd3-6995-40f0-8a32-174db3a8f4f2", "origin")
|
||||||
|
want := "https://resources.tidal.com/images/e6b59fd3/6995/40f0/8a32/174db3a8f4f2/origin.jpg"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("unexpected origin playlist image URL: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTidalPlaylistOwnerName(t *testing.T) {
|
||||||
|
editorial := &tidalPublicPlaylist{Type: "EDITORIAL"}
|
||||||
|
if got := tidalPlaylistOwnerName(editorial); got != "TIDAL" {
|
||||||
|
t.Fatalf("unexpected editorial owner: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
artist := &tidalPublicPlaylist{Type: "ARTIST"}
|
||||||
|
if got := tidalPlaylistOwnerName(artist); got != "Artist" {
|
||||||
|
t.Fatalf("unexpected artist owner: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &tidalPublicPlaylist{}
|
||||||
|
user.Creator.Name = "djtest"
|
||||||
|
if got := tidalPlaylistOwnerName(user); got != "djtest" {
|
||||||
|
t.Fatalf("unexpected creator owner: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,3 +68,45 @@ func normalizeSymbolOnlyTitle(title string) string {
|
|||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Shared Track Verification ====================
|
||||||
|
|
||||||
|
// resolvedTrackInfo holds the metadata fetched from a provider for verification.
|
||||||
|
type resolvedTrackInfo struct {
|
||||||
|
Title string
|
||||||
|
ArtistName string
|
||||||
|
Duration int // seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// trackMatchesRequest checks whether a resolved track from a provider matches
|
||||||
|
// the original download request. Returns true if the track is a plausible match.
|
||||||
|
func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool {
|
||||||
|
if req.ArtistName != "" && resolved.ArtistName != "" &&
|
||||||
|
!artistsMatch(req.ArtistName, resolved.ArtistName) {
|
||||||
|
GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n",
|
||||||
|
logPrefix, req.ArtistName, resolved.ArtistName)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.TrackName != "" && resolved.Title != "" &&
|
||||||
|
!titlesMatch(req.TrackName, resolved.Title) {
|
||||||
|
GoLog("[%s] Verification failed: title mismatch — expected '%s', got '%s'\n",
|
||||||
|
logPrefix, req.TrackName, resolved.Title)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedDurationSec := req.DurationMS / 1000
|
||||||
|
if expectedDurationSec > 0 && resolved.Duration > 0 {
|
||||||
|
diff := expectedDurationSec - resolved.Duration
|
||||||
|
if diff < 0 {
|
||||||
|
diff = -diff
|
||||||
|
}
|
||||||
|
if diff > 10 {
|
||||||
|
GoLog("[%s] Verification failed: duration mismatch — expected %ds, got %ds\n",
|
||||||
|
logPrefix, expectedDurationSec, resolved.Duration)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
+70
-15
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend - YouTube download via Cobalt API (lossy-only provider)
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -12,7 +11,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type YouTubeDownloader struct {
|
type YouTubeDownloader struct {
|
||||||
@@ -31,6 +29,7 @@ var (
|
|||||||
type YouTubeQuality string
|
type YouTubeQuality string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
YouTubeQualityOpus320 YouTubeQuality = "opus_320"
|
||||||
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
|
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
|
||||||
YouTubeQualityOpus128 YouTubeQuality = "opus_128"
|
YouTubeQualityOpus128 YouTubeQuality = "opus_128"
|
||||||
YouTubeQualityMP3128 YouTubeQuality = "mp3_128"
|
YouTubeQualityMP3128 YouTubeQuality = "mp3_128"
|
||||||
@@ -39,7 +38,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
youtubeOpusSupportedBitrates = []int{128, 256}
|
youtubeOpusSupportedBitrates = []int{128, 256, 320}
|
||||||
youtubeMp3SupportedBitrates = []int{128, 256, 320}
|
youtubeMp3SupportedBitrates = []int{128, 256, 320}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -83,7 +82,7 @@ type YouTubeDownloadResult struct {
|
|||||||
func NewYouTubeDownloader() *YouTubeDownloader {
|
func NewYouTubeDownloader() *YouTubeDownloader {
|
||||||
youtubeDownloaderOnce.Do(func() {
|
youtubeDownloaderOnce.Do(func() {
|
||||||
globalYouTubeDownloader = &YouTubeDownloader{
|
globalYouTubeDownloader = &YouTubeDownloader{
|
||||||
client: NewHTTPClientWithTimeout(120 * time.Second),
|
client: NewHTTPClientWithTimeout(DownloadTimeout),
|
||||||
apiURL: "https://api.qwkuns.me",
|
apiURL: "https://api.qwkuns.me",
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -148,6 +147,8 @@ func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalize
|
|||||||
switch normalizedRaw {
|
switch normalizedRaw {
|
||||||
case "opus_256", "opus256", "opus":
|
case "opus_256", "opus256", "opus":
|
||||||
return "opus", 256, YouTubeQualityOpus256
|
return "opus", 256, YouTubeQualityOpus256
|
||||||
|
case "opus_320", "opus320":
|
||||||
|
return "opus", 320, YouTubeQualityOpus320
|
||||||
case "opus_128", "opus128":
|
case "opus_128", "opus128":
|
||||||
return "opus", 128, YouTubeQualityOpus128
|
return "opus", 128, YouTubeQualityOpus128
|
||||||
case "mp3_320", "mp3320", "mp3", "":
|
case "mp3_320", "mp3320", "mp3", "":
|
||||||
@@ -161,7 +162,6 @@ func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalize
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchYouTube returns a YouTube Music search URL for the given track
|
|
||||||
func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) {
|
func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) {
|
||||||
query := fmt.Sprintf("%s %s", artistName, trackName)
|
query := fmt.Sprintf("%s %s", artistName, trackName)
|
||||||
searchQuery := url.QueryEscape(query)
|
searchQuery := url.QueryEscape(query)
|
||||||
@@ -213,7 +213,6 @@ func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQua
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// requestCobaltDirect sends a download request to the primary Cobalt API.
|
|
||||||
func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) {
|
func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) {
|
||||||
reqBody := CobaltRequest{
|
reqBody := CobaltRequest{
|
||||||
URL: videoURL,
|
URL: videoURL,
|
||||||
@@ -470,7 +469,6 @@ func BuildYouTubeWatchURL(videoID string) string {
|
|||||||
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
|
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// isYouTubeVideoID checks if s is an 11-char YouTube video ID
|
|
||||||
func isYouTubeVideoID(s string) bool {
|
func isYouTubeVideoID(s string) bool {
|
||||||
if len(s) != 11 {
|
if len(s) != 11 {
|
||||||
return false
|
return false
|
||||||
@@ -515,12 +513,10 @@ func ExtractYouTubeVideoID(urlStr string) (string, error) {
|
|||||||
return "", fmt.Errorf("invalid URL: %w", err)
|
return "", fmt.Errorf("invalid URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// /watch?v=
|
|
||||||
if v := parsed.Query().Get("v"); v != "" {
|
if v := parsed.Query().Get("v"); v != "" {
|
||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// /embed/
|
|
||||||
if strings.Contains(parsed.Path, "/embed/") {
|
if strings.Contains(parsed.Path, "/embed/") {
|
||||||
parts := strings.Split(parsed.Path, "/embed/")
|
parts := strings.Split(parsed.Path, "/embed/")
|
||||||
if len(parts) >= 2 {
|
if len(parts) >= 2 {
|
||||||
@@ -528,7 +524,6 @@ func ExtractYouTubeVideoID(urlStr string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// /v/
|
|
||||||
if strings.Contains(parsed.Path, "/v/") {
|
if strings.Contains(parsed.Path, "/v/") {
|
||||||
parts := strings.Split(parsed.Path, "/v/")
|
parts := strings.Split(parsed.Path, "/v/")
|
||||||
if len(parts) >= 2 {
|
if len(parts) >= 2 {
|
||||||
@@ -539,12 +534,65 @@ func ExtractYouTubeVideoID(urlStr string) (string, error) {
|
|||||||
return "", fmt.Errorf("could not extract video ID from URL")
|
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) {
|
func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
|
||||||
downloader := NewYouTubeDownloader()
|
downloader := NewYouTubeDownloader()
|
||||||
|
|
||||||
format, bitrate, quality := parseYouTubeQualityInput(req.Quality)
|
format, bitrate, quality := parseYouTubeQualityInput(req.Quality)
|
||||||
|
|
||||||
// URL lookup priority: YouTube video ID > Spotify ID > Deezer ID > ISRC
|
// URL lookup priority: YouTube video ID > YT Music extension > SongLink (Spotify/Deezer/ISRC)
|
||||||
var youtubeURL string
|
var youtubeURL string
|
||||||
var lookupErr error
|
var lookupErr error
|
||||||
|
|
||||||
@@ -554,7 +602,15 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
|
|||||||
GoLog("[YouTube] SpotifyID appears to be YouTube video ID, using directly: %s\n", youtubeURL)
|
GoLog("[YouTube] SpotifyID appears to be YouTube video ID, using directly: %s\n", youtubeURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try Spotify ID via SongLink
|
// 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) {
|
if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) {
|
||||||
GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID)
|
GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID)
|
||||||
songlink := NewSongLinkClient()
|
songlink := NewSongLinkClient()
|
||||||
@@ -566,7 +622,7 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try Deezer ID via SongLink
|
// Fallback: Try Deezer ID via SongLink
|
||||||
if youtubeURL == "" && req.DeezerID != "" {
|
if youtubeURL == "" && req.DeezerID != "" {
|
||||||
GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID)
|
GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID)
|
||||||
songlink := NewSongLinkClient()
|
songlink := NewSongLinkClient()
|
||||||
@@ -578,7 +634,7 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try ISRC via SongLink
|
// Fallback: Try ISRC via SongLink
|
||||||
if youtubeURL == "" && req.ISRC != "" {
|
if youtubeURL == "" && req.ISRC != "" {
|
||||||
GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC)
|
GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC)
|
||||||
songlink := NewSongLinkClient()
|
songlink := NewSongLinkClient()
|
||||||
@@ -646,7 +702,6 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
|
|||||||
|
|
||||||
GoLog("[YouTube] Downloading to: %s\n", outputPath)
|
GoLog("[YouTube] Downloading to: %s\n", outputPath)
|
||||||
|
|
||||||
// Parallel fetch cover art + lyrics
|
|
||||||
var parallelResult *ParallelDownloadResult
|
var parallelResult *ParallelDownloadResult
|
||||||
if req.EmbedLyrics || req.CoverURL != "" {
|
if req.EmbedLyrics || req.CoverURL != "" {
|
||||||
GoLog("[YouTube] Starting parallel fetch for cover and lyrics...\n")
|
GoLog("[YouTube] Starting parallel fetch for cover and lyrics...\n")
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ func TestParseYouTubeQualityInput_Mp3NormalizesToSupportedBitrates(t *testing.T)
|
|||||||
|
|
||||||
func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
|
func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
|
||||||
_, opusBitrate, _ := parseYouTubeQualityInput("opus_999")
|
_, opusBitrate, _ := parseYouTubeQualityInput("opus_999")
|
||||||
if opusBitrate != 256 {
|
if opusBitrate != 320 {
|
||||||
t.Fatalf("expected opus normalization to 256, got %d", opusBitrate)
|
t.Fatalf("expected opus normalization to 320, got %d", opusBitrate)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1")
|
_, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1")
|
||||||
@@ -39,3 +39,16 @@ func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
|
|||||||
t.Fatalf("expected mp3 normalization to 128, got %d", mp3Bitrate)
|
t.Fatalf("expected mp3 normalization to 128, got %d", mp3Bitrate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseYouTubeQualityInput_Opus320(t *testing.T) {
|
||||||
|
format, bitrate, normalized := parseYouTubeQualityInput("opus_320")
|
||||||
|
if format != "opus" {
|
||||||
|
t.Fatalf("expected opus format, got %s", format)
|
||||||
|
}
|
||||||
|
if bitrate != 320 {
|
||||||
|
t.Fatalf("expected 320 bitrate, got %d", bitrate)
|
||||||
|
}
|
||||||
|
if normalized != YouTubeQualityOpus320 {
|
||||||
|
t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus320, normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+192
-50
@@ -15,6 +15,9 @@ import Gobackend // Import Go framework
|
|||||||
private var libraryScanProgressEventSink: FlutterEventSink?
|
private var libraryScanProgressEventSink: FlutterEventSink?
|
||||||
private var lastLibraryScanProgressPayload: String?
|
private var lastLibraryScanProgressPayload: String?
|
||||||
|
|
||||||
|
/// Currently accessed security-scoped URL for library folder
|
||||||
|
private var activeSecurityScopedURL: URL?
|
||||||
|
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
@@ -157,38 +160,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
case "getSpotifyMetadata":
|
|
||||||
let args = call.arguments as! [String: Any]
|
|
||||||
let url = args["url"] as! String
|
|
||||||
let response = GobackendGetSpotifyMetadata(url, &error)
|
|
||||||
if let error = error { throw error }
|
|
||||||
return response
|
|
||||||
|
|
||||||
case "searchSpotify":
|
|
||||||
let args = call.arguments as! [String: Any]
|
|
||||||
let query = args["query"] as! String
|
|
||||||
let limit = args["limit"] as? Int ?? 10
|
|
||||||
let response = GobackendSearchSpotify(query, Int(limit), &error)
|
|
||||||
if let error = error { throw error }
|
|
||||||
return response
|
|
||||||
|
|
||||||
case "searchSpotifyAll":
|
|
||||||
let args = call.arguments as! [String: Any]
|
|
||||||
let query = args["query"] as! String
|
|
||||||
let trackLimit = args["track_limit"] as? Int ?? 15
|
|
||||||
let artistLimit = args["artist_limit"] as? Int ?? 3
|
|
||||||
let response = GobackendSearchSpotifyAll(query, Int(trackLimit), Int(artistLimit), &error)
|
|
||||||
if let error = error { throw error }
|
|
||||||
return response
|
|
||||||
|
|
||||||
case "getSpotifyRelatedArtists":
|
|
||||||
let args = call.arguments as! [String: Any]
|
|
||||||
let artistId = args["artist_id"] as! String
|
|
||||||
let limit = args["limit"] as? Int ?? 12
|
|
||||||
let response = GobackendGetSpotifyRelatedArtists(artistId, Int(limit), &error)
|
|
||||||
if let error = error { throw error }
|
|
||||||
return response
|
|
||||||
|
|
||||||
case "checkAvailability":
|
case "checkAvailability":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let spotifyId = args["spotify_id"] as! String
|
let spotifyId = args["spotify_id"] as! String
|
||||||
@@ -412,6 +383,22 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "getQobuzMetadata":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let resourceType = args["resource_type"] as! String
|
||||||
|
let resourceId = args["resource_id"] as! String
|
||||||
|
let response = GobackendGetQobuzMetadata(resourceType, resourceId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getTidalMetadata":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let resourceType = args["resource_type"] as! String
|
||||||
|
let resourceId = args["resource_id"] as! String
|
||||||
|
let response = GobackendGetTidalMetadata(resourceType, resourceId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
case "parseDeezerUrl":
|
case "parseDeezerUrl":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let url = args["url"] as! String
|
let url = args["url"] as! String
|
||||||
@@ -419,6 +406,13 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "parseQobuzUrl":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let url = args["url"] as! String
|
||||||
|
let response = GobackendParseQobuzURLExport(url, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
case "parseTidalUrl":
|
case "parseTidalUrl":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let url = args["url"] as! String
|
let url = args["url"] as! String
|
||||||
@@ -492,13 +486,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
case "getAmazonURLFromDeezerTrack":
|
|
||||||
let args = call.arguments as! [String: Any]
|
|
||||||
let deezerTrackId = args["deezer_track_id"] as! String
|
|
||||||
let response = GobackendGetAmazonURLFromDeezerTrack(deezerTrackId, &error)
|
|
||||||
if let error = error { throw error }
|
|
||||||
return response
|
|
||||||
|
|
||||||
case "preWarmTrackCache":
|
case "preWarmTrackCache":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let tracksJson = args["tracks"] as! String
|
let tracksJson = args["tracks"] as! String
|
||||||
@@ -514,17 +501,6 @@ import Gobackend // Import Go framework
|
|||||||
GobackendClearTrackCache()
|
GobackendClearTrackCache()
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
case "setSpotifyCredentials":
|
|
||||||
let args = call.arguments as! [String: Any]
|
|
||||||
let clientId = args["client_id"] as! String
|
|
||||||
let clientSecret = args["client_secret"] as! String
|
|
||||||
GobackendSetSpotifyAPICredentials(clientId, clientSecret)
|
|
||||||
return nil
|
|
||||||
|
|
||||||
case "hasSpotifyCredentials":
|
|
||||||
let hasCredentials = GobackendCheckSpotifyCredentials()
|
|
||||||
return hasCredentials
|
|
||||||
|
|
||||||
// Log methods
|
// Log methods
|
||||||
case "getLogs":
|
case "getLogs":
|
||||||
let response = GobackendGetLogs()
|
let response = GobackendGetLogs()
|
||||||
@@ -647,6 +623,20 @@ import Gobackend // Import Go framework
|
|||||||
let response = GobackendSearchTracksWithExtensionsJSON(query, Int(limit), &error)
|
let response = GobackendSearchTracksWithExtensionsJSON(query, Int(limit), &error)
|
||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "searchTracksWithMetadataProviders":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let query = args["query"] as! String
|
||||||
|
let limit = args["limit"] as? Int ?? 20
|
||||||
|
let includeExtensions = args["include_extensions"] as? Bool ?? true
|
||||||
|
let response = GobackendSearchTracksWithMetadataProvidersJSON(
|
||||||
|
query,
|
||||||
|
Int(limit),
|
||||||
|
includeExtensions,
|
||||||
|
&error
|
||||||
|
)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
case "enrichTrackWithExtension":
|
case "enrichTrackWithExtension":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
@@ -838,6 +828,23 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
|
case "setStoreRegistryUrl":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let registryUrl = args["registry_url"] as? String ?? ""
|
||||||
|
GobackendSetStoreRegistryURLJSON(registryUrl, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "getStoreRegistryUrl":
|
||||||
|
let response = GobackendGetStoreRegistryURLJSON(&error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "clearStoreRegistryUrl":
|
||||||
|
GobackendClearStoreRegistryURLJSON(&error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return nil
|
||||||
|
|
||||||
case "getStoreExtensions":
|
case "getStoreExtensions":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let forceRefresh = args["force_refresh"] as? Bool ?? false
|
let forceRefresh = args["force_refresh"] as? Bool ?? false
|
||||||
@@ -922,6 +929,26 @@ import Gobackend // Import Go framework
|
|||||||
let response = GobackendReadAudioMetadataJSON(filePath, &error)
|
let response = GobackendReadAudioMetadataJSON(filePath, &error)
|
||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
// iOS Security-Scoped Bookmark for Local Library
|
||||||
|
case "resolveIosBookmark":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let bookmarkBase64 = args["bookmark"] as! String
|
||||||
|
return try resolveIosBookmark(bookmarkBase64)
|
||||||
|
|
||||||
|
case "startAccessingIosBookmark":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let bookmarkBase64 = args["bookmark"] as! String
|
||||||
|
return try startAccessingIosBookmark(bookmarkBase64)
|
||||||
|
|
||||||
|
case "stopAccessingIosBookmark":
|
||||||
|
stopAccessingIosBookmark()
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "createIosBookmarkFromPath":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let path = args["path"] as! String
|
||||||
|
return try createIosBookmarkFromPath(path)
|
||||||
|
|
||||||
// Lyrics Provider Settings
|
// Lyrics Provider Settings
|
||||||
case "setLyricsProviders":
|
case "setLyricsProviders":
|
||||||
@@ -953,6 +980,15 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
// CUE Sheet Parsing
|
||||||
|
case "parseCueSheet":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let cuePath = args["cue_path"] as! String
|
||||||
|
let audioDir = args["audio_dir"] as? String ?? ""
|
||||||
|
let response = GobackendParseCueSheet(cuePath, audioDir, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw NSError(
|
throw NSError(
|
||||||
domain: "SpotiFLAC",
|
domain: "SpotiFLAC",
|
||||||
@@ -961,6 +997,112 @@ import Gobackend // Import Go framework
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - iOS Security-Scoped Bookmark Helpers
|
||||||
|
|
||||||
|
/// Create a security-scoped bookmark from a filesystem path (e.g. from FilePicker).
|
||||||
|
/// The path must currently be accessible (within the same picker session).
|
||||||
|
/// Returns base64-encoded bookmark data.
|
||||||
|
private func createIosBookmarkFromPath(_ path: String) throws -> String {
|
||||||
|
let url = URL(fileURLWithPath: path)
|
||||||
|
do {
|
||||||
|
let bookmarkData = try url.bookmarkData(
|
||||||
|
options: .minimalBookmark,
|
||||||
|
includingResourceValuesForKeys: nil,
|
||||||
|
relativeTo: nil
|
||||||
|
)
|
||||||
|
return bookmarkData.base64EncodedString()
|
||||||
|
} catch {
|
||||||
|
throw NSError(
|
||||||
|
domain: "SpotiFLAC",
|
||||||
|
code: -1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Failed to create bookmark for path \(path): \(error.localizedDescription)"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a base64-encoded security-scoped bookmark and return the resolved path.
|
||||||
|
/// Does NOT start accessing the resource.
|
||||||
|
private func resolveIosBookmark(_ bookmarkBase64: String) throws -> String {
|
||||||
|
guard let bookmarkData = Data(base64Encoded: bookmarkBase64) else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "SpotiFLAC",
|
||||||
|
code: -1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Invalid base64 bookmark data"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isStale = false
|
||||||
|
let url: URL
|
||||||
|
do {
|
||||||
|
url = try URL(
|
||||||
|
resolvingBookmarkData: bookmarkData,
|
||||||
|
options: [],
|
||||||
|
relativeTo: nil,
|
||||||
|
bookmarkDataIsStale: &isStale
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
throw NSError(
|
||||||
|
domain: "SpotiFLAC",
|
||||||
|
code: -1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Failed to resolve bookmark: \(error.localizedDescription)"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a base64-encoded bookmark, start accessing the security-scoped resource,
|
||||||
|
/// and return the resolved filesystem path. The resource stays accessed until
|
||||||
|
/// `stopAccessingIosBookmark()` is called.
|
||||||
|
private func startAccessingIosBookmark(_ bookmarkBase64: String) throws -> String {
|
||||||
|
// Stop any previously accessed resource first
|
||||||
|
stopAccessingIosBookmark()
|
||||||
|
|
||||||
|
guard let bookmarkData = Data(base64Encoded: bookmarkBase64) else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "SpotiFLAC",
|
||||||
|
code: -1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Invalid base64 bookmark data"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isStale = false
|
||||||
|
let url: URL
|
||||||
|
do {
|
||||||
|
url = try URL(
|
||||||
|
resolvingBookmarkData: bookmarkData,
|
||||||
|
options: [],
|
||||||
|
relativeTo: nil,
|
||||||
|
bookmarkDataIsStale: &isStale
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
throw NSError(
|
||||||
|
domain: "SpotiFLAC",
|
||||||
|
code: -1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Failed to resolve bookmark: \(error.localizedDescription)"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard url.startAccessingSecurityScopedResource() else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "SpotiFLAC",
|
||||||
|
code: -1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Failed to start accessing security-scoped resource at \(url.path)"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
activeSecurityScopedURL = url
|
||||||
|
return url.path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop accessing the currently active security-scoped resource, if any.
|
||||||
|
private func stopAccessingIosBookmark() {
|
||||||
|
if let url = activeSecurityScopedURL {
|
||||||
|
url.stopAccessingSecurityScopedResource()
|
||||||
|
activeSecurityScopedURL = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class ClosureStreamHandler: NSObject, FlutterStreamHandler {
|
private final class ClosureStreamHandler: NSObject, FlutterStreamHandler {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ final _routerProvider = Provider<GoRouter>((ref) {
|
|||||||
settingsProvider.select((s) => s.hasCompletedTutorial),
|
settingsProvider.select((s) => s.hasCompletedTutorial),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Determine initial location based on app state
|
|
||||||
String initialLocation;
|
String initialLocation;
|
||||||
if (isFirstLaunch) {
|
if (isFirstLaunch) {
|
||||||
initialLocation = '/setup';
|
initialLocation = '/setup';
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '3.7.0';
|
static const String version = '3.8.6';
|
||||||
static const String buildNumber = '103';
|
static const String buildNumber = '112';
|
||||||
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';
|
||||||
|
|||||||
+880
-1579
File diff suppressed because it is too large
Load Diff
+907
-1253
File diff suppressed because it is too large
Load Diff
+558
-909
File diff suppressed because it is too large
Load Diff
+567
-1789
File diff suppressed because it is too large
Load Diff
+554
-911
File diff suppressed because it is too large
Load Diff
+554
-907
File diff suppressed because it is too large
Load Diff
+632
-992
File diff suppressed because it is too large
Load Diff
+634
-979
File diff suppressed because it is too large
Load Diff
+768
-1133
File diff suppressed because it is too large
Load Diff
+554
-907
File diff suppressed because it is too large
Load Diff
+567
-1786
File diff suppressed because it is too large
Load Diff
+667
-1059
File diff suppressed because it is too large
Load Diff
+556
-911
File diff suppressed because it is too large
Load Diff
+3118
-4714
File diff suppressed because it is too large
Load Diff
+589
-1367
File diff suppressed because it is too large
Load Diff
+2894
-1542
File diff suppressed because it is too large
Load Diff
+12
-864
File diff suppressed because it is too large
Load Diff
+21
-1101
File diff suppressed because it is too large
Load Diff
+311
-1089
File diff suppressed because it is too large
Load Diff
+311
-1089
File diff suppressed because it is too large
Load Diff
+2919
-3995
File diff suppressed because it is too large
Load Diff
+386
-1164
File diff suppressed because it is too large
Load Diff
+528
-1306
File diff suppressed because it is too large
Load Diff
+311
-1089
File diff suppressed because it is too large
Load Diff
+12
-864
File diff suppressed because it is too large
Load Diff
+21
-1101
File diff suppressed because it is too large
Load Diff
+364
-1142
File diff suppressed because it is too large
Load Diff
+21
-1101
File diff suppressed because it is too large
Load Diff
+12
-864
File diff suppressed because it is too large
Load Diff
+311
-1089
File diff suppressed because it is too large
Load Diff
+311
-1089
File diff suppressed because it is too large
Load Diff
+137
-6
@@ -1,13 +1,16 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:spotiflac_android/app.dart';
|
import 'package:spotiflac_android/app.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/services/notification_service.dart';
|
import 'package:spotiflac_android/services/notification_service.dart';
|
||||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||||
@@ -88,15 +91,143 @@ class _EagerInitialization extends ConsumerStatefulWidget {
|
|||||||
_EagerInitializationState();
|
_EagerInitializationState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
class _EagerInitializationState extends ConsumerState<_EagerInitialization>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
|
ProviderSubscription<bool>? _localLibraryEnabledSub;
|
||||||
|
Timer? _downloadHistoryWarmupTimer;
|
||||||
|
Timer? _libraryCollectionsWarmupTimer;
|
||||||
|
Timer? _localLibraryWarmupTimer;
|
||||||
|
bool _localLibraryWarmupScheduled = false;
|
||||||
|
bool _autoScanTriggeredOnLaunch = false;
|
||||||
|
|
||||||
|
static const _lastScannedAtKey = 'local_library_last_scanned_at';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_initializeAppServices();
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_initializeExtensions();
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
ref.read(downloadHistoryProvider);
|
if (!mounted) return;
|
||||||
ref.read(localLibraryProvider);
|
_initializeAppServices();
|
||||||
ref.read(libraryCollectionsProvider);
|
_initializeExtensions();
|
||||||
|
_initializeDeferredProviders();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
_localLibraryEnabledSub?.close();
|
||||||
|
_downloadHistoryWarmupTimer?.cancel();
|
||||||
|
_libraryCollectionsWarmupTimer?.cancel();
|
||||||
|
_localLibraryWarmupTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
_maybeAutoScanLocalLibrary();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initializeDeferredProviders() {
|
||||||
|
_downloadHistoryWarmupTimer = _scheduleProviderWarmup(
|
||||||
|
const Duration(milliseconds: 400),
|
||||||
|
() => ref.read(downloadHistoryProvider),
|
||||||
|
);
|
||||||
|
_libraryCollectionsWarmupTimer = _scheduleProviderWarmup(
|
||||||
|
const Duration(milliseconds: 900),
|
||||||
|
() => ref.read(libraryCollectionsProvider),
|
||||||
|
);
|
||||||
|
|
||||||
|
_maybeScheduleLocalLibraryWarmup(
|
||||||
|
ref.read(
|
||||||
|
settingsProvider.select((settings) => settings.localLibraryEnabled),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
_localLibraryEnabledSub = ref.listenManual<bool>(
|
||||||
|
settingsProvider.select((settings) => settings.localLibraryEnabled),
|
||||||
|
(previous, next) {
|
||||||
|
if (next == true) {
|
||||||
|
_maybeScheduleLocalLibraryWarmup(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer _scheduleProviderWarmup(Duration delay, VoidCallback action) {
|
||||||
|
return Timer(delay, () {
|
||||||
|
if (!mounted) return;
|
||||||
|
action();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _maybeScheduleLocalLibraryWarmup(bool enabled) {
|
||||||
|
if (!enabled || _localLibraryWarmupScheduled) return;
|
||||||
|
_localLibraryWarmupScheduled = true;
|
||||||
|
_localLibraryWarmupTimer = _scheduleProviderWarmup(
|
||||||
|
const Duration(milliseconds: 1600),
|
||||||
|
() {
|
||||||
|
ref.read(localLibraryProvider);
|
||||||
|
// Trigger auto-scan after initial warmup on first app launch.
|
||||||
|
if (!_autoScanTriggeredOnLaunch) {
|
||||||
|
_autoScanTriggeredOnLaunch = true;
|
||||||
|
// Give the provider a moment to load existing data before scanning.
|
||||||
|
Future.delayed(const Duration(milliseconds: 500), () {
|
||||||
|
if (mounted) _maybeAutoScanLocalLibrary();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks whether an automatic incremental scan should be triggered based on
|
||||||
|
/// the user's auto-scan preference and the time since the last scan.
|
||||||
|
Future<void> _maybeAutoScanLocalLibrary() async {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
if (!settings.localLibraryEnabled) return;
|
||||||
|
if (settings.localLibraryPath.isEmpty) return;
|
||||||
|
if (settings.localLibraryAutoScan == 'off') return;
|
||||||
|
|
||||||
|
// Don't start a scan if one is already running.
|
||||||
|
final libraryState = ref.read(localLibraryProvider);
|
||||||
|
if (libraryState.isScanning) return;
|
||||||
|
|
||||||
|
// Determine cooldown based on auto-scan mode.
|
||||||
|
final now = DateTime.now();
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final lastScannedMs = prefs.getInt(_lastScannedAtKey);
|
||||||
|
|
||||||
|
if (lastScannedMs != null) {
|
||||||
|
final lastScanned = DateTime.fromMillisecondsSinceEpoch(lastScannedMs);
|
||||||
|
final elapsed = now.difference(lastScanned);
|
||||||
|
|
||||||
|
switch (settings.localLibraryAutoScan) {
|
||||||
|
case 'on_open':
|
||||||
|
// Cooldown of 10 minutes to prevent rapid re-scans.
|
||||||
|
if (elapsed.inMinutes < 10) return;
|
||||||
|
break;
|
||||||
|
case 'daily':
|
||||||
|
if (elapsed.inHours < 24) return;
|
||||||
|
break;
|
||||||
|
case 'weekly':
|
||||||
|
if (elapsed.inDays < 7) return;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All checks passed -- start an incremental scan.
|
||||||
|
final iosBookmark = settings.localLibraryBookmark;
|
||||||
|
ref.read(localLibraryProvider.notifier).startScan(
|
||||||
|
settings.localLibraryPath,
|
||||||
|
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initializeAppServices() async {
|
Future<void> _initializeAppServices() async {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class DownloadItem {
|
|||||||
final DownloadErrorType? errorType;
|
final DownloadErrorType? errorType;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
final String? qualityOverride; // Override quality for this specific download
|
final String? qualityOverride; // Override quality for this specific download
|
||||||
|
final String? playlistName; // Playlist context for folder organization
|
||||||
|
|
||||||
const DownloadItem({
|
const DownloadItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -48,6 +49,7 @@ class DownloadItem {
|
|||||||
this.errorType,
|
this.errorType,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
this.qualityOverride,
|
this.qualityOverride,
|
||||||
|
this.playlistName,
|
||||||
});
|
});
|
||||||
|
|
||||||
DownloadItem copyWith({
|
DownloadItem copyWith({
|
||||||
@@ -63,6 +65,7 @@ class DownloadItem {
|
|||||||
DownloadErrorType? errorType,
|
DownloadErrorType? errorType,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
String? qualityOverride,
|
String? qualityOverride,
|
||||||
|
String? playlistName,
|
||||||
}) {
|
}) {
|
||||||
return DownloadItem(
|
return DownloadItem(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -77,6 +80,7 @@ class DownloadItem {
|
|||||||
errorType: errorType ?? this.errorType,
|
errorType: errorType ?? this.errorType,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
qualityOverride: qualityOverride ?? this.qualityOverride,
|
qualityOverride: qualityOverride ?? this.qualityOverride,
|
||||||
|
playlistName: playlistName ?? this.playlistName,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user