mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 03:15:51 +02:00
Compare commits
366 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb90c73f42 | |||
| c6cf65f075 | |||
| 25de009ebc | |||
| 8918d74bb5 | |||
| f9de8d45d9 | |||
| 48eef0853d | |||
| fc70a912bf | |||
| cd3e5b4b28 | |||
| 482ca82eb4 | |||
| 6d87ae5484 | |||
| bd3e2b999b | |||
| 186196e12b | |||
| bd73eb292d | |||
| 8ee2919934 | |||
| f29177216d | |||
| 18d3612674 | |||
| f7c0e417d7 | |||
| 3fd13e9930 | |||
| 0b20cb895e | |||
| 8979210804 | |||
| e9b24712c5 | |||
| 3d6e5615fa | |||
| fc7220b572 | |||
| 198ed5ce6f | |||
| b48462a945 | |||
| 0f327cd1f6 | |||
| 4f2e677e8b | |||
| 79a69f8f70 | |||
| bf0f4bdf3e | |||
| 5e1cc3ecb5 | |||
| d4b37edc2f | |||
| 9483614bc7 | |||
| a73f2e1a13 | |||
| 091e3fadd9 | |||
| 5340ca7b16 | |||
| 85d3e58a26 | |||
| 1125c757fe | |||
| 66d714d368 | |||
| 49c2501fbc | |||
| e487817f21 | |||
| d8bbeb1e67 | |||
| 9693616645 | |||
| 0423e36d34 | |||
| c8d605fdee | |||
| 03fd734048 | |||
| da9d64ccfd | |||
| 02e64b7a3c | |||
| a435009d4d | |||
| 9ca73a99a6 | |||
| 4974284760 | |||
| a0306bd345 | |||
| ea7e594c68 | |||
| d00a84f1b9 | |||
| 58b6203681 | |||
| d299144c47 | |||
| 40b224e5a1 | |||
| 7021e5493f | |||
| 68bbc8a259 | |||
| be94a59441 | |||
| 3a73aee1b7 | |||
| c91154ea3e | |||
| 4f365ca7fe | |||
| 98fdc0ed7c | |||
| 12be560cb8 | |||
| 4cf885a52e | |||
| c57c8a4267 | |||
| 2ca6c737c0 | |||
| 2a451ec2a3 | |||
| 346e79b247 | |||
| 497ba342c0 | |||
| aca0bbb819 | |||
| 2df8fd6282 | |||
| 999317eba1 | |||
| 16991476ed | |||
| ba33639818 | |||
| 23cab16471 | |||
| 0a892011de | |||
| acb1d957d3 | |||
| 4a492aeefc | |||
| eb143a41fc | |||
| 75db2f162b | |||
| 855d0e3ffc | |||
| 5ccd06cc68 | |||
| b2873378fc | |||
| 66a89d9e8e | |||
| 814deca19d | |||
| 3bb6754d9c | |||
| 7d11d67cd2 | |||
| c0bd10cfca | |||
| e003b15ffd | |||
| ac1c7d31c9 | |||
| 6fc9ffeb23 | |||
| 9bebed506b | |||
| bffeb55a7a | |||
| c66d13c9fd | |||
| 8529985a0e | |||
| a8a3973225 | |||
| 6710f90e1e | |||
| 929c5f3249 | |||
| f170ead7b9 | |||
| e63e366228 | |||
| 95e755e54e | |||
| c719406425 | |||
| 9627ef66cf | |||
| 15f977d98d | |||
| 5b5f043624 | |||
| 529a920b24 | |||
| 09eb6cf206 | |||
| af6fa6ea53 | |||
| 280b921755 | |||
| 6ebe0c51ce | |||
| 47bd24c1bd | |||
| 2b23678c0d | |||
| e8327545ad | |||
| 89a38af538 | |||
| b7f34ec47c | |||
| 967523bfc6 | |||
| 29d8a185f9 | |||
| 4495d4bf4e | |||
| 67737467e0 | |||
| 13845eea04 | |||
| 12779778d3 | |||
| d4178ad036 | |||
| 49ea84384d | |||
| a6d9849468 | |||
| 16100aa0fd | |||
| 387dd47374 | |||
| 6ecb69feae | |||
| feff985439 | |||
| 2e8fe34824 | |||
| f58005f406 | |||
| 75abc03a4f | |||
| 84381d142a | |||
| f67f52eba9 | |||
| 3747ffff64 | |||
| ed47efed17 | |||
| c0d72e89d7 | |||
| a4313cfe0f | |||
| c7bef03ee3 | |||
| ce5a9e0cff | |||
| 859b823e77 | |||
| 7d8cf5f7ca | |||
| 4adaed8da0 | |||
| 554fe08fcd | |||
| b8af75bf6e | |||
| 35f2f119db | |||
| f36096e0ac | |||
| 1665e4cd57 | |||
| 42f0267277 | |||
| 82f59d32b9 | |||
| 941347b007 | |||
| 739c89569f | |||
| 18607597e9 | |||
| 7bb808cba5 | |||
| 78cd396847 | |||
| bb342c01e2 | |||
| 8a5dc0edfe | |||
| 8540da484f | |||
| 20f789f8e0 | |||
| 3e89326c95 | |||
| a7ea4de25a | |||
| aabfbf062e | |||
| 7b9ed3ec8e | |||
| 6dad66d62d | |||
| 31018230ee | |||
| 54ddc1f59c | |||
| c6856bd1a1 | |||
| 8c18c7b8f1 | |||
| 10c5293f64 | |||
| d5381afcf9 | |||
| 134bf4375f | |||
| aa9854fc0a | |||
| 10bc29e347 | |||
| 733efce161 | |||
| ac9141f167 | |||
| d89850e8a9 | |||
| 5948e4f125 | |||
| 34d22f783c | |||
| c347b6999e | |||
| adc74741ce | |||
| 48f614359e | |||
| 16669d8b7a | |||
| f1eef47600 | |||
| fc1567d2c8 | |||
| fffce6039a | |||
| cbfa147a12 | |||
| 5b8c953ae6 | |||
| 37a4dc096b | |||
| b3808645fb | |||
| 24aa804bf2 | |||
| 941ffb2bb7 | |||
| 59737d6f2b | |||
| c8ad93ee9b | |||
| 8cb0c037c2 | |||
| e30b69397b | |||
| d6e837fd61 | |||
| 5c97d202b9 | |||
| 0f6cfa75bb | |||
| 91bd6d1572 | |||
| df77ae3986 | |||
| 3cd6d068a2 | |||
| 29165da5ac | |||
| 9343583c69 | |||
| d82d255bae | |||
| 93a7042a84 | |||
| 5be5c869da | |||
| 8d45e023b2 | |||
| f2ae1398db | |||
| c2736a61fb | |||
| 76fe8dbc69 | |||
| dd05061829 | |||
| 8f6b99c550 | |||
| f54ee86591 | |||
| 42e0ec2663 | |||
| 0456a97b35 | |||
| 07c609cc3a | |||
| de5d26403f | |||
| 73c2d0efac | |||
| d3c1c440cc | |||
| 94195c636f | |||
| 9abf492362 | |||
| defc84c216 | |||
| 3c9ae39145 | |||
| 64408c8d8b | |||
| db55bb4693 | |||
| 9c6856b584 | |||
| 581f43f4c1 | |||
| 221d7e4829 | |||
| 706528f04b | |||
| f95a96dd1f | |||
| d85c16ce0f | |||
| 35afdf4be4 | |||
| eb5ed86019 | |||
| 0cfa6f56be | |||
| 5af88ead33 | |||
| 8ec63ee610 | |||
| c8247bf7a0 | |||
| 2f3270c7ff | |||
| 960d60f0bc | |||
| 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:
|
||||
- name: Checkout repository
|
||||
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
|
||||
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: |
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
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:"
|
||||
echo "Generated changelog:"
|
||||
cat /tmp/changelog.txt
|
||||
|
||||
- name: Download Android APK
|
||||
@@ -352,15 +342,22 @@ jobs:
|
||||
- name: Prepare release body
|
||||
run: |
|
||||
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_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
|
||||
|
||||
---
|
||||
@@ -396,6 +393,63 @@ jobs:
|
||||
env:
|
||||
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:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [get-version, create-release]
|
||||
@@ -404,6 +458,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download Android APK
|
||||
uses: actions/download-artifact@v7
|
||||
@@ -417,52 +473,43 @@ jobs:
|
||||
name: ios-ipa
|
||||
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
|
||||
run: |
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
VERSION_NUM=${VERSION#v}
|
||||
|
||||
# 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."
|
||||
if [ ! -s /tmp/cliff_tg.txt ]; then
|
||||
echo "See release notes on GitHub for details." > /tmp/changelog.txt
|
||||
else
|
||||
# Convert GitHub Markdown to Telegram HTML:
|
||||
# - **text** → <b>text</b>
|
||||
# - `code` → <code>code</code>
|
||||
# - ### Header → <b>Header</b>
|
||||
# - Escape HTML special chars first
|
||||
# - Remove > blockquote prefix
|
||||
CHANGELOG=$(echo "$FULL_CHANGELOG" | \
|
||||
sed 's/^> //' | \
|
||||
# Convert Markdown to Telegram HTML
|
||||
CHANGELOG=$(cat /tmp/cliff_tg.txt | \
|
||||
sed '/^## [0-9][0-9.[:alpha:]-]*$/d' | \
|
||||
sed '/^\*\*Full Changelog\*\*/d' | \
|
||||
sed 's/ by \[@[^]]*\](https:\/\/github\.com\/[^)]*)//g' | \
|
||||
sed 's/ by @[A-Za-z0-9_-]\+//g' | \
|
||||
sed 's/\[#\([0-9]*\)\]([^)]*)/#\1/g' | \
|
||||
sed 's/\[@\([^]]*\)\]([^)]*)/@\1/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/^- /• /g' | \
|
||||
sed 's/^ - / ◦ /g')
|
||||
|
||||
# Take first 2500 characters, then cut at last complete line
|
||||
sed 's/^- /• /g')
|
||||
|
||||
# Truncate for Telegram 4096 char limit
|
||||
CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d')
|
||||
|
||||
# Check if truncated
|
||||
FULL_LEN=${#FULL_CHANGELOG}
|
||||
if [ $FULL_LEN -gt 2500 ]; then
|
||||
CHANGELOG="${CHANGELOG}"$'\n\n... (see full changelog on GitHub)'
|
||||
fi
|
||||
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||
fi
|
||||
|
||||
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||
echo "DEBUG: Final changelog:"
|
||||
echo "Telegram changelog:"
|
||||
cat /tmp/changelog.txt
|
||||
|
||||
- name: Send to Telegram Channel
|
||||
|
||||
@@ -12,6 +12,9 @@ Thumbs.db
|
||||
# Kiro specs (development only)
|
||||
.kiro/
|
||||
|
||||
# Design assets (banners, mockups)
|
||||
design/
|
||||
|
||||
# Reference folder (development only)
|
||||
referensi/
|
||||
|
||||
@@ -64,6 +67,7 @@ AGENTS.md
|
||||
|
||||
# Temp/misc
|
||||
nul
|
||||
network_requests.txt
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
@@ -73,3 +77,6 @@ flutter_*.log
|
||||
# Development tools
|
||||
tool/
|
||||
.claude/settings.local.json
|
||||
|
||||
# FVM Version Cache
|
||||
.fvm/
|
||||
|
||||
+74
-2
@@ -1,5 +1,77 @@
|
||||
# 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
|
||||
|
||||
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
|
||||
- New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)`
|
||||
- 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
|
||||
- Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC
|
||||
- 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`
|
||||
- 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 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 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
|
||||
|
||||
+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
|
||||
```
|
||||
|
||||
3. **Install dependencies**
|
||||
3. **Use FVM (Flutter Version: 3.38.1)**
|
||||
```bash
|
||||
fvm use
|
||||
```
|
||||
|
||||
4. **Install dependencies**
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
4. **Generate code** (for Riverpod, JSON serialization, etc.)
|
||||
5. **Generate code** (for Riverpod, JSON serialization, etc.)
|
||||
```bash
|
||||
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
|
||||
flutter run
|
||||
```
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/40f8f1914287dea317122a837f98b0ddf7af3205adc2f84a350d767e0a6a345c)
|
||||
[](https://crowdin.com/project/spotiflac-mobile)
|
||||
|
||||
<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>
|
||||
|
||||
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/31d1bf3c3b2015c13e83c4f909a7c6093a9423e3e702f0c582a3e0035c849424)
|
||||
[](https://crowdin.com/project/spotiflac-mobile)
|
||||
|
||||
[](https://t.me/spotiflac)
|
||||
[](https://t.me/spotiflac_chat)
|
||||
|
||||
</div>
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -24,79 +34,141 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no acc
|
||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Extensions
|
||||
|
||||
Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
|
||||
Extensions let the community add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
|
||||
|
||||
### Installing Extensions
|
||||
1. Go to **Store** tab in the app
|
||||
2. Browse and install extensions with one tap
|
||||
3. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
||||
4. Configure extension settings if needed
|
||||
5. Set provider priority in **Settings > Extensions > Provider Priority**
|
||||
|
||||
1. Open the **Store** tab in the app
|
||||
2. On first launch, enter an **Extension Repository URL** when prompted
|
||||
3. Browse and install extensions with one tap
|
||||
4. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
||||
5. Configure extension settings if needed
|
||||
6. Set provider priority under **Settings > Extensions > Provider Priority**
|
||||
|
||||
### Developing Extensions
|
||||
Want to create your own extension? Check out the [Extension Development Guide](https://zarz.moe/docs) for complete documentation.
|
||||
|
||||
## Other project
|
||||
> [!NOTE]
|
||||
> Want to build your own extension? The [Extension Development Guide](https://zarzet.github.io/SpotiFLAC-Mobile/docs) has everything you need.
|
||||
|
||||
---
|
||||
|
||||
## Related Projects
|
||||
|
||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
||||
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music available for Windows, macOS & Linux.
|
||||
|
||||
## Telegram
|
||||
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
|
||||
Python library for SpotiFLAC integration, maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu).
|
||||
|
||||
[](https://t.me/spotiflac)
|
||||
[](https://t.me/spotiflac_chat)
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
**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.
|
||||
<details>
|
||||
<summary><b>Why does the Store tab ask me to enter a URL?</b></summary>
|
||||
<br>
|
||||
|
||||
**Q: Why are some tracks downloading in lower quality?**
|
||||
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.
|
||||
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: Can I download playlists?**
|
||||
A: Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
|
||||
</details>
|
||||
|
||||
**Q: Why do I need to grant storage permission?**
|
||||
A: The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant "All files access" in Settings > Apps > SpotiFLAC > Permissions.
|
||||
<details>
|
||||
<summary><b>Why is my download failing with "Song not found"?</b></summary>
|
||||
<br>
|
||||
|
||||
**Q: Is this app safe?**
|
||||
A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README).
|
||||
The track may not be available on the streaming services. Try enabling more providers under **Settings > Download > Provider Priority**, or install additional extensions like Amazon Music from the Store.
|
||||
|
||||
**Q: Why is download not working in my country?**
|
||||
A: Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
|
||||
<br>
|
||||
|
||||
### Want to support SpotiFLAC-Mobile?
|
||||
Quality depends on what's available from the streaming service and its extensions. Built-in providers:
|
||||
- **Tidal** up to 24-bit/192kHz
|
||||
- **Qobuz** up to 24-bit/192kHz
|
||||
- **Deezer** up to 16-bit/44.1kHz
|
||||
|
||||
_If this software is useful and brings you value, consider supporting the project. Your support helps keep development going._
|
||||
</details>
|
||||
|
||||
[](https://ko-fi.com/zarzet)
|
||||
<details>
|
||||
<summary><b>Can I download playlists?</b></summary>
|
||||
<br>
|
||||
|
||||
Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
|
||||
|
||||
## Disclaimer
|
||||
</details>
|
||||
|
||||
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
||||
<details>
|
||||
<summary><b>Why do I need to grant storage permission?</b></summary>
|
||||
<br>
|
||||
|
||||
**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.
|
||||
The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant **All files access** under **Settings > Apps > SpotiFLAC > Permissions**.
|
||||
|
||||
The application is purely a user interface that facilitates communication between your device and existing third-party services.
|
||||
</details>
|
||||
|
||||
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.
|
||||
<details>
|
||||
<summary><b>Is this app safe?</b></summary>
|
||||
<br>
|
||||
|
||||
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.
|
||||
Yes SpotiFLAC is open source and you can verify the code yourself. Each release is also scanned with VirusTotal (see badge above).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Why is downloading not working in my country?</b></summary>
|
||||
<br>
|
||||
|
||||
Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Can I add SpotiFLAC to AltStore or SideStore?</b></summary>
|
||||
<br>
|
||||
|
||||
Yes! Add the official source to receive updates directly within the app. Copy this link:
|
||||
|
||||
```
|
||||
https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/refs/heads/main/apps.json
|
||||
```
|
||||
|
||||
In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link.
|
||||
|
||||
</details>
|
||||
|
||||
> [!NOTE]
|
||||
> If SpotiFLAC is useful to you, consider supporting development:
|
||||
>
|
||||
> [](https://ko-fi.com/zarzet)
|
||||
|
||||
---
|
||||
|
||||
## Contributors
|
||||
|
||||
Thanks to everyone who has contributed to SpotiFLAC Mobile!
|
||||
|
||||
<a href="https://github.com/zarzet/SpotiFLAC-Mobile/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=zarzet/SpotiFLAC-Mobile" />
|
||||
</a>
|
||||
|
||||
We also appreciate everyone who helped with [translations on Crowdin](https://crowdin.com/project/spotiflac-mobile), reported bugs, suggested features, and spread the word.
|
||||
|
||||
Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md) to get started!
|
||||
|
||||
---
|
||||
|
||||
## API Credits
|
||||
|
||||
[hifi-api](https://github.com/binimum/hifi-api) · [music.binimum.org](https://music.binimum.org) · [qqdl.site](https://qqdl.site) · [squid.wtf](https://squid.wtf) · [spotisaver.net](https://spotisaver.net) · [dabmusic.xyz](https://dabmusic.xyz) · [AfkarXYZ](https://github.com/afkarxyz) · [LRCLib](https://lrclib.net) · [Paxsenix](https://lyrics.paxsenix.org) · [Cobalt](https://cobalt.tools) · [qwkuns.me](https://qwkuns.me) · [SpotubeDL](https://spotubedl.com) · [Song.link](https://song.link) · [IDHS](https://github.com/sjdonado/idonthavespotify)
|
||||
|
||||
| | | | | |
|
||||
|---|---|---|---|---|
|
||||
| [hifi-api](https://github.com/binimum/hifi-api) | [music.binimum.org](https://music.binimum.org) | [qqdl.site](https://qqdl.site) | [squid.wtf](https://squid.wtf) | [spotisaver.net](https://spotisaver.net) |
|
||||
| [dabmusic.xyz](https://dabmusic.xyz) | [AfkarXYZ](https://github.com/afkarxyz) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) |
|
||||
| [qwkuns.me](https://qwkuns.me) | [SpotubeDL](https://spotubedl.com) | [Song.link](https://song.link) | [IDHS](https://github.com/sjdonado/idonthavespotify) | |
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
||||
> **Star the repo** to get notified about all new releases directly from GitHub.
|
||||
|
||||
@@ -9,6 +9,19 @@
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
- .dart_tool/**
|
||||
- lib/**/*.g.dart
|
||||
- lib/l10n/*.dart
|
||||
language:
|
||||
strict-casts: true
|
||||
strict-inference: true
|
||||
strict-raw-types: true
|
||||
plugins:
|
||||
- custom_lint
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
@@ -23,6 +36,13 @@ linter:
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
avoid_dynamic_calls: true
|
||||
cancel_subscriptions: true
|
||||
close_sinks: true
|
||||
|
||||
custom_lint:
|
||||
rules:
|
||||
- avoid_public_notifier_properties
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
|
||||
@@ -104,7 +104,7 @@ class DownloadService : Service() {
|
||||
updateNotification(progress, total)
|
||||
}
|
||||
}
|
||||
return START_STICKY
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
@@ -115,10 +115,8 @@ class DownloadService : Service() {
|
||||
* We must call stopSelf() within a few seconds to avoid a crash.
|
||||
*/
|
||||
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.")
|
||||
|
||||
// Gracefully stop the service
|
||||
stopForegroundService()
|
||||
}
|
||||
|
||||
@@ -139,14 +137,13 @@ class DownloadService : Service() {
|
||||
|
||||
private fun startForegroundService() {
|
||||
isRunning = true
|
||||
|
||||
// Acquire wake lock to prevent CPU sleep
|
||||
|
||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
wakeLock = powerManager.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK,
|
||||
WAKELOCK_TAG
|
||||
).apply {
|
||||
acquire(60 * 60 * 1000L) // 1 hour max
|
||||
acquire(60 * 60 * 1000L)
|
||||
}
|
||||
|
||||
val notification = buildNotification(0, 0)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "SpotiFLAC Source",
|
||||
"identifier": "com.zarzet.spotiflac.source",
|
||||
"subtitle": "FLAC Downloader for iOS",
|
||||
"apps": [
|
||||
{
|
||||
"name": "SpotiFLAC",
|
||||
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||
"developerName": "zarzet",
|
||||
"version": "3.9.0",
|
||||
"versionDate": "2026-03-25",
|
||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.9.0/SpotiFLAC-v3.9.0-ios-unsigned.ipa",
|
||||
"localizedDescription": "Mobile version of SpotiFLAC written in Flutter. Download Tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
|
||||
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
||||
"size": 34477323
|
||||
}
|
||||
]
|
||||
}
|
||||
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"
|
||||
)
|
||||
|
||||
// AudioMetadata represents common audio file metadata
|
||||
type AudioMetadata struct {
|
||||
Title string
|
||||
Artist string
|
||||
@@ -31,7 +30,6 @@ type AudioMetadata struct {
|
||||
Comment string
|
||||
}
|
||||
|
||||
// MP3Quality represents MP3 specific quality info
|
||||
type MP3Quality struct {
|
||||
SampleRate int
|
||||
BitDepth int
|
||||
@@ -39,7 +37,6 @@ type MP3Quality struct {
|
||||
Bitrate int
|
||||
}
|
||||
|
||||
// OggQuality represents Ogg/Opus specific quality info
|
||||
type OggQuality struct {
|
||||
SampleRate int
|
||||
BitDepth int
|
||||
@@ -47,10 +44,6 @@ type OggQuality struct {
|
||||
Bitrate int // estimated bitrate in bps
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ID3 Tag Reading (MP3)
|
||||
// =============================================================================
|
||||
|
||||
func ReadID3Tags(filePath string) (*AudioMetadata, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
@@ -505,7 +498,13 @@ func extractUserTextFrame(data []byte) (string, string) {
|
||||
|
||||
func isLyricsDescription(description string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(description)) {
|
||||
case "lyrics", "lyric", "unsyncedlyrics", "unsynced lyrics", "lrc":
|
||||
case
|
||||
"lyrics",
|
||||
"lyric",
|
||||
"unsyncedlyrics",
|
||||
"unsynced lyrics",
|
||||
"uslt",
|
||||
"lrc":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -1210,10 +1209,6 @@ func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ID3v1 Genre List
|
||||
// =============================================================================
|
||||
|
||||
var id3v1Genres = []string{
|
||||
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge",
|
||||
"Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B",
|
||||
@@ -1244,10 +1239,6 @@ var id3v1Genres = []string{
|
||||
"Thrash Metal", "Anime", "J-Pop", "Synthpop",
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Cover Art Extraction
|
||||
// =============================================================================
|
||||
|
||||
func extractMP3CoverArt(filePath string) ([]byte, string, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
@@ -1581,7 +1572,14 @@ func base64StdDecode(dst, src []byte) (int, 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))
|
||||
if ext == "" {
|
||||
ext = strings.ToLower(filepath.Ext(displayNameHint))
|
||||
}
|
||||
|
||||
switch ext {
|
||||
case ".flac":
|
||||
@@ -1602,7 +1600,19 @@ func extractAnyCoverArt(filePath string) ([]byte, string, error) {
|
||||
return extractOggCoverArt(filePath)
|
||||
|
||||
case ".m4a":
|
||||
return nil, "", fmt.Errorf("M4A cover extraction not yet supported")
|
||||
data, err := extractCoverFromM4A(filePath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
mimeType := "image/jpeg"
|
||||
if len(data) >= 8 &&
|
||||
data[0] == 0x89 &&
|
||||
data[1] == 0x50 &&
|
||||
data[2] == 0x4E &&
|
||||
data[3] == 0x47 {
|
||||
mimeType = "image/png"
|
||||
}
|
||||
return data, mimeType, nil
|
||||
|
||||
default:
|
||||
return nil, "", fmt.Errorf("unsupported format: %s", ext)
|
||||
@@ -1610,6 +1620,10 @@ func extractAnyCoverArt(filePath string) ([]byte, string, error) {
|
||||
}
|
||||
|
||||
func SaveCoverToCache(filePath, cacheDir string) (string, error) {
|
||||
return SaveCoverToCacheWithHint(filePath, "", cacheDir)
|
||||
}
|
||||
|
||||
func SaveCoverToCacheWithHint(filePath, displayNameHint, cacheDir string) (string, error) {
|
||||
cacheKey := filePath
|
||||
if stat, err := os.Stat(filePath); err == nil {
|
||||
cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano())
|
||||
@@ -1626,7 +1640,7 @@ func SaveCoverToCache(filePath, cacheDir string) (string, error) {
|
||||
return pngPath, nil
|
||||
}
|
||||
|
||||
imageData, mimeType, err := extractAnyCoverArt(filePath)
|
||||
imageData, mimeType, err := extractAnyCoverArtWithHint(filePath, displayNameHint)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func ffmpegCommand(args ...string) *exec.Cmd {
|
||||
if ffmpegPath, err := exec.LookPath("ffmpeg"); err == nil {
|
||||
return exec.Command(ffmpegPath, args...)
|
||||
}
|
||||
return exec.Command("ffmpeg", args...)
|
||||
}
|
||||
|
||||
func runFFmpegTestCommand(t *testing.T, args ...string) {
|
||||
t.Helper()
|
||||
cmd := ffmpegCommand(args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("ffmpeg failed: %v\n%s", err, string(output))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLyricsReadsMp3AfterCoverEmbed(t *testing.T) {
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
t.Skip("ffmpeg not available")
|
||||
}
|
||||
|
||||
tempDir := t.TempDir()
|
||||
sourceFlac := filepath.Join(tempDir, "source.flac")
|
||||
baseMp3 := filepath.Join(tempDir, "base.mp3")
|
||||
finalMp3 := filepath.Join(tempDir, "final.mp3")
|
||||
coverPath := filepath.Join(tempDir, "cover.jpg")
|
||||
lyrics := "[ti:Test Song]\n[ar:Test Artist]\n[00:00.00]Hello from embedded lyrics"
|
||||
|
||||
runFFmpegTestCommand(
|
||||
t,
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=440:duration=1",
|
||||
"-c:a",
|
||||
"flac",
|
||||
sourceFlac,
|
||||
)
|
||||
|
||||
runFFmpegTestCommand(
|
||||
t,
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"color=c=red:s=32x32:d=1",
|
||||
"-frames:v",
|
||||
"1",
|
||||
coverPath,
|
||||
)
|
||||
|
||||
runFFmpegTestCommand(
|
||||
t,
|
||||
"-y",
|
||||
"-i",
|
||||
sourceFlac,
|
||||
"-b:a",
|
||||
"320k",
|
||||
"-metadata",
|
||||
"title=Test Song",
|
||||
"-metadata",
|
||||
"artist=Test Artist",
|
||||
"-metadata",
|
||||
"lyrics="+lyrics,
|
||||
baseMp3,
|
||||
)
|
||||
|
||||
runFFmpegTestCommand(
|
||||
t,
|
||||
"-y",
|
||||
"-i",
|
||||
baseMp3,
|
||||
"-i",
|
||||
coverPath,
|
||||
"-map",
|
||||
"0:a",
|
||||
"-map_metadata",
|
||||
"-1",
|
||||
"-map",
|
||||
"1:0",
|
||||
"-c:v:0",
|
||||
"copy",
|
||||
"-id3v2_version",
|
||||
"3",
|
||||
"-metadata",
|
||||
"title=Test Song",
|
||||
"-metadata",
|
||||
"artist=Test Artist",
|
||||
"-metadata",
|
||||
"lyrics="+lyrics,
|
||||
"-metadata:s:v",
|
||||
"title=Album cover",
|
||||
"-metadata:s:v",
|
||||
"comment=Cover (front)",
|
||||
"-c:a",
|
||||
"copy",
|
||||
finalMp3,
|
||||
)
|
||||
|
||||
meta, err := ReadID3Tags(finalMp3)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadID3Tags failed: %v", err)
|
||||
}
|
||||
if meta == nil {
|
||||
t.Fatalf("ReadID3Tags returned nil metadata")
|
||||
}
|
||||
|
||||
embeddedLyrics, err := ExtractLyrics(finalMp3)
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractLyrics failed: %v (metadata=%+v)", err, meta)
|
||||
}
|
||||
if !strings.Contains(embeddedLyrics, "Hello from embedded lyrics") {
|
||||
t.Fatalf("embedded lyrics missing, got %q (metadata=%+v)", embeddedLyrics, meta)
|
||||
}
|
||||
if !strings.Contains(meta.Lyrics, "Hello from embedded lyrics") {
|
||||
t.Fatalf("ReadID3Tags lyrics missing, got %+v", meta)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(finalMp3); err != nil {
|
||||
t.Fatalf("expected final mp3 to exist: %v", err)
|
||||
}
|
||||
}
|
||||
+34
-5
@@ -17,6 +17,8 @@ const (
|
||||
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
|
||||
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
||||
|
||||
var tidalSizeRegex = regexp.MustCompile(`/\d+x\d+\.jpg$`)
|
||||
|
||||
func convertSmallToMedium(imageURL string) string {
|
||||
if strings.Contains(imageURL, spotifySize300) {
|
||||
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||
@@ -40,7 +42,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||
maxURL := upgradeToMaxQuality(downloadURL)
|
||||
if maxURL != downloadURL {
|
||||
downloadURL = maxURL
|
||||
// Log already printed by upgradeToMaxQuality for Deezer
|
||||
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
|
||||
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
|
||||
}
|
||||
@@ -86,16 +87,22 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||
}
|
||||
|
||||
func upgradeToMaxQuality(coverURL string) string {
|
||||
// Spotify CDN upgrade
|
||||
if strings.Contains(coverURL, spotifySize640) {
|
||||
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||
}
|
||||
|
||||
// Deezer CDN upgrade
|
||||
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||
return upgradeDeezerCover(coverURL)
|
||||
}
|
||||
|
||||
if strings.Contains(coverURL, "resources.tidal.com") {
|
||||
return upgradeTidalCover(coverURL)
|
||||
}
|
||||
|
||||
if strings.Contains(coverURL, "static.qobuz.com") {
|
||||
return upgradeQobuzCover(coverURL)
|
||||
}
|
||||
|
||||
return coverURL
|
||||
}
|
||||
|
||||
@@ -104,7 +111,6 @@ func upgradeDeezerCover(coverURL string) string {
|
||||
return coverURL
|
||||
}
|
||||
|
||||
// Replace any size pattern with 1800x1800
|
||||
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
|
||||
if upgraded != coverURL {
|
||||
GoLog("[Cover] Deezer: upgraded to 1800x1800")
|
||||
@@ -112,12 +118,35 @@ func upgradeDeezerCover(coverURL string) string {
|
||||
return upgraded
|
||||
}
|
||||
|
||||
func upgradeTidalCover(coverURL string) string {
|
||||
if !strings.Contains(coverURL, "resources.tidal.com") {
|
||||
return coverURL
|
||||
}
|
||||
|
||||
upgraded := tidalSizeRegex.ReplaceAllString(coverURL, "/origin.jpg")
|
||||
if upgraded != coverURL {
|
||||
GoLog("[Cover] Tidal: upgraded to origin resolution")
|
||||
}
|
||||
return upgraded
|
||||
}
|
||||
|
||||
func upgradeQobuzCover(coverURL string) string {
|
||||
if !strings.Contains(coverURL, "static.qobuz.com") {
|
||||
return coverURL
|
||||
}
|
||||
|
||||
upgraded := qobuzImageSizeRe.ReplaceAllString(coverURL, "_max.jpg")
|
||||
if upgraded != coverURL {
|
||||
GoLog("[Cover] Qobuz: upgraded to max resolution")
|
||||
}
|
||||
return upgraded
|
||||
}
|
||||
|
||||
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
||||
if imageURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Always upgrade small to medium first
|
||||
result := convertSmallToMedium(imageURL)
|
||||
|
||||
if maxQuality {
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CueSheet represents a parsed .cue file
|
||||
type CueSheet struct {
|
||||
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"`
|
||||
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
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "\xef\xbb\xbf") {
|
||||
line = strings.TrimPrefix(line, "\xef\xbb\xbf")
|
||||
line = strings.TrimSpace(line)
|
||||
}
|
||||
|
||||
upper := strings.ToUpper(line)
|
||||
|
||||
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 "):]
|
||||
fname, ftype := parseCueFileLine(rest)
|
||||
sheet.FileName = fname
|
||||
sheet.FileType = ftype
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(upper, "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
|
||||
}
|
||||
|
||||
if strings.HasPrefix(upper, "SONGWRITER ") {
|
||||
value := unquoteCue(line[len("SONGWRITER "):])
|
||||
if currentTrack != nil {
|
||||
currentTrack.Composer = value
|
||||
} else {
|
||||
sheet.Composer = value
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
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"`
|
||||
RecordType string `json:"record_type"`
|
||||
Label string `json:"label"`
|
||||
Copyright string `json:"copyright"`
|
||||
Genres struct {
|
||||
Data []deezerGenre `json:"data"`
|
||||
} `json:"genres"`
|
||||
@@ -1084,8 +1085,9 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
|
||||
}
|
||||
|
||||
type AlbumExtendedMetadata struct {
|
||||
Genre string
|
||||
Label string
|
||||
Genre string
|
||||
Label string
|
||||
Copyright string
|
||||
}
|
||||
|
||||
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{
|
||||
Genre: strings.Join(genres, ", "),
|
||||
Label: album.Label,
|
||||
Genre: strings.Join(genres, ", "),
|
||||
Label: album.Label,
|
||||
Copyright: album.Copyright,
|
||||
}
|
||||
|
||||
c.cacheMu.Lock()
|
||||
@@ -1129,7 +1132,7 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
|
||||
c.maybeCleanupCachesLocked(now)
|
||||
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
|
||||
}
|
||||
@@ -1178,7 +1181,7 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
||||
|
||||
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
delay := deezerRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
|
||||
delay := deezerRetryDelay * time.Duration(1<<(attempt-1))
|
||||
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
|
||||
time.Sleep(delay)
|
||||
}
|
||||
@@ -1191,7 +1194,6 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
||||
lastErr = err
|
||||
errStr := err.Error()
|
||||
|
||||
// Check if error is retryable
|
||||
isRetryable := strings.Contains(errStr, "timeout") ||
|
||||
strings.Contains(errStr, "connection reset") ||
|
||||
strings.Contains(errStr, "connection refused") ||
|
||||
|
||||
+265
-10
@@ -15,6 +15,7 @@ import (
|
||||
)
|
||||
|
||||
const deezerYoinkifyURL = "https://yoinkify.lol/api/download"
|
||||
const deezerMusicDLURL = "https://www.musicdl.me/api/download"
|
||||
|
||||
type YoinkifyRequest struct {
|
||||
URL string `json:"url"`
|
||||
@@ -119,7 +120,7 @@ func (c *DeezerClient) DownloadFromYoinkify(spotifyURL, outputPath string, outpu
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
resp, err := GetDownloadClient().Do(req)
|
||||
if err != nil {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
@@ -194,6 +195,235 @@ func (c *DeezerClient) DownloadFromYoinkify(spotifyURL, outputPath string, outpu
|
||||
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, false); err != nil {
|
||||
GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err)
|
||||
// Don't reject direct IDs from request payload — they're presumably correct.
|
||||
}
|
||||
return trackURL, nil
|
||||
}
|
||||
|
||||
// Try 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, true); verifyErr != nil {
|
||||
GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr)
|
||||
// Fall through to ISRC search instead of using wrong track.
|
||||
} else {
|
||||
return availability.DeezerURL, nil
|
||||
}
|
||||
} else {
|
||||
return availability.DeezerURL, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try 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, false); verifyErr != nil {
|
||||
GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr)
|
||||
return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr)
|
||||
}
|
||||
return fmt.Sprintf("https://www.deezer.com/track/%s", resolvedID), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not resolve Deezer track URL")
|
||||
}
|
||||
|
||||
func verifyDeezerTrack(req DownloadRequest, deezerID string, skipNameVerification bool) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
||||
defer cancel()
|
||||
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
|
||||
if err != nil {
|
||||
return nil // Can't verify — don't block the download.
|
||||
}
|
||||
resolved := resolvedTrackInfo{
|
||||
Title: trackResp.Track.Name,
|
||||
ArtistName: trackResp.Track.Artists,
|
||||
ISRC: trackResp.Track.ISRC,
|
||||
Duration: trackResp.Track.DurationMS / 1000,
|
||||
SkipNameVerification: skipNameVerification,
|
||||
}
|
||||
if !trackMatchesRequest(req, resolved, "Deezer") {
|
||||
return fmt.Errorf("expected '%s - %s', got '%s - %s'",
|
||||
req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
|
||||
}
|
||||
GoLog("[Deezer] Track %s verified: '%s - %s' ✓\n", deezerID, resolved.ArtistName, resolved.Title)
|
||||
return nil
|
||||
}
|
||||
|
||||
type deezerMusicDLRequest struct {
|
||||
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)
|
||||
}
|
||||
|
||||
for _, key := range []string{"download_url", "url", "link"} {
|
||||
if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||
return strings.TrimSpace(urlVal), nil
|
||||
}
|
||||
}
|
||||
if data, ok := raw["data"].(map[string]any); ok {
|
||||
for _, key := range []string{"download_url", "url", "link"} {
|
||||
if urlVal, ok := data[key].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||
return strings.TrimSpace(urlVal), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no download URL found in MusicDL response")
|
||||
}
|
||||
|
||||
func (c *DeezerClient) DownloadFromMusicDL(deezerTrackURL, outputPath string, outputFD int, itemID string) error {
|
||||
GoLog("[Deezer] Resolving download URL via MusicDL for: %s\n", deezerTrackURL)
|
||||
|
||||
downloadURL, err := c.GetMusicDLDownloadURL(deezerTrackURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
GoLog("[Deezer] MusicDL returned download URL, starting download...\n")
|
||||
|
||||
ctx := context.Background()
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
ctx = initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
}
|
||||
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create download request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := GetDownloadClient().Do(req)
|
||||
if err != nil {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
expectedSize := resp.ContentLength
|
||||
if expectedSize > 0 && itemID != "" {
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
|
||||
out, err := openOutputForWrite(outputPath, outputFD)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||
var written int64
|
||||
if itemID != "" {
|
||||
pw := NewItemProgressWriter(bufWriter, itemID)
|
||||
written, err = io.Copy(pw, resp.Body)
|
||||
} else {
|
||||
written, err = io.Copy(bufWriter, resp.Body)
|
||||
}
|
||||
|
||||
flushErr := bufWriter.Flush()
|
||||
closeErr := out.Close()
|
||||
|
||||
if err != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to flush output: %w", flushErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to close output: %w", closeErr)
|
||||
}
|
||||
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
}
|
||||
|
||||
GoLog("[Deezer] Downloaded via MusicDL: %.2f MB\n", float64(written)/(1024*1024))
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
|
||||
deezerClient := GetDeezerClient()
|
||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||
@@ -204,11 +434,6 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
spotifyURL, err := resolveSpotifyURLForYoinkify(req)
|
||||
if err != nil {
|
||||
return DeezerDownloadResult{}, err
|
||||
}
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||
"title": req.TrackName,
|
||||
"artist": req.ArtistName,
|
||||
@@ -254,11 +479,41 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
|
||||
)
|
||||
}()
|
||||
|
||||
if err := deezerClient.DownloadFromYoinkify(spotifyURL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return DeezerDownloadResult{}, ErrDownloadCancelled
|
||||
// Try MusicDL first (better quality), fallback to Yoinkify
|
||||
var downloadErr error
|
||||
deezerTrackURL, deezerURLErr := resolveDeezerTrackURL(req)
|
||||
if deezerURLErr == nil {
|
||||
GoLog("[Deezer] Trying MusicDL for: %s\n", deezerTrackURL)
|
||||
downloadErr = deezerClient.DownloadFromMusicDL(deezerTrackURL, outputPath, req.OutputFD, req.ItemID)
|
||||
if downloadErr != nil {
|
||||
if errors.Is(downloadErr, ErrDownloadCancelled) {
|
||||
return DeezerDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
GoLog("[Deezer] MusicDL failed: %v, falling back to Yoinkify\n", downloadErr)
|
||||
}
|
||||
} else {
|
||||
GoLog("[Deezer] Could not resolve Deezer URL: %v, using Yoinkify directly\n", deezerURLErr)
|
||||
}
|
||||
|
||||
if downloadErr != nil || deezerURLErr != nil {
|
||||
spotifyURL, err := resolveSpotifyURLForYoinkify(req)
|
||||
if err != nil {
|
||||
if deezerURLErr != nil {
|
||||
return DeezerDownloadResult{}, fmt.Errorf(
|
||||
"deezer download failed: direct Deezer resolution error: %v; Yoinkify fallback error: %w",
|
||||
deezerURLErr,
|
||||
err,
|
||||
)
|
||||
}
|
||||
return DeezerDownloadResult{}, err
|
||||
}
|
||||
downloadErr = deezerClient.DownloadFromYoinkify(spotifyURL, outputPath, req.OutputFD, req.ItemID)
|
||||
if downloadErr != nil {
|
||||
if errors.Is(downloadErr, ErrDownloadCancelled) {
|
||||
return DeezerDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
return DeezerDownloadResult{}, fmt.Errorf("deezer download failed (MusicDL + Yoinkify): %w", downloadErr)
|
||||
}
|
||||
return DeezerDownloadResult{}, fmt.Errorf("deezer yoinkify failed: %w", err)
|
||||
}
|
||||
|
||||
<-parallelDone
|
||||
|
||||
@@ -34,7 +34,6 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
|
||||
return idx
|
||||
}
|
||||
|
||||
// Slow path: need to build index
|
||||
// Use per-directory mutex to prevent multiple goroutines from building simultaneously
|
||||
buildLock, _ := isrcBuildingMu.LoadOrStore(outputDir, &sync.Mutex{})
|
||||
mu := buildLock.(*sync.Mutex)
|
||||
|
||||
+741
-575
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildDeezerExtendedMetadataResultHandlesNil(t *testing.T) {
|
||||
result := buildDeezerExtendedMetadataResult(nil)
|
||||
|
||||
if result["genre"] != "" {
|
||||
t.Fatalf("expected empty genre, got %q", result["genre"])
|
||||
}
|
||||
if result["label"] != "" {
|
||||
t.Fatalf("expected empty label, got %q", result["label"])
|
||||
}
|
||||
if result["copyright"] != "" {
|
||||
t.Fatalf("expected empty copyright, got %q", result["copyright"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDeezerExtendedMetadataResultIncludesCopyright(t *testing.T) {
|
||||
result := buildDeezerExtendedMetadataResult(&AlbumExtendedMetadata{
|
||||
Genre: "Rock",
|
||||
Label: "EMI",
|
||||
Copyright: "(C) Queen",
|
||||
})
|
||||
|
||||
if result["genre"] != "Rock" {
|
||||
t.Fatalf("unexpected genre: %q", result["genre"])
|
||||
}
|
||||
if result["label"] != "EMI" {
|
||||
t.Fatalf("unexpected label: %q", result["label"])
|
||||
}
|
||||
if result["copyright"] != "(C) Queen" {
|
||||
t.Fatalf("unexpected copyright: %q", result["copyright"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDeezerISRCSearchResultAddsCompatibilityIDs(t *testing.T) {
|
||||
result := buildDeezerISRCSearchResult(&TrackMetadata{
|
||||
SpotifyID: "deezer:3135556",
|
||||
Name: "Love Of My Life",
|
||||
Artists: "Queen",
|
||||
AlbumName: "A Night at the Opera",
|
||||
ISRC: "GBUM71029604",
|
||||
ReleaseDate: "1975-11-21",
|
||||
})
|
||||
|
||||
if result["spotify_id"] != "deezer:3135556" {
|
||||
t.Fatalf("unexpected spotify_id: %v", result["spotify_id"])
|
||||
}
|
||||
if result["id"] != "3135556" {
|
||||
t.Fatalf("unexpected id: %v", result["id"])
|
||||
}
|
||||
if result["track_id"] != "3135556" {
|
||||
t.Fatalf("unexpected track_id: %v", result["track_id"])
|
||||
}
|
||||
if result["success"] != true {
|
||||
t.Fatalf("expected success=true, got %v", result["success"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
|
||||
req := DownloadRequest{
|
||||
TrackName: "Bonus Track",
|
||||
ArtistName: "Artist",
|
||||
AlbumName: "Album (Deluxe)",
|
||||
AlbumArtist: "Artist",
|
||||
ReleaseDate: "2024-01-01",
|
||||
TrackNumber: 14,
|
||||
DiscNumber: 1,
|
||||
ISRC: "REQ123",
|
||||
CoverURL: "https://example.com/cover.jpg",
|
||||
Genre: "Pop",
|
||||
Label: "Label",
|
||||
Copyright: "Copyright",
|
||||
}
|
||||
|
||||
result := DownloadResult{
|
||||
Title: "Bonus Track",
|
||||
Artist: "Artist",
|
||||
Album: "Album",
|
||||
ReleaseDate: "2023-12-01",
|
||||
TrackNumber: 2,
|
||||
DiscNumber: 9,
|
||||
ISRC: "RES456",
|
||||
}
|
||||
|
||||
resp := buildDownloadSuccessResponse(
|
||||
req,
|
||||
result,
|
||||
"tidal",
|
||||
"ok",
|
||||
"/tmp/test.flac",
|
||||
false,
|
||||
)
|
||||
|
||||
if resp.Album != req.AlbumName {
|
||||
t.Fatalf("album = %q, want %q", resp.Album, req.AlbumName)
|
||||
}
|
||||
if resp.ReleaseDate != req.ReleaseDate {
|
||||
t.Fatalf("release date = %q, want %q", resp.ReleaseDate, req.ReleaseDate)
|
||||
}
|
||||
if resp.TrackNumber != req.TrackNumber {
|
||||
t.Fatalf("track number = %d, want %d", resp.TrackNumber, req.TrackNumber)
|
||||
}
|
||||
if resp.DiscNumber != req.DiscNumber {
|
||||
t.Fatalf("disc number = %d, want %d", resp.DiscNumber, req.DiscNumber)
|
||||
}
|
||||
if resp.Artist != result.Artist {
|
||||
t.Fatalf("artist = %q, want provider artist %q", resp.Artist, result.Artist)
|
||||
}
|
||||
if resp.ISRC != result.ISRC {
|
||||
t.Fatalf("isrc = %q, want provider isrc %q", resp.ISRC, result.ISRC)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferredReleaseMetadataPrefersRequestValues(t *testing.T) {
|
||||
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
|
||||
DownloadRequest{
|
||||
AlbumName: "Album (Deluxe Edition)",
|
||||
ReleaseDate: "2024-01-01",
|
||||
TrackNumber: 13,
|
||||
DiscNumber: 2,
|
||||
},
|
||||
"Album",
|
||||
"2023-01-01",
|
||||
3,
|
||||
1,
|
||||
)
|
||||
|
||||
if album != "Album (Deluxe Edition)" {
|
||||
t.Fatalf("album = %q", album)
|
||||
}
|
||||
if releaseDate != "2024-01-01" {
|
||||
t.Fatalf("release date = %q", releaseDate)
|
||||
}
|
||||
if trackNumber != 13 {
|
||||
t.Fatalf("track number = %d", trackNumber)
|
||||
}
|
||||
if discNumber != 2 {
|
||||
t.Fatalf("disc number = %d", discNumber)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
|
||||
req := DownloadRequest{
|
||||
TrackName: "Track",
|
||||
ArtistName: "Artist",
|
||||
AlbumName: "Album",
|
||||
AlbumArtist: "Artist",
|
||||
}
|
||||
|
||||
result := DownloadResult{
|
||||
Title: "Track",
|
||||
Artist: "Artist",
|
||||
Album: "Album",
|
||||
CoverURL: "https://cdn.qobuz.test/cover.jpg",
|
||||
}
|
||||
|
||||
resp := buildDownloadSuccessResponse(
|
||||
req,
|
||||
result,
|
||||
"qobuz",
|
||||
"ok",
|
||||
"/tmp/test.flac",
|
||||
false,
|
||||
)
|
||||
|
||||
if resp.CoverURL != result.CoverURL {
|
||||
t.Fatalf("cover url = %q, want %q", resp.CoverURL, result.CoverURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMissing(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
SpotifyID: "spotify-track-id",
|
||||
AlbumName: "Original Album",
|
||||
ReleaseDate: "2024-01-01",
|
||||
ISRC: "REQ123",
|
||||
}
|
||||
|
||||
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
|
||||
AlbumName: "Resolved Album",
|
||||
ReleaseDate: "",
|
||||
ISRC: "",
|
||||
})
|
||||
|
||||
if req.ReleaseDate != "2024-01-01" {
|
||||
t.Fatalf("release date = %q, want existing value preserved", req.ReleaseDate)
|
||||
}
|
||||
if req.AlbumName != "Resolved Album" {
|
||||
t.Fatalf("album = %q, want updated album", req.AlbumName)
|
||||
}
|
||||
if req.ISRC != "REQ123" {
|
||||
t.Fatalf("isrc = %q, want existing value preserved", req.ISRC)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
TrackName: "Song Title",
|
||||
ArtistName: "Artist Name",
|
||||
AlbumName: "Album Name",
|
||||
ReleaseDate: "",
|
||||
DurationMs: 180000,
|
||||
}
|
||||
|
||||
tracks := []ExtTrackMetadata{
|
||||
{
|
||||
ID: "first",
|
||||
Name: "Song Title",
|
||||
Artists: "Artist Name",
|
||||
AlbumName: "Album Name",
|
||||
DurationMS: 180000,
|
||||
ReleaseDate: "",
|
||||
ProviderID: "spotify",
|
||||
},
|
||||
{
|
||||
ID: "second",
|
||||
Name: "Song Title",
|
||||
Artists: "Artist Name",
|
||||
AlbumName: "Album Name",
|
||||
DurationMS: 180000,
|
||||
ReleaseDate: "2024-03-09",
|
||||
ProviderID: "deezer",
|
||||
},
|
||||
}
|
||||
|
||||
best := selectBestReEnrichTrack(req, tracks)
|
||||
if best == nil {
|
||||
t.Fatal("expected a selected track")
|
||||
}
|
||||
if best.ID != "second" {
|
||||
t.Fatalf("selected track = %q, want candidate with release date", best.ID)
|
||||
}
|
||||
}
|
||||
+241
-130
@@ -44,16 +44,76 @@ func compareVersions(v1, v2 string) int {
|
||||
}
|
||||
|
||||
type LoadedExtension struct {
|
||||
ID string `json:"id"`
|
||||
Manifest *ExtensionManifest `json:"manifest"`
|
||||
VM *goja.Runtime `json:"-"`
|
||||
VMMu sync.Mutex `json:"-"`
|
||||
runtime *ExtensionRuntime
|
||||
Enabled bool `json:"enabled"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DataDir string `json:"data_dir"`
|
||||
SourceDir string `json:"source_dir"`
|
||||
IconPath string `json:"icon_path"`
|
||||
ID string `json:"id"`
|
||||
Manifest *ExtensionManifest `json:"manifest"`
|
||||
VM *goja.Runtime `json:"-"`
|
||||
VMMu sync.Mutex `json:"-"`
|
||||
runtime *ExtensionRuntime
|
||||
initialized bool
|
||||
Enabled bool `json:"enabled"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DataDir string `json:"data_dir"`
|
||||
SourceDir string `json:"source_dir"`
|
||||
IconPath string `json:"icon_path"`
|
||||
}
|
||||
|
||||
func getExtensionInitSettings(extensionID string) map[string]interface{} {
|
||||
settings := GetExtensionSettingsStore().GetAll(extensionID)
|
||||
if len(settings) == 0 {
|
||||
return settings
|
||||
}
|
||||
|
||||
filtered := make(map[string]interface{}, len(settings))
|
||||
for key, value := range settings {
|
||||
if strings.HasPrefix(key, "_") {
|
||||
continue
|
||||
}
|
||||
filtered[key] = value
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func ensureRuntimeReadyLocked(ext *LoadedExtension, applyStoredSettings bool) error {
|
||||
if ext.VM == nil || ext.runtime == nil {
|
||||
if err := initializeVMLocked(ext); err != nil {
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if applyStoredSettings && !ext.initialized {
|
||||
settings := getExtensionInitSettings(ext.ID)
|
||||
if len(settings) > 0 {
|
||||
if err := initializeExtensionWithSettingsLocked(ext, settings); err != nil {
|
||||
teardownVMLocked(ext)
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
ext.initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
ext.Error = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ext *LoadedExtension) ensureRuntimeReady() error {
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
|
||||
return ensureRuntimeReadyLocked(ext, true)
|
||||
}
|
||||
|
||||
func (ext *LoadedExtension) lockReadyVM() (*goja.Runtime, error) {
|
||||
ext.VMMu.Lock()
|
||||
if err := ensureRuntimeReadyLocked(ext, true); err != nil {
|
||||
ext.VMMu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
return ext.VM, nil
|
||||
}
|
||||
|
||||
type ExtensionManager struct {
|
||||
@@ -151,7 +211,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
if exists {
|
||||
versionCompare := compareVersions(manifest.Version, existingVersion)
|
||||
if versionCompare > 0 {
|
||||
// This is an upgrade - call UpgradeExtension
|
||||
return m.UpgradeExtension(filePath)
|
||||
} else if versionCompare == 0 {
|
||||
return nil, fmt.Errorf("Extension '%s' v%s is already installed", existingDisplayName, existingVersion)
|
||||
@@ -221,10 +280,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
SourceDir: extDir,
|
||||
}
|
||||
|
||||
if err := m.initializeVM(ext); err != nil {
|
||||
if err := validateExtensionLoad(ext); err != nil {
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err)
|
||||
GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err)
|
||||
}
|
||||
|
||||
m.extensions[manifest.Name] = ext
|
||||
@@ -233,7 +292,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||
func initializeVMLocked(ext *LoadedExtension) error {
|
||||
ext.VM = nil
|
||||
ext.runtime = nil
|
||||
ext.initialized = false
|
||||
vm := goja.New()
|
||||
ext.VM = vm
|
||||
|
||||
@@ -280,6 +342,136 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
return initializeVMLocked(ext)
|
||||
}
|
||||
|
||||
func initializeExtensionWithSettingsLocked(
|
||||
ext *LoadedExtension,
|
||||
settings map[string]interface{},
|
||||
) error {
|
||||
if ext.VM == nil {
|
||||
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
||||
}
|
||||
|
||||
settingsJSON, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to save settings")
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
var settings = %s;
|
||||
if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
|
||||
try {
|
||||
extension.initialize(settings);
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
}
|
||||
}
|
||||
return { success: true, message: 'no initialize function' };
|
||||
})()
|
||||
`, string(settingsJSON))
|
||||
|
||||
result, err := ext.VM.RunString(script)
|
||||
if err != nil {
|
||||
ext.Error = fmt.Sprintf("initialize failed: %v", err)
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Initialize error for %s: %v\n", ext.ID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) {
|
||||
exported := result.Export()
|
||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||
errMsg := "unknown error"
|
||||
if e, ok := resultMap["error"].(string); ok {
|
||||
errMsg = e
|
||||
}
|
||||
ext.Error = errMsg
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Initialize failed for %s: %s\n", ext.ID, errMsg)
|
||||
return fmt.Errorf("initialize failed: %s", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ext.initialized = true
|
||||
GoLog("[Extension] Initialized %s\n", ext.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCleanupLocked(ext *LoadedExtension) error {
|
||||
if ext.VM != nil {
|
||||
script := `
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
||||
try {
|
||||
extension.cleanup();
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
}
|
||||
}
|
||||
return { success: true, message: 'no cleanup function' };
|
||||
})()
|
||||
`
|
||||
|
||||
result, err := ext.VM.RunString(script)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) {
|
||||
exported := result.Export()
|
||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||
errMsg := "unknown error"
|
||||
if e, ok := resultMap["error"].(string); ok {
|
||||
errMsg = e
|
||||
}
|
||||
return fmt.Errorf("cleanup failed: %s", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) && !goja.IsNull(result) {
|
||||
GoLog("[Extension] Cleanup called for %s\n", ext.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func teardownVMLocked(ext *LoadedExtension) {
|
||||
if err := runCleanupLocked(ext); err != nil {
|
||||
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
|
||||
}
|
||||
if ext.runtime != nil {
|
||||
if err := ext.runtime.flushStorageNow(); err != nil {
|
||||
GoLog("[Extension] Failed to flush storage for %s: %v\n", ext.ID, err)
|
||||
}
|
||||
ext.runtime.closeStorageFlusher()
|
||||
}
|
||||
ext.runtime = nil
|
||||
ext.VM = nil
|
||||
ext.initialized = false
|
||||
}
|
||||
|
||||
func validateExtensionLoad(ext *LoadedExtension) error {
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
|
||||
if err := initializeVMLocked(ext); err != nil {
|
||||
return err
|
||||
}
|
||||
teardownVMLocked(ext)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -289,21 +481,9 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||
return fmt.Errorf("Extension not found")
|
||||
}
|
||||
|
||||
if ext.VM != nil {
|
||||
cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null")
|
||||
if err != nil {
|
||||
GoLog("[Extension] Error calling cleanup for %s: %v\n", extensionID, err)
|
||||
} else if cleanup != nil && !goja.IsUndefined(cleanup) && !goja.IsNull(cleanup) {
|
||||
GoLog("[Extension] Cleanup called for %s\n", extensionID)
|
||||
}
|
||||
}
|
||||
if ext.runtime != nil {
|
||||
if err := ext.runtime.flushStorageNow(); err != nil {
|
||||
GoLog("[Extension] Failed to flush storage for %s: %v\n", extensionID, err)
|
||||
}
|
||||
ext.runtime.closeStorageFlusher()
|
||||
ext.runtime = nil
|
||||
}
|
||||
ext.VMMu.Lock()
|
||||
teardownVMLocked(ext)
|
||||
ext.VMMu.Unlock()
|
||||
|
||||
delete(m.extensions, extensionID)
|
||||
GoLog("[Extension] Unloaded extension: %s\n", extensionID)
|
||||
@@ -342,7 +522,21 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
|
||||
return fmt.Errorf("Extension not found")
|
||||
}
|
||||
|
||||
ext.Enabled = enabled
|
||||
if enabled {
|
||||
ext.Enabled = true
|
||||
if err := ext.ensureRuntimeReady(); err != nil {
|
||||
store := GetExtensionSettingsStore()
|
||||
ext.Enabled = false
|
||||
_ = store.Set(extensionID, "_enabled", false)
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
ext.Enabled = false
|
||||
ext.Error = ""
|
||||
ext.VMMu.Lock()
|
||||
teardownVMLocked(ext)
|
||||
ext.VMMu.Unlock()
|
||||
}
|
||||
GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled])
|
||||
|
||||
store := GetExtensionSettingsStore()
|
||||
@@ -401,7 +595,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
||||
return nil, fmt.Errorf("failed to read manifest.json: %w", err)
|
||||
}
|
||||
|
||||
// Parse and validate manifest
|
||||
manifest, err := ParseManifest(manifestData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
||||
@@ -430,7 +623,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
||||
SourceDir: dirPath,
|
||||
}
|
||||
|
||||
// Restore enabled state from settings store
|
||||
store := GetExtensionSettingsStore()
|
||||
if enabledVal, err := store.Get(manifest.Name, "_enabled"); err == nil {
|
||||
if enabled, ok := enabledVal.(bool); ok {
|
||||
@@ -439,10 +631,10 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.initializeVM(ext); err != nil {
|
||||
if err := validateExtensionLoad(ext); err != nil {
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err)
|
||||
GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err)
|
||||
}
|
||||
|
||||
m.extensions[manifest.Name] = ext
|
||||
@@ -467,17 +659,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
|
||||
}
|
||||
|
||||
// Only allows upgrades (new version > current version), not downgrades
|
||||
func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) {
|
||||
// Validate file extension
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||
}
|
||||
@@ -529,7 +715,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
||||
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)
|
||||
if versionCompare < 0 {
|
||||
return nil, fmt.Errorf("Cannot downgrade extension. Current version: %s, New version: %s", existing.Manifest.Version, newManifest.Version)
|
||||
@@ -540,7 +725,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)
|
||||
|
||||
// Save data directory path and enabled state (we want to preserve them)
|
||||
extDataDir := existing.DataDir
|
||||
extDir := existing.SourceDir
|
||||
wasEnabled := existing.Enabled
|
||||
@@ -601,11 +785,14 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
||||
SourceDir: extDir,
|
||||
}
|
||||
|
||||
// Initialize Goja VM
|
||||
if err := m.initializeVM(ext); err != nil {
|
||||
if wasEnabled {
|
||||
if err := ext.ensureRuntimeReady(); err != nil {
|
||||
GoLog("[Extension] Failed to initialize upgraded extension %s: %v\n", newManifest.Name, err)
|
||||
}
|
||||
} else if err := validateExtensionLoad(ext); err != nil {
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Failed to initialize VM for %s: %v\n", newManifest.Name, err)
|
||||
GoLog("[Extension] Failed to validate upgraded extension %s: %v\n", newManifest.Name, err)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
@@ -626,7 +813,6 @@ type ExtensionUpgradeInfo struct {
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
||||
// Validate file extension
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||
}
|
||||
@@ -675,7 +861,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
||||
}
|
||||
|
||||
if !exists {
|
||||
// Not installed - this is a new install, not upgrade
|
||||
info.CurrentVersion = ""
|
||||
info.CanUpgrade = false
|
||||
} else {
|
||||
@@ -739,7 +924,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
permissions = append(permissions, "storage:enabled")
|
||||
}
|
||||
|
||||
// Determine status
|
||||
status := "loaded"
|
||||
if ext.Error != "" {
|
||||
status = "error"
|
||||
@@ -805,56 +989,13 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
|
||||
return fmt.Errorf("Extension not found")
|
||||
}
|
||||
|
||||
if ext.VM == nil {
|
||||
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
||||
}
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
|
||||
settingsJSON, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to save settings")
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
var settings = %s;
|
||||
if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
|
||||
try {
|
||||
extension.initialize(settings);
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
}
|
||||
}
|
||||
return { success: true, message: 'no initialize function' };
|
||||
})()
|
||||
`, string(settingsJSON))
|
||||
|
||||
result, err := ext.VM.RunString(script)
|
||||
if err != nil {
|
||||
ext.Error = fmt.Sprintf("initialize failed: %v", err)
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err)
|
||||
if err := ensureRuntimeReadyLocked(ext, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) {
|
||||
exported := result.Export()
|
||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||
errMsg := "unknown error"
|
||||
if e, ok := resultMap["error"].(string); ok {
|
||||
errMsg = e
|
||||
}
|
||||
ext.Error = errMsg
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, errMsg)
|
||||
return fmt.Errorf("initialize failed: %s", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Extension] Initialized %s\n", extensionID)
|
||||
return nil
|
||||
return initializeExtensionWithSettingsLocked(ext, settings)
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
||||
@@ -869,41 +1010,12 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
||||
if ext.VM == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
script := `
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
||||
try {
|
||||
extension.cleanup();
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
}
|
||||
}
|
||||
return { success: true, message: 'no cleanup function' };
|
||||
})()
|
||||
`
|
||||
|
||||
result, err := ext.VM.RunString(script)
|
||||
if err != nil {
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
if err := runCleanupLocked(ext); err != nil {
|
||||
GoLog("[Extension] Cleanup error for %s: %v\n", extensionID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) {
|
||||
exported := result.Export()
|
||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||
errMsg := "unknown error"
|
||||
if e, ok := resultMap["error"].(string); ok {
|
||||
errMsg = e
|
||||
}
|
||||
GoLog("[Extension] Cleanup failed for %s: %s\n", extensionID, errMsg)
|
||||
return fmt.Errorf("cleanup failed: %s", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Extension] Cleaned up %s\n", extensionID)
|
||||
return nil
|
||||
}
|
||||
@@ -932,15 +1044,14 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
|
||||
return nil, fmt.Errorf("extension not found: %s", extensionID)
|
||||
}
|
||||
|
||||
if ext.VM == nil {
|
||||
return nil, fmt.Errorf("extension VM not initialized")
|
||||
if err := ext.ensureRuntimeReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !ext.Enabled {
|
||||
return nil, fmt.Errorf("extension is disabled")
|
||||
}
|
||||
|
||||
// Call the action function on the extension object
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides extension manifest parsing and validation
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -69,6 +70,7 @@ type ExtArtistMetadata struct {
|
||||
HeaderImage string `json:"header_image,omitempty"`
|
||||
Listeners int `json:"listeners,omitempty"`
|
||||
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
|
||||
Releases []ExtAlbumMetadata `json:"releases,omitempty"`
|
||||
TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
}
|
||||
@@ -99,15 +101,16 @@ type ExtDownloadResult struct {
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
ErrorType string `json:"error_type,omitempty"`
|
||||
|
||||
Title string `json:"title,omitempty"`
|
||||
Artist string `json:"artist,omitempty"`
|
||||
Album string `json:"album,omitempty"`
|
||||
AlbumArtist string `json:"album_artist,omitempty"`
|
||||
TrackNumber int `json:"track_number,omitempty"`
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Artist string `json:"artist,omitempty"`
|
||||
Album string `json:"album,omitempty"`
|
||||
AlbumArtist string `json:"album_artist,omitempty"`
|
||||
TrackNumber int `json:"track_number,omitempty"`
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
DecryptionKey string `json:"decryption_key,omitempty"`
|
||||
}
|
||||
|
||||
type ExtensionProviderWrapper struct {
|
||||
@@ -122,6 +125,15 @@ func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ExtensionProviderWrapper) lockReadyVM() error {
|
||||
vm, err := p.extension.lockReadyVM()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.vm = vm
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||
@@ -130,8 +142,9 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -189,8 +202,9 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -237,8 +251,9 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -288,8 +303,9 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -325,6 +341,12 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -336,8 +358,10 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
|
||||
if !p.extension.Enabled {
|
||||
return track, nil
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
GoLog("[Extension] EnrichTrack init error for %s: %v\n", p.extension.ID, err)
|
||||
return track, nil
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
trackJSON, err := json.Marshal(track)
|
||||
@@ -388,7 +412,7 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
|
||||
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() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
||||
}
|
||||
@@ -396,18 +420,19 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(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;
|
||||
})()
|
||||
`, isrc, trackName, artistName)
|
||||
`, isrc, trackName, artistName, spotifyID, deezerID)
|
||||
|
||||
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
|
||||
if err != nil {
|
||||
@@ -443,8 +468,9 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -482,9 +508,9 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
|
||||
return &urlResult, nil
|
||||
}
|
||||
|
||||
const ExtDownloadTimeout = 5 * time.Minute
|
||||
const ExtDownloadTimeout = DownloadTimeout
|
||||
|
||||
func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, onProgress func(percent int)) (*ExtDownloadResult, error) {
|
||||
func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath, itemID string, onProgress func(percent int)) (*ExtDownloadResult, error) {
|
||||
if !p.extension.Manifest.IsDownloadProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
||||
}
|
||||
@@ -492,9 +518,18 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return &ExtDownloadResult{
|
||||
Success: false,
|
||||
ErrorMessage: err.Error(),
|
||||
ErrorType: "init_error",
|
||||
}, nil
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
if p.extension.runtime != nil {
|
||||
p.extension.runtime.setActiveDownloadItemID(itemID)
|
||||
defer p.extension.runtime.clearActiveDownloadItemID()
|
||||
}
|
||||
|
||||
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) > 0 {
|
||||
@@ -598,8 +633,30 @@ func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) (
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var allTracks []ExtTrackMetadata
|
||||
providerByID := make(map[string]*ExtensionProviderWrapper, len(providers))
|
||||
orderedProviders := make([]*ExtensionProviderWrapper, 0, len(providers))
|
||||
for _, provider := range providers {
|
||||
providerByID[provider.extension.ID] = provider
|
||||
}
|
||||
for _, providerID := range GetMetadataProviderPriority() {
|
||||
if provider := providerByID[providerID]; provider != nil {
|
||||
orderedProviders = append(orderedProviders, provider)
|
||||
delete(providerByID, providerID)
|
||||
}
|
||||
}
|
||||
if len(providerByID) > 0 {
|
||||
remainingIDs := make([]string, 0, len(providerByID))
|
||||
for providerID := range providerByID {
|
||||
remainingIDs = append(remainingIDs, providerID)
|
||||
}
|
||||
sort.Strings(remainingIDs)
|
||||
for _, providerID := range remainingIDs {
|
||||
orderedProviders = append(orderedProviders, providerByID[providerID])
|
||||
}
|
||||
}
|
||||
|
||||
var allTracks []ExtTrackMetadata
|
||||
for _, provider := range orderedProviders {
|
||||
result, err := provider.SearchTracks(query, limit)
|
||||
if err != nil {
|
||||
GoLog("[Extension] Search error from %s: %v\n", provider.extension.ID, err)
|
||||
@@ -619,6 +676,8 @@ var providerPriorityMu sync.RWMutex
|
||||
var metadataProviderPriority []string
|
||||
var metadataProviderPriorityMu sync.RWMutex
|
||||
|
||||
var searchBuiltInMetadataTracksFunc = searchBuiltInMetadataTracks
|
||||
|
||||
func SetProviderPriority(providerIDs []string) {
|
||||
providerPriorityMu.Lock()
|
||||
defer providerPriorityMu.Unlock()
|
||||
@@ -631,7 +690,7 @@ func GetProviderPriority() []string {
|
||||
defer providerPriorityMu.RUnlock()
|
||||
|
||||
if len(providerPriority) == 0 {
|
||||
return []string{"tidal", "qobuz", "amazon", "deezer"}
|
||||
return []string{"tidal", "qobuz", "deezer"}
|
||||
}
|
||||
|
||||
result := make([]string, len(providerPriority))
|
||||
@@ -642,8 +701,30 @@ func GetProviderPriority() []string {
|
||||
func SetMetadataProviderPriority(providerIDs []string) {
|
||||
metadataProviderPriorityMu.Lock()
|
||||
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 {
|
||||
@@ -651,7 +732,7 @@ func GetMetadataProviderPriority() []string {
|
||||
defer metadataProviderPriorityMu.RUnlock()
|
||||
|
||||
if len(metadataProviderPriority) == 0 {
|
||||
return []string{"deezer", "spotify"}
|
||||
return []string{"deezer", "qobuz", "tidal"}
|
||||
}
|
||||
|
||||
result := make([]string, len(metadataProviderPriority))
|
||||
@@ -661,13 +742,172 @@ func GetMetadataProviderPriority() []string {
|
||||
|
||||
func isBuiltInProvider(providerID string) bool {
|
||||
switch providerID {
|
||||
case "tidal", "qobuz", "amazon", "deezer":
|
||||
case "tidal", "qobuz", "deezer":
|
||||
return true
|
||||
default:
|
||||
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) {
|
||||
priority := GetProviderPriority()
|
||||
extManager := GetExtensionManager()
|
||||
@@ -694,6 +934,27 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
priority = newPriority
|
||||
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
|
||||
@@ -742,6 +1003,24 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
if 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 == "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Label from enrichment: %s\n", enrichedTrack.Label)
|
||||
req.Label = enrichedTrack.Label
|
||||
@@ -762,6 +1041,77 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
}
|
||||
|
||||
// If key metadata is still missing after extension enrichment, search
|
||||
// configured metadata providers (Spotify/Deezer/Tidal/Qobuz) — same
|
||||
// logic that ReEnrichFile uses.
|
||||
if req.Source != "" && !isBuiltInProvider(strings.ToLower(req.Source)) &&
|
||||
req.TrackName != "" && req.ArtistName != "" &&
|
||||
(req.AlbumName == "" || req.ReleaseDate == "" || req.ISRC == "") {
|
||||
|
||||
searchQuery := req.TrackName + " " + req.ArtistName
|
||||
GoLog("[DownloadWithExtensionFallback] Metadata incomplete, searching providers for: %s\n", searchQuery)
|
||||
|
||||
tracks, searchErr := extManager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
|
||||
if searchErr == nil && len(tracks) > 0 {
|
||||
track := tracks[0]
|
||||
GoLog("[DownloadWithExtensionFallback] Metadata match (%s): %s - %s (album: %s, date: %s, isrc: %s)\n",
|
||||
track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate, track.ISRC)
|
||||
|
||||
if track.AlbumName != "" && req.AlbumName == "" {
|
||||
req.AlbumName = track.AlbumName
|
||||
}
|
||||
if track.AlbumArtist != "" && req.AlbumArtist == "" {
|
||||
req.AlbumArtist = track.AlbumArtist
|
||||
}
|
||||
if track.ReleaseDate != "" && req.ReleaseDate == "" {
|
||||
req.ReleaseDate = track.ReleaseDate
|
||||
}
|
||||
if track.ISRC != "" && req.ISRC == "" {
|
||||
req.ISRC = track.ISRC
|
||||
}
|
||||
if track.TrackNumber > 0 && req.TrackNumber == 0 {
|
||||
req.TrackNumber = track.TrackNumber
|
||||
}
|
||||
if track.DiscNumber > 0 && req.DiscNumber == 0 {
|
||||
req.DiscNumber = track.DiscNumber
|
||||
}
|
||||
if track.CoverURL != "" && req.CoverURL == "" {
|
||||
req.CoverURL = track.CoverURL
|
||||
}
|
||||
if track.Genre != "" && req.Genre == "" {
|
||||
req.Genre = track.Genre
|
||||
}
|
||||
if track.Label != "" && req.Label == "" {
|
||||
req.Label = track.Label
|
||||
}
|
||||
if track.Copyright != "" && req.Copyright == "" {
|
||||
req.Copyright = track.Copyright
|
||||
}
|
||||
} else if searchErr != nil {
|
||||
GoLog("[DownloadWithExtensionFallback] Metadata provider search failed (non-fatal): %v\n", searchErr)
|
||||
}
|
||||
|
||||
// Try Deezer extended metadata if we have ISRC
|
||||
if req.ISRC != "" &&
|
||||
(req.Genre == "" || req.Label == "" || req.Copyright == "") {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
extMeta, err := GetDeezerClient().GetExtendedMetadataByISRC(ctx, req.ISRC)
|
||||
cancel()
|
||||
if err == nil && extMeta != nil {
|
||||
if req.Genre == "" && extMeta.Genre != "" {
|
||||
req.Genre = extMeta.Genre
|
||||
}
|
||||
if req.Label == "" && extMeta.Label != "" {
|
||||
req.Label = extMeta.Label
|
||||
}
|
||||
if req.Copyright == "" && extMeta.Copyright != "" {
|
||||
req.Copyright = extMeta.Copyright
|
||||
}
|
||||
GoLog("[DownloadWithExtensionFallback] Extended metadata from Deezer: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if req.Source != "" &&
|
||||
!isBuiltInProvider(strings.ToLower(req.Source)) &&
|
||||
(!strictMode || selectedProvider == "" || strings.EqualFold(selectedProvider, req.Source)) {
|
||||
@@ -777,12 +1127,12 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
|
||||
GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn)
|
||||
|
||||
outputPath := buildOutputPath(req)
|
||||
outputPath := buildOutputPathForExtension(req, ext)
|
||||
if req.ItemID != "" {
|
||||
StartItemProgress(req.ItemID)
|
||||
}
|
||||
|
||||
result, err := provider.Download(trackID, req.Quality, outputPath, func(percent int) {
|
||||
result, err := provider.Download(trackID, req.Quality, outputPath, req.ItemID, func(percent int) {
|
||||
if req.ItemID != "" {
|
||||
normalized := float64(percent) / 100.0
|
||||
if normalized < 0 {
|
||||
@@ -813,6 +1163,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
DecryptionKey: result.DecryptionKey,
|
||||
}
|
||||
|
||||
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
|
||||
@@ -854,6 +1205,30 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
}
|
||||
|
||||
// Always pass enriched metadata from req so Flutter can
|
||||
// embed it — fills gaps from metadata provider search.
|
||||
if req.AlbumName != "" && resp.Album == "" {
|
||||
resp.Album = req.AlbumName
|
||||
}
|
||||
if req.AlbumArtist != "" && resp.AlbumArtist == "" {
|
||||
resp.AlbumArtist = req.AlbumArtist
|
||||
}
|
||||
if req.ReleaseDate != "" && resp.ReleaseDate == "" {
|
||||
resp.ReleaseDate = req.ReleaseDate
|
||||
}
|
||||
if req.ISRC != "" && resp.ISRC == "" {
|
||||
resp.ISRC = req.ISRC
|
||||
}
|
||||
if req.TrackNumber > 0 && resp.TrackNumber == 0 {
|
||||
resp.TrackNumber = req.TrackNumber
|
||||
}
|
||||
if req.DiscNumber > 0 && resp.DiscNumber == 0 {
|
||||
resp.DiscNumber = req.DiscNumber
|
||||
}
|
||||
if req.CoverURL != "" && resp.CoverURL == "" {
|
||||
resp.CoverURL = req.CoverURL
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -904,7 +1279,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
|
||||
|
||||
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)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
deezerClient := GetDeezerClient()
|
||||
@@ -919,6 +1295,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
req.Label = extMeta.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 {
|
||||
GoLog("[DownloadWithExtensionFallback] Failed to get extended metadata from Deezer: %v\n", err)
|
||||
}
|
||||
@@ -966,7 +1346,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
|
||||
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 {
|
||||
GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID)
|
||||
if err != nil {
|
||||
@@ -975,12 +1355,12 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
continue
|
||||
}
|
||||
|
||||
outputPath := buildOutputPath(req)
|
||||
outputPath := buildOutputPathForExtension(req, ext)
|
||||
if req.ItemID != "" {
|
||||
StartItemProgress(req.ItemID)
|
||||
}
|
||||
|
||||
result, err := provider.Download(availability.TrackID, req.Quality, outputPath, func(percent int) {
|
||||
result, err := provider.Download(availability.TrackID, req.Quality, outputPath, req.ItemID, func(percent int) {
|
||||
if req.ItemID != "" {
|
||||
normalized := float64(percent) / 100.0
|
||||
if normalized < 0 {
|
||||
@@ -1011,6 +1391,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
DecryptionKey: result.DecryptionKey,
|
||||
}
|
||||
|
||||
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
|
||||
@@ -1125,28 +1506,10 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
||||
TrackNumber: qobuzResult.TrackNumber,
|
||||
DiscNumber: qobuzResult.DiscNumber,
|
||||
ISRC: qobuzResult.ISRC,
|
||||
CoverURL: qobuzResult.CoverURL,
|
||||
}
|
||||
}
|
||||
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":
|
||||
deezerResult, deezerErr := downloadFromDeezer(req)
|
||||
if deezerErr == nil {
|
||||
@@ -1186,6 +1549,7 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
||||
TrackNumber: result.TrackNumber,
|
||||
DiscNumber: result.DiscNumber,
|
||||
ISRC: result.ISRC,
|
||||
CoverURL: result.CoverURL,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
@@ -1226,7 +1590,58 @@ func buildOutputPath(req DownloadRequest) string {
|
||||
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) {
|
||||
@@ -1237,8 +1652,9 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
if options == nil {
|
||||
@@ -1318,8 +1734,9 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -1374,6 +1791,12 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
|
||||
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 {
|
||||
handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID
|
||||
}
|
||||
@@ -1397,8 +1820,9 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
sourceJSON, _ := json.Marshal(sourceTrack)
|
||||
@@ -1467,8 +1891,9 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return &PostProcessResult{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
metadataJSON, _ := json.Marshal(metadata)
|
||||
@@ -1529,8 +1954,9 @@ func (p *ExtensionProviderWrapper) PostProcessV2(input PostProcessInput, metadat
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return &PostProcessResult{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
metadataJSON, _ := json.Marshal(metadata)
|
||||
@@ -1653,7 +2079,6 @@ func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResu
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetPostProcessingProviders returns all extensions that provide post-processing
|
||||
func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
@@ -1667,7 +2092,6 @@ func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrap
|
||||
return providers
|
||||
}
|
||||
|
||||
// RunPostProcessing runs all enabled post-processing hooks on a file
|
||||
func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[string]interface{}) (*PostProcessResult, error) {
|
||||
providers := m.GetPostProcessingProviders()
|
||||
if len(providers) == 0 {
|
||||
@@ -1713,7 +2137,6 @@ func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[strin
|
||||
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) {
|
||||
providers := m.GetPostProcessingProviders()
|
||||
if len(providers) == 0 {
|
||||
@@ -1768,9 +2191,6 @@ func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata
|
||||
return &PostProcessResult{Success: true, NewFilePath: currentInput.Path, NewFileURI: currentInput.URI}, nil
|
||||
}
|
||||
|
||||
// ==================== Lyrics Provider ====================
|
||||
|
||||
// ExtLyricsResult represents lyrics data returned from an extension
|
||||
type ExtLyricsResult struct {
|
||||
Lines []ExtLyricsLine `json:"lines"`
|
||||
SyncType string `json:"syncType"`
|
||||
@@ -1785,7 +2205,6 @@ type ExtLyricsLine struct {
|
||||
EndTimeMs int64 `json:"endTimeMs"`
|
||||
}
|
||||
|
||||
// FetchLyrics calls the extension's fetchLyrics function
|
||||
func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName string, durationSec float64) (*LyricsResponse, error) {
|
||||
if !p.extension.Manifest.IsLyricsProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a lyrics provider", p.extension.ID)
|
||||
@@ -1794,8 +2213,9 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
// Use global variables to avoid JS injection issues with special characters in track/artist names
|
||||
@@ -1885,7 +2305,6 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// GetLyricsProviders returns all enabled extensions that provide lyrics
|
||||
func (m *ExtensionManager) GetLyricsProviders() []*ExtensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -81,13 +81,17 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
|
||||
}
|
||||
|
||||
type ExtensionRuntime struct {
|
||||
extensionID string
|
||||
manifest *ExtensionManifest
|
||||
settings map[string]interface{}
|
||||
httpClient *http.Client
|
||||
cookieJar http.CookieJar
|
||||
dataDir string
|
||||
vm *goja.Runtime
|
||||
extensionID string
|
||||
manifest *ExtensionManifest
|
||||
settings map[string]interface{}
|
||||
httpClient *http.Client
|
||||
downloadClient *http.Client
|
||||
cookieJar http.CookieJar
|
||||
dataDir string
|
||||
vm *goja.Runtime
|
||||
|
||||
activeDownloadMu sync.RWMutex
|
||||
activeDownloadItemID string
|
||||
|
||||
storageMu sync.RWMutex
|
||||
storageCache map[string]interface{}
|
||||
@@ -132,13 +136,38 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
storageFlushDelay: defaultStorageFlushDelay,
|
||||
}
|
||||
|
||||
runtime.httpClient = newExtensionHTTPClient(ext, jar, 30*time.Second)
|
||||
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout)
|
||||
|
||||
return runtime
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) setActiveDownloadItemID(itemID string) {
|
||||
r.activeDownloadMu.Lock()
|
||||
defer r.activeDownloadMu.Unlock()
|
||||
r.activeDownloadItemID = strings.TrimSpace(itemID)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) clearActiveDownloadItemID() {
|
||||
r.activeDownloadMu.Lock()
|
||||
defer r.activeDownloadMu.Unlock()
|
||||
r.activeDownloadItemID = ""
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) getActiveDownloadItemID() string {
|
||||
r.activeDownloadMu.RLock()
|
||||
defer r.activeDownloadMu.RUnlock()
|
||||
return r.activeDownloadItemID
|
||||
}
|
||||
|
||||
func newExtensionHTTPClient(ext *LoadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
|
||||
// Extension sandbox enforces HTTPS-only domains. Do not apply global
|
||||
// allow_http scheme downgrade here, because some extension APIs (e.g.
|
||||
// spotify-web) will redirect http -> https and can end up in 301 loops.
|
||||
// We still reuse sharedTransport so insecure TLS compatibility mode remains effective.
|
||||
client := &http.Client{
|
||||
Transport: sharedTransport,
|
||||
Timeout: 30 * time.Second,
|
||||
Timeout: timeout,
|
||||
Jar: jar,
|
||||
}
|
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
@@ -165,9 +194,7 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
runtime.httpClient = client
|
||||
|
||||
return runtime
|
||||
return client
|
||||
}
|
||||
|
||||
type RedirectBlockedError struct {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides Auth API and PKCE support for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
@@ -16,8 +15,6 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== Auth API (OAuth Support) ====================
|
||||
|
||||
func validateExtensionAuthURL(urlStr string) error {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
@@ -204,9 +201,6 @@ func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(result)
|
||||
}
|
||||
|
||||
// ==================== PKCE Support ====================
|
||||
|
||||
// generatePKCEVerifier generates a cryptographically random code verifier
|
||||
// Length should be between 43-128 characters (RFC 7636)
|
||||
func generatePKCEVerifier(length int) (string, error) {
|
||||
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 }
|
||||
// Uses the stored PKCE verifier automatically
|
||||
func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
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)
|
||||
clientID, _ := config["clientId"].(string)
|
||||
redirectURI, _ := config["redirectUri"].(string)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides FFmpeg API for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
@@ -10,9 +9,7 @@ import (
|
||||
"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 {
|
||||
ExtensionID string
|
||||
Command string
|
||||
@@ -24,7 +21,6 @@ type FFmpegCommand struct {
|
||||
Output string
|
||||
}
|
||||
|
||||
// Global FFmpeg command queue
|
||||
var (
|
||||
ffmpegCommands = make(map[string]*FFmpegCommand)
|
||||
ffmpegCommandsMu sync.RWMutex
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides File API for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
@@ -13,8 +12,6 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== File API (Sandboxed) ====================
|
||||
|
||||
var (
|
||||
allowedDownloadDirs []string
|
||||
allowedDownloadDirsMu sync.RWMutex
|
||||
@@ -177,7 +174,12 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
}
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
client := r.downloadClient
|
||||
if client == nil {
|
||||
client = r.httpClient
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -203,13 +205,22 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
defer out.Close()
|
||||
|
||||
contentLength := resp.ContentLength
|
||||
activeItemID := r.getActiveDownloadItemID()
|
||||
if activeItemID != "" && contentLength > 0 {
|
||||
SetItemBytesTotal(activeItemID, contentLength)
|
||||
}
|
||||
|
||||
var progressWriter interface{ Write([]byte) (int, error) } = out
|
||||
if activeItemID != "" {
|
||||
progressWriter = NewItemProgressWriter(out, activeItemID)
|
||||
}
|
||||
|
||||
var written int64
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
nr, er := resp.Body.Read(buf)
|
||||
if nr > 0 {
|
||||
nw, ew := out.Write(buf[0:nr])
|
||||
nw, ew := progressWriter.Write(buf[0:nr])
|
||||
if nw < 0 || nr < nw {
|
||||
nw = 0
|
||||
if ew == nil {
|
||||
@@ -218,6 +229,12 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
written += int64(nw)
|
||||
if ew != nil {
|
||||
if ew == ErrDownloadCancelled {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "download cancelled",
|
||||
})
|
||||
}
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to write file: %v", ew),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides HTTP API for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
@@ -12,8 +11,6 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== HTTP API (Sandboxed) ====================
|
||||
|
||||
type HTTPResponse struct {
|
||||
StatusCode int `json:"statusCode"`
|
||||
Body string `json:"body"`
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides Track Matching API for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
@@ -7,8 +6,6 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== Track Matching API ====================
|
||||
|
||||
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(0.0)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides Browser-like Polyfills for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
@@ -13,12 +12,10 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== Browser-like Polyfills ====================
|
||||
// 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 {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.createFetchError("URL is required")
|
||||
@@ -141,7 +138,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
return responseObj
|
||||
}
|
||||
|
||||
// createFetchError creates a fetch error response
|
||||
func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
|
||||
errorObj := r.vm.NewObject()
|
||||
errorObj.Set("ok", false)
|
||||
@@ -157,7 +153,6 @@ func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
|
||||
return errorObj
|
||||
}
|
||||
|
||||
// atobPolyfill implements browser atob() - decode base64 to string
|
||||
func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
@@ -174,7 +169,6 @@ func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(string(decoded))
|
||||
}
|
||||
|
||||
// btoaPolyfill implements browser btoa() - encode string to base64
|
||||
func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
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)))
|
||||
}
|
||||
|
||||
// registerTextEncoderDecoder registers TextEncoder and TextDecoder classes
|
||||
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
|
||||
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) {
|
||||
// JSON is already built-in to Goja, but we can enhance it
|
||||
jsonScript := `
|
||||
if (typeof JSON === 'undefined') {
|
||||
var JSON = {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides Storage and Credentials API for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
@@ -17,8 +16,6 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== Storage API ====================
|
||||
|
||||
const (
|
||||
defaultStorageFlushDelay = 400 * time.Millisecond
|
||||
storageFlushRetryDelay = 2 * time.Second
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides Utility functions for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
@@ -17,8 +16,6 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== Utility Functions ====================
|
||||
|
||||
func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides extension settings storage
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
|
||||
+162
-49
@@ -8,6 +8,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -20,7 +21,7 @@ const (
|
||||
CategoryIntegration = "integration"
|
||||
)
|
||||
|
||||
type StoreExtension struct {
|
||||
type storeExtension struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
@@ -40,7 +41,7 @@ type StoreExtension struct {
|
||||
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
|
||||
}
|
||||
|
||||
func (e *StoreExtension) getDisplayName() string {
|
||||
func (e *storeExtension) getDisplayName() string {
|
||||
if e.DisplayName != "" {
|
||||
return e.DisplayName
|
||||
}
|
||||
@@ -50,34 +51,34 @@ func (e *StoreExtension) getDisplayName() string {
|
||||
return e.Name
|
||||
}
|
||||
|
||||
func (e *StoreExtension) getDownloadURL() string {
|
||||
func (e *storeExtension) getDownloadURL() string {
|
||||
if e.DownloadURL != "" {
|
||||
return e.DownloadURL
|
||||
}
|
||||
return e.DownloadURLAlt
|
||||
}
|
||||
|
||||
func (e *StoreExtension) getIconURL() string {
|
||||
func (e *storeExtension) getIconURL() string {
|
||||
if e.IconURL != "" {
|
||||
return e.IconURL
|
||||
}
|
||||
return e.IconURLAlt
|
||||
}
|
||||
|
||||
func (e *StoreExtension) getMinAppVersion() string {
|
||||
func (e *storeExtension) getMinAppVersion() string {
|
||||
if e.MinAppVersion != "" {
|
||||
return e.MinAppVersion
|
||||
}
|
||||
return e.MinAppVersionAlt
|
||||
}
|
||||
|
||||
type StoreRegistry struct {
|
||||
type storeRegistry struct {
|
||||
Version int `json:"version"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Extensions []StoreExtension `json:"extensions"`
|
||||
Extensions []storeExtension `json:"extensions"`
|
||||
}
|
||||
|
||||
type StoreExtensionResponse struct {
|
||||
type storeExtensionResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
@@ -96,8 +97,8 @@ type StoreExtensionResponse struct {
|
||||
HasUpdate bool `json:"has_update"`
|
||||
}
|
||||
|
||||
func (e *StoreExtension) ToResponse() StoreExtensionResponse {
|
||||
return StoreExtensionResponse{
|
||||
func (e *storeExtension) toResponse() storeExtensionResponse {
|
||||
resp := storeExtensionResponse{
|
||||
ID: e.ID,
|
||||
Name: e.Name,
|
||||
DisplayName: e.getDisplayName(),
|
||||
@@ -107,55 +108,89 @@ func (e *StoreExtension) ToResponse() StoreExtensionResponse {
|
||||
DownloadURL: e.getDownloadURL(),
|
||||
IconURL: e.getIconURL(),
|
||||
Category: e.Category,
|
||||
Tags: e.Tags,
|
||||
Downloads: e.Downloads,
|
||||
UpdatedAt: e.UpdatedAt,
|
||||
MinAppVersion: e.getMinAppVersion(),
|
||||
}
|
||||
|
||||
if len(e.Tags) > 0 {
|
||||
resp.Tags = append([]string(nil), e.Tags...)
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
type ExtensionStore struct {
|
||||
type extensionStore struct {
|
||||
registryURL string
|
||||
cacheDir string
|
||||
cache *StoreRegistry
|
||||
cache *storeRegistry
|
||||
cacheMu sync.RWMutex
|
||||
cacheTime time.Time
|
||||
cacheTTL time.Duration
|
||||
}
|
||||
|
||||
var (
|
||||
extensionStore *ExtensionStore
|
||||
extensionStoreMu sync.Mutex
|
||||
globalExtensionStore *extensionStore
|
||||
extensionStoreMu sync.Mutex
|
||||
)
|
||||
|
||||
const (
|
||||
defaultRegistryURL = "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Extension/main/registry.json"
|
||||
cacheTTL = 30 * time.Minute
|
||||
cacheFileName = "store_cache.json"
|
||||
cacheTTL = 30 * time.Minute
|
||||
cacheFileName = "store_cache.json"
|
||||
)
|
||||
|
||||
func InitExtensionStore(cacheDir string) *ExtensionStore {
|
||||
func initExtensionStore(cacheDir string) *extensionStore {
|
||||
extensionStoreMu.Lock()
|
||||
defer extensionStoreMu.Unlock()
|
||||
|
||||
if extensionStore == nil {
|
||||
extensionStore = &ExtensionStore{
|
||||
registryURL: defaultRegistryURL,
|
||||
if globalExtensionStore == nil {
|
||||
globalExtensionStore = &extensionStore{
|
||||
registryURL: "", // No default - user must provide a registry URL
|
||||
cacheDir: cacheDir,
|
||||
cacheTTL: cacheTTL,
|
||||
}
|
||||
extensionStore.loadDiskCache()
|
||||
globalExtensionStore.loadDiskCache()
|
||||
}
|
||||
return extensionStore
|
||||
return globalExtensionStore
|
||||
}
|
||||
|
||||
func GetExtensionStore() *ExtensionStore {
|
||||
// SetRegistryURL updates the registry URL and clears the in-memory cache
|
||||
// so the next fetch will use the new URL. Disk cache is also cleared.
|
||||
func (s *extensionStore) setRegistryURL(registryURL string) {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
|
||||
if s.registryURL == registryURL {
|
||||
return
|
||||
}
|
||||
|
||||
s.registryURL = registryURL
|
||||
s.cache = nil
|
||||
s.cacheTime = time.Time{}
|
||||
|
||||
// Clear disk cache since it's from a different registry
|
||||
if s.cacheDir != "" {
|
||||
cachePath := filepath.Join(s.cacheDir, cacheFileName)
|
||||
os.Remove(cachePath)
|
||||
}
|
||||
|
||||
LogInfo("ExtensionStore", "Registry URL updated to: %s", registryURL)
|
||||
}
|
||||
|
||||
// GetRegistryURL returns the currently configured registry URL.
|
||||
func (s *extensionStore) getRegistryURL() string {
|
||||
s.cacheMu.RLock()
|
||||
defer s.cacheMu.RUnlock()
|
||||
return s.registryURL
|
||||
}
|
||||
|
||||
func getExtensionStore() *extensionStore {
|
||||
extensionStoreMu.Lock()
|
||||
defer extensionStoreMu.Unlock()
|
||||
return extensionStore
|
||||
return globalExtensionStore
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) loadDiskCache() {
|
||||
func (s *extensionStore) loadDiskCache() {
|
||||
if s.cacheDir == "" {
|
||||
return
|
||||
}
|
||||
@@ -167,7 +202,7 @@ func (s *ExtensionStore) loadDiskCache() {
|
||||
}
|
||||
|
||||
var cacheData struct {
|
||||
Registry StoreRegistry `json:"registry"`
|
||||
Registry storeRegistry `json:"registry"`
|
||||
CacheTime int64 `json:"cache_time"`
|
||||
}
|
||||
|
||||
@@ -180,13 +215,13 @@ func (s *ExtensionStore) loadDiskCache() {
|
||||
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) saveDiskCache() {
|
||||
func (s *extensionStore) saveDiskCache() {
|
||||
if s.cacheDir == "" || s.cache == nil {
|
||||
return
|
||||
}
|
||||
|
||||
cacheData := struct {
|
||||
Registry StoreRegistry `json:"registry"`
|
||||
Registry storeRegistry `json:"registry"`
|
||||
CacheTime int64 `json:"cache_time"`
|
||||
}{
|
||||
Registry: *s.cache,
|
||||
@@ -202,10 +237,14 @@ func (s *ExtensionStore) saveDiskCache() {
|
||||
os.WriteFile(cachePath, data, 0644)
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) {
|
||||
func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error) {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
|
||||
if s.registryURL == "" {
|
||||
return nil, fmt.Errorf("no registry URL configured. Please add a repository URL first")
|
||||
}
|
||||
|
||||
if !forceRefresh && s.cache != nil && time.Since(s.cacheTime) < s.cacheTTL {
|
||||
LogDebug("ExtensionStore", "Using cached registry (%d extensions)", len(s.cache.Extensions))
|
||||
return s.cache, nil
|
||||
@@ -241,7 +280,7 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
|
||||
return nil, fmt.Errorf("failed to read registry: %w", err)
|
||||
}
|
||||
|
||||
var registry StoreRegistry
|
||||
var registry storeRegistry
|
||||
if err := json.Unmarshal(body, ®istry); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse registry: %w", err)
|
||||
}
|
||||
@@ -254,8 +293,8 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
|
||||
return ®istry, nil
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
|
||||
registry, err := s.FetchRegistry(false)
|
||||
func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExtensionResponse, error) {
|
||||
registry, err := s.fetchRegistry(forceRefresh)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -269,29 +308,32 @@ func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, er
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]StoreExtensionResponse, len(registry.Extensions))
|
||||
for i, ext := range registry.Extensions {
|
||||
resp := ext.ToResponse()
|
||||
LogDebug("ExtensionStore", "Building store response for %d registry extensions (%d installed)", len(registry.Extensions), len(installed))
|
||||
|
||||
result := make([]storeExtensionResponse, 0, len(registry.Extensions))
|
||||
for i := range registry.Extensions {
|
||||
ext := ®istry.Extensions[i]
|
||||
resp := ext.toResponse()
|
||||
if installedVersion, ok := installed[ext.ID]; ok {
|
||||
resp.IsInstalled = true
|
||||
resp.InstalledVersion = installedVersion
|
||||
resp.HasUpdate = compareVersions(ext.Version, installedVersion) > 0
|
||||
}
|
||||
|
||||
result[i] = resp
|
||||
result = append(result, resp)
|
||||
}
|
||||
|
||||
LogDebug("ExtensionStore", "Built store response payload for %d extensions", len(result))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
|
||||
registry, err := s.FetchRegistry(false)
|
||||
func (s *extensionStore) downloadExtension(extensionID string, destPath string) error {
|
||||
registry, err := s.fetchRegistry(false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var ext *StoreExtension
|
||||
var ext *storeExtension
|
||||
for _, e := range registry.Extensions {
|
||||
if e.ID == extensionID {
|
||||
ext = &e
|
||||
@@ -336,6 +378,80 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveRegistryURL normalises a user-supplied URL into a direct registry.json URL.
|
||||
//
|
||||
// Accepted formats:
|
||||
// - https://raw.githubusercontent.com/owner/repo/<branch>/registry.json → returned as-is
|
||||
// - https://github.com/owner/repo (with optional trailing path / .git) → resolved via
|
||||
// the GitHub API to discover the default branch, then converted to the raw URL
|
||||
// - Any other HTTPS URL → returned as-is (assumed to be a direct link)
|
||||
func resolveRegistryURL(input string) (string, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return "", fmt.Errorf("registry URL is empty")
|
||||
}
|
||||
|
||||
// Already a fully-qualified raw URL – keep it.
|
||||
if strings.Contains(input, "raw.githubusercontent.com") {
|
||||
return input, nil
|
||||
}
|
||||
|
||||
const ghPrefix = "https://github.com/"
|
||||
if !strings.HasPrefix(input, ghPrefix) {
|
||||
// Also accept http:// and upgrade silently.
|
||||
const ghPrefixHTTP = "http://github.com/"
|
||||
if strings.HasPrefix(input, ghPrefixHTTP) {
|
||||
input = "https://github.com/" + input[len(ghPrefixHTTP):]
|
||||
} else {
|
||||
// Not a GitHub URL – return as-is.
|
||||
return input, nil
|
||||
}
|
||||
}
|
||||
|
||||
path := input[len(ghPrefix):]
|
||||
parts := strings.SplitN(path, "/", 3) // owner, repo, [rest]
|
||||
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
|
||||
return "", fmt.Errorf("invalid GitHub URL: expected github.com/<owner>/<repo>")
|
||||
}
|
||||
owner := parts[0]
|
||||
repo := strings.TrimSuffix(parts[1], ".git")
|
||||
|
||||
branch := resolveGitHubDefaultBranch(owner, repo)
|
||||
|
||||
resolved := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/registry.json", owner, repo, branch)
|
||||
LogInfo("ExtensionStore", "Resolved %s → %s (branch: %s)", input, resolved, branch)
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// resolveGitHubDefaultBranch calls the GitHub API to discover the repository's
|
||||
// default branch. Falls back to "main" on any error.
|
||||
func resolveGitHubDefaultBranch(owner, repo string) string {
|
||||
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repo)
|
||||
client := NewHTTPClientWithTimeout(10 * time.Second)
|
||||
|
||||
resp, err := client.Get(apiURL)
|
||||
if err != nil {
|
||||
LogWarn("ExtensionStore", "GitHub API request failed for %s/%s: %v – falling back to main", owner, repo, err)
|
||||
return "main"
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
LogWarn("ExtensionStore", "GitHub API returned %d for %s/%s – falling back to main", resp.StatusCode, owner, repo)
|
||||
return "main"
|
||||
}
|
||||
|
||||
var info struct {
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil || info.DefaultBranch == "" {
|
||||
LogWarn("ExtensionStore", "Could not parse default_branch for %s/%s – falling back to main", owner, repo)
|
||||
return "main"
|
||||
}
|
||||
|
||||
return info.DefaultBranch
|
||||
}
|
||||
|
||||
func requireHTTPSURL(rawURL string, context string) error {
|
||||
if rawURL == "" {
|
||||
return fmt.Errorf("%s URL is empty", context)
|
||||
@@ -350,7 +466,7 @@ func requireHTTPSURL(rawURL string, context string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) GetCategories() []string {
|
||||
func (s *extensionStore) getCategories() []string {
|
||||
return []string{
|
||||
CategoryMetadata,
|
||||
CategoryDownload,
|
||||
@@ -360,8 +476,8 @@ func (s *ExtensionStore) GetCategories() []string {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) {
|
||||
extensions, err := s.GetExtensionsWithStatus()
|
||||
func (s *extensionStore) searchExtensions(query string, category string) ([]storeExtensionResponse, error) {
|
||||
extensions, err := s.getExtensionsWithStatus(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -370,22 +486,19 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
|
||||
return extensions, nil
|
||||
}
|
||||
|
||||
var result []StoreExtensionResponse
|
||||
result := make([]storeExtensionResponse, 0, len(extensions))
|
||||
queryLower := toLower(query)
|
||||
|
||||
for _, ext := range extensions {
|
||||
// Filter by category
|
||||
if category != "" && ext.Category != category {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter by query
|
||||
if query != "" {
|
||||
if !containsIgnoreCase(ext.Name, queryLower) &&
|
||||
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
||||
!containsIgnoreCase(ext.Description, queryLower) &&
|
||||
!containsIgnoreCase(ext.Author, queryLower) {
|
||||
// Check tags
|
||||
found := false
|
||||
for _, tag := range ext.Tags {
|
||||
if containsIgnoreCase(tag, queryLower) {
|
||||
@@ -405,7 +518,7 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) ClearCache() {
|
||||
func (s *extensionStore) clearCache() {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides timeout execution for extension JS code
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
|
||||
+1
-1
@@ -12,6 +12,7 @@ require (
|
||||
github.com/refraction-networking/utls v1.8.2
|
||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864
|
||||
golang.org/x/net v0.50.0
|
||||
golang.org/x/text v0.34.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -24,6 +25,5 @@ require (
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
)
|
||||
|
||||
@@ -6,8 +6,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw=
|
||||
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
||||
@@ -30,36 +28,20 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv
|
||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4=
|
||||
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
|
||||
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af h1:VqXrZNyqFISxo0rNDFZQlRDRIp7RXSJDeh/LbrK+W1k=
|
||||
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af/go.mod h1:tbwefIr7RlQD1OpZ0KEZ9nux/uiihAOGdafgZfJkmII=
|
||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4=
|
||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
|
||||
+5
-11
@@ -31,7 +31,7 @@ func getRandomUserAgent() string {
|
||||
|
||||
const (
|
||||
DefaultTimeout = 60 * time.Second
|
||||
DownloadTimeout = 120 * time.Second
|
||||
DownloadTimeout = 24 * time.Hour
|
||||
SongLinkTimeout = 30 * time.Second
|
||||
DefaultMaxRetries = 3
|
||||
DefaultRetryDelay = 1 * time.Second
|
||||
@@ -300,14 +300,11 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for ISP blocking via HTTP status codes
|
||||
// Some ISPs return 403 or 451 when blocking content
|
||||
if resp.StatusCode == 403 || resp.StatusCode == 451 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
bodyStr := strings.ToLower(string(body))
|
||||
|
||||
// Check if response looks like ISP blocking page
|
||||
ispBlockingIndicators := []string{
|
||||
"blocked", "forbidden", "access denied", "not available in your",
|
||||
"restricted", "censored", "unavailable for legal", "blocked by",
|
||||
@@ -346,11 +343,12 @@ func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Dur
|
||||
return min(nextDelay, config.MaxDelay)
|
||||
}
|
||||
|
||||
// Returns 60 seconds as default if header is missing or invalid
|
||||
// Returns 0 if the header is missing or invalid so callers can keep their
|
||||
// normal exponential backoff instead of stalling for an arbitrary minute.
|
||||
func getRetryAfterDuration(resp *http.Response) time.Duration {
|
||||
retryAfter := resp.Header.Get("Retry-After")
|
||||
if retryAfter == "" {
|
||||
return 60 * time.Second // Default wait time
|
||||
return 0
|
||||
}
|
||||
|
||||
if seconds, err := strconv.Atoi(retryAfter); err == nil {
|
||||
@@ -364,7 +362,7 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
|
||||
}
|
||||
}
|
||||
|
||||
return 60 * time.Second // Default
|
||||
return 0
|
||||
}
|
||||
|
||||
func ReadResponseBody(resp *http.Response) ([]byte, error) {
|
||||
@@ -489,7 +487,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
||||
}
|
||||
}
|
||||
|
||||
// Check error message patterns for common ISP blocking indicators
|
||||
blockingPatterns := []struct {
|
||||
pattern string
|
||||
reason string
|
||||
@@ -518,7 +515,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Returns true if ISP blocking was detected
|
||||
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
||||
ispErr := IsISPBlocking(err, requestURL)
|
||||
if ispErr != nil {
|
||||
@@ -532,7 +528,6 @@ func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// extractDomain extracts the domain from a URL string
|
||||
func extractDomain(rawURL string) string {
|
||||
if rawURL == "" {
|
||||
return "unknown"
|
||||
@@ -554,7 +549,6 @@ func extractDomain(rawURL string) string {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// If ISP blocking is detected, returns a more descriptive error
|
||||
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
|
||||
@@ -91,7 +91,6 @@ func (t *utlsTransport) getPort(u *url.URL) string {
|
||||
return "80"
|
||||
}
|
||||
|
||||
// Cloudflare bypass client using uTLS Chrome fingerprint
|
||||
var cloudflareBypassTransport = newUTLSTransport()
|
||||
|
||||
var cloudflareBypassClient = &http.Client{
|
||||
@@ -111,10 +110,8 @@ func GetCloudflareBypassClient() *http.Client {
|
||||
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
// Try with standard client first
|
||||
resp, err := sharedClient.Do(req)
|
||||
if err == nil {
|
||||
// Check for Cloudflare challenge page (403 with specific markers)
|
||||
if resp.StatusCode == 403 || resp.StatusCode == 503 {
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
@@ -138,11 +135,9 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||
if isCloudflare {
|
||||
LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...")
|
||||
|
||||
// Clone request for retry
|
||||
reqCopy := req.Clone(req.Context())
|
||||
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
// Retry with uTLS Chrome fingerprint
|
||||
return cloudflareBypassClient.Do(reqCopy)
|
||||
}
|
||||
}
|
||||
@@ -158,7 +153,6 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Check if error might be TLS-related (Cloudflare blocking)
|
||||
errStr := strings.ToLower(err.Error())
|
||||
tlsRelated := strings.Contains(errStr, "tls") ||
|
||||
strings.Contains(errStr, "handshake") ||
|
||||
@@ -168,11 +162,9 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||
if tlsRelated {
|
||||
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
|
||||
|
||||
// Clone request for retry
|
||||
reqCopy := req.Clone(req.Context())
|
||||
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
// Retry with uTLS Chrome fingerprint
|
||||
return cloudflareBypassClient.Do(reqCopy)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,13 +22,11 @@ var (
|
||||
idhsRateLimiter = NewRateLimiter(8, time.Minute) // 8 req/min (below 10 limit)
|
||||
)
|
||||
|
||||
// IDHSSearchRequest represents the request body for IDHS API
|
||||
type IDHSSearchRequest struct {
|
||||
Link string `json:"link"`
|
||||
Adapters []string `json:"adapters,omitempty"`
|
||||
}
|
||||
|
||||
// IDHSSearchResponse represents the response from IDHS API
|
||||
type IDHSSearchResponse struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // song, album, artist, podcast, show
|
||||
@@ -41,7 +39,6 @@ type IDHSSearchResponse struct {
|
||||
Links []IDHSLink `json:"links"`
|
||||
}
|
||||
|
||||
// IDHSLink represents a link to a streaming platform
|
||||
type IDHSLink struct {
|
||||
Type string `json:"type"` // spotify, youTube, appleMusic, deezer, soundCloud, tidal
|
||||
URL string `json:"url"`
|
||||
@@ -49,7 +46,6 @@ type IDHSLink struct {
|
||||
NotAvailable bool `json:"notAvailable,omitempty"`
|
||||
}
|
||||
|
||||
// NewIDHSClient creates a new IDHS client
|
||||
func NewIDHSClient() *IDHSClient {
|
||||
idhsClientOnce.Do(func() {
|
||||
globalIDHSClient = &IDHSClient{
|
||||
@@ -117,7 +113,6 @@ func (c *IDHSClient) Search(link string, adapters []string) (*IDHSSearchResponse
|
||||
func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
|
||||
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
||||
|
||||
// Request only the platforms we need
|
||||
adapters := []string{"tidal", "deezer"}
|
||||
|
||||
result, err := c.Search(spotifyURL, adapters)
|
||||
@@ -151,11 +146,9 @@ func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAv
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
// GetAvailabilityFromDeezer checks track availability using IDHS
|
||||
func (c *IDHSClient) GetAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
|
||||
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
||||
|
||||
// Request only the platforms we need
|
||||
adapters := []string{"spotify", "tidal"}
|
||||
|
||||
result, err := c.Search(deezerURL, adapters)
|
||||
|
||||
+270
-54
@@ -1,16 +1,17 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LibraryScanResult represents metadata from a scanned audio file
|
||||
type LibraryScanResult struct {
|
||||
ID string `json:"id"`
|
||||
TrackName string `json:"trackName"`
|
||||
@@ -42,7 +43,6 @@ type LibraryScanProgress struct {
|
||||
IsComplete bool `json:"is_complete"`
|
||||
}
|
||||
|
||||
// IncrementalScanResult contains results of an incremental library scan
|
||||
type IncrementalScanResult struct {
|
||||
Scanned []LibraryScanResult `json:"scanned"` // New or updated files
|
||||
DeletedPaths []string `json:"deletedPaths"` // Files that no longer exist
|
||||
@@ -65,6 +65,7 @@ var supportedAudioFormats = map[string]bool{
|
||||
".mp3": true,
|
||||
".opus": true,
|
||||
".ogg": true,
|
||||
".cue": true,
|
||||
}
|
||||
|
||||
type libraryAudioFileInfo struct {
|
||||
@@ -72,6 +73,11 @@ type libraryAudioFileInfo struct {
|
||||
modTime int64
|
||||
}
|
||||
|
||||
type scannedCueFileInfo struct {
|
||||
sheet *CueSheet
|
||||
audioPath string
|
||||
}
|
||||
|
||||
func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) {
|
||||
var files []libraryAudioFileInfo
|
||||
|
||||
@@ -145,12 +151,7 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
||||
return "[]", err
|
||||
}
|
||||
|
||||
audioFiles := make([]string, 0, len(audioFileInfos))
|
||||
for _, fileInfo := range audioFileInfos {
|
||||
audioFiles = append(audioFiles, fileInfo.path)
|
||||
}
|
||||
|
||||
totalFiles := len(audioFiles)
|
||||
totalFiles := len(audioFileInfos)
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.TotalFiles = totalFiles
|
||||
libraryScanProgressMu.Unlock()
|
||||
@@ -168,7 +169,31 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||
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 {
|
||||
case <-cancelCh:
|
||||
return "[]", fmt.Errorf("scan cancelled")
|
||||
@@ -181,7 +206,40 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
||||
libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
errorCount++
|
||||
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
|
||||
@@ -207,7 +265,15 @@ func ScanLibraryFolder(folderPath string) (string, 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{
|
||||
ID: generateLibraryID(filePath),
|
||||
@@ -216,16 +282,17 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
||||
Format: strings.TrimPrefix(ext, "."),
|
||||
}
|
||||
|
||||
// Get file modification time
|
||||
if info, err := os.Stat(filePath); err == nil {
|
||||
if knownModTime > 0 {
|
||||
result.FileModTime = knownModTime
|
||||
} else if info, err := os.Stat(filePath); err == nil {
|
||||
result.FileModTime = info.ModTime().UnixMilli()
|
||||
}
|
||||
|
||||
libraryCoverCacheMu.RLock()
|
||||
coverCacheDir := libraryCoverCacheDir
|
||||
libraryCoverCacheMu.RUnlock()
|
||||
if coverCacheDir != "" && ext != ".m4a" {
|
||||
coverPath, err := SaveCoverToCache(filePath, coverCacheDir)
|
||||
if coverCacheDir != "" {
|
||||
coverPath, err := SaveCoverToCacheWithHint(filePath, displayNameHint, coverCacheDir)
|
||||
if err == nil && coverPath != "" {
|
||||
result.CoverPath = coverPath
|
||||
}
|
||||
@@ -239,15 +306,31 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
||||
case ".mp3":
|
||||
return scanMP3File(filePath, result)
|
||||
case ".opus", ".ogg":
|
||||
return scanOggFile(filePath, result)
|
||||
return scanOggFile(filePath, result, displayNameHint)
|
||||
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 == "" {
|
||||
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
result.TrackName = strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
|
||||
}
|
||||
if result.ArtistName == "" {
|
||||
result.ArtistName = "Unknown Artist"
|
||||
@@ -260,7 +343,7 @@ func applyDefaultLibraryMetadata(filePath string, result *LibraryScanResult) {
|
||||
func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadMetadata(filePath)
|
||||
if err != nil {
|
||||
return scanFromFilename(filePath, result)
|
||||
return scanFromFilename(filePath, "", result)
|
||||
}
|
||||
|
||||
result.TrackName = metadata.Title
|
||||
@@ -282,26 +365,43 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul
|
||||
}
|
||||
}
|
||||
|
||||
applyDefaultLibraryMetadata(filePath, result)
|
||||
applyDefaultLibraryMetadata(filePath, "", result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadM4ATags(filePath)
|
||||
if err == nil && metadata != nil {
|
||||
result.TrackName = metadata.Title
|
||||
result.ArtistName = metadata.Artist
|
||||
result.AlbumName = metadata.Album
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.ISRC = metadata.ISRC
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.ReleaseDate = metadata.Date
|
||||
if result.ReleaseDate == "" {
|
||||
result.ReleaseDate = metadata.Year
|
||||
}
|
||||
result.Genre = metadata.Genre
|
||||
}
|
||||
|
||||
quality, err := GetM4AQuality(filePath)
|
||||
if err == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
}
|
||||
|
||||
return scanFromFilename(filePath, result)
|
||||
applyDefaultLibraryMetadata(filePath, "", result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadID3Tags(filePath)
|
||||
if err != nil {
|
||||
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
|
||||
return scanFromFilename(filePath, result)
|
||||
return scanFromFilename(filePath, "", result)
|
||||
}
|
||||
|
||||
result.TrackName = metadata.Title
|
||||
@@ -328,16 +428,16 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
||||
}
|
||||
}
|
||||
|
||||
applyDefaultLibraryMetadata(filePath, result)
|
||||
applyDefaultLibraryMetadata(filePath, "", result)
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
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
|
||||
@@ -360,13 +460,14 @@ func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
||||
}
|
||||
}
|
||||
|
||||
applyDefaultLibraryMetadata(filePath, result)
|
||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
filename := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
|
||||
filename := strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
|
||||
|
||||
parts := strings.SplitN(filename, " - ", 2)
|
||||
if len(parts) == 2 {
|
||||
@@ -389,7 +490,7 @@ func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanR
|
||||
|
||||
dir := filepath.Dir(filePath)
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -436,8 +537,12 @@ func CancelLibraryScan() {
|
||||
}
|
||||
|
||||
func ReadAudioMetadata(filePath string) (string, error) {
|
||||
return ReadAudioMetadataWithDisplayName(filePath, "")
|
||||
}
|
||||
|
||||
func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string, error) {
|
||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||
result, err := scanAudioFile(filePath, scanTime)
|
||||
result, err := scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime, 0)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -450,10 +555,43 @@ func ReadAudioMetadata(filePath string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
|
||||
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
|
||||
// Only files that are new or have changed modification time will be scanned
|
||||
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
|
||||
func 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 == "" {
|
||||
return "{}", fmt.Errorf("folder path is empty")
|
||||
}
|
||||
@@ -466,22 +604,12 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
||||
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))
|
||||
|
||||
// Reset progress
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress = LibraryScanProgress{}
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
// Setup cancellation
|
||||
libraryScanCancelMu.Lock()
|
||||
if libraryScanCancel != nil {
|
||||
close(libraryScanCancel)
|
||||
@@ -490,7 +618,6 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
||||
cancelCh := libraryScanCancel
|
||||
libraryScanCancelMu.Unlock()
|
||||
|
||||
// Collect all audio files with their mod times
|
||||
currentFiles, err := collectLibraryAudioFiles(folderPath, cancelCh)
|
||||
if err != nil {
|
||||
return "{}", err
|
||||
@@ -505,28 +632,48 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
||||
libraryScanProgress.TotalFiles = totalFiles
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
// Find files to scan (new or modified)
|
||||
var filesToScan []libraryAudioFileInfo
|
||||
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 {
|
||||
existingModTime, exists := existingFiles[f.path]
|
||||
if !exists {
|
||||
// New file
|
||||
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
|
||||
if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks {
|
||||
if f.modTime == cueTrackModTime {
|
||||
skippedCount++
|
||||
} else {
|
||||
filesToScan = append(filesToScan, f)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
filesToScan = append(filesToScan, f)
|
||||
} else if f.modTime != existingModTime {
|
||||
// Modified file
|
||||
filesToScan = append(filesToScan, f)
|
||||
} else {
|
||||
// Unchanged file - skip
|
||||
skippedCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Find deleted files
|
||||
var deletedPaths []string
|
||||
for existingPath := range existingFiles {
|
||||
if !currentPathSet[existingPath] {
|
||||
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
|
||||
baseCuePath := existingPath[:idx]
|
||||
if currentPathSet[baseCuePath] {
|
||||
continue
|
||||
}
|
||||
deletedPaths = append(deletedPaths, existingPath)
|
||||
} else if !currentPathSet[existingPath] {
|
||||
deletedPaths = append(deletedPaths, existingPath)
|
||||
}
|
||||
}
|
||||
@@ -551,11 +698,29 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// Scan the files that need scanning
|
||||
results := make([]LibraryScanResult, 0, len(filesToScan))
|
||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||
errorCount := 0
|
||||
|
||||
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 {
|
||||
select {
|
||||
case <-cancelCh:
|
||||
@@ -569,7 +734,37 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
||||
libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
result, err := scanAudioFile(f.path, scanTime)
|
||||
ext := strings.ToLower(filepath.Ext(f.path))
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if cueReferencedAudioFilesInc[f.path] {
|
||||
continue
|
||||
}
|
||||
|
||||
result, err := scanAudioFileWithKnownModTime(f.path, scanTime, f.modTime)
|
||||
if err != nil {
|
||||
errorCount++
|
||||
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
|
||||
@@ -603,3 +798,24 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
|
||||
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
|
||||
// Only files that are new or have changed modification time will be scanned
|
||||
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
|
||||
existingFiles := make(map[string]int64)
|
||||
if existingFilesJSON != "" && existingFilesJSON != "{}" {
|
||||
if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil {
|
||||
GoLog("[LibraryScan] Warning: failed to parse existing files JSON: %v\n", err)
|
||||
}
|
||||
}
|
||||
return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles)
|
||||
}
|
||||
|
||||
func ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath string) (string, error) {
|
||||
existingFiles, err := loadExistingFilesSnapshot(snapshotPath)
|
||||
if err != nil {
|
||||
return "{}", fmt.Errorf("failed to load incremental snapshot: %w", err)
|
||||
}
|
||||
return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles)
|
||||
}
|
||||
|
||||
+107
-73
@@ -3,6 +3,7 @@ package gobackend
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -41,7 +42,6 @@ var DefaultLyricsProviders = []string{
|
||||
LyricsProviderQQMusic,
|
||||
}
|
||||
|
||||
// Global lyrics provider configuration
|
||||
var (
|
||||
lyricsProvidersMu sync.RWMutex
|
||||
lyricsProviders []string // ordered list of enabled providers
|
||||
@@ -83,7 +83,6 @@ func SetLyricsProviderOrder(providers []string) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate provider names
|
||||
validNames := map[string]bool{
|
||||
LyricsProviderSpotifyAPI: true,
|
||||
LyricsProviderLRCLIB: true,
|
||||
@@ -105,7 +104,6 @@ func SetLyricsProviderOrder(providers []string) {
|
||||
GoLog("[Lyrics] Provider order set to: %v\n", valid)
|
||||
}
|
||||
|
||||
// GetLyricsProviderOrder returns the current lyrics provider order.
|
||||
func GetLyricsProviderOrder() []string {
|
||||
lyricsProvidersMu.RLock()
|
||||
defer lyricsProvidersMu.RUnlock()
|
||||
@@ -119,15 +117,14 @@ func GetLyricsProviderOrder() []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// GetAvailableLyricsProviders returns metadata about all available providers.
|
||||
func GetAvailableLyricsProviders() []map[string]interface{} {
|
||||
return []map[string]interface{}{
|
||||
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced synced lyrics via community API"},
|
||||
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"},
|
||||
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": false, "description": "NetEase Cloud Music (good for Asian songs)"},
|
||||
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Largest lyrics database (multi-language)"},
|
||||
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Word-by-word synced lyrics"},
|
||||
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics (good for Chinese songs)"},
|
||||
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics via Paxsenix"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +137,6 @@ func normalizeLyricsFetchOptions(opts LyricsFetchOptions) LyricsFetchOptions {
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetLyricsFetchOptions sets provider-specific lyric fetch behavior.
|
||||
func SetLyricsFetchOptions(opts LyricsFetchOptions) {
|
||||
normalized := normalizeLyricsFetchOptions(opts)
|
||||
|
||||
@@ -156,7 +152,6 @@ func SetLyricsFetchOptions(opts LyricsFetchOptions) {
|
||||
)
|
||||
}
|
||||
|
||||
// GetLyricsFetchOptions returns current provider-specific lyric fetch behavior.
|
||||
func GetLyricsFetchOptions() LyricsFetchOptions {
|
||||
lyricsFetchOptionsMu.RLock()
|
||||
defer lyricsFetchOptionsMu.RUnlock()
|
||||
@@ -432,6 +427,99 @@ func parseSpotifyRetryAfter(retryAfter string, now time.Time) time.Time {
|
||||
return now.Add(10 * time.Minute)
|
||||
}
|
||||
|
||||
func buildSpotifyLyricsResponse(lines []LyricsLine, syncType, plainLyrics string) (*LyricsResponse, error) {
|
||||
if len(lines) == 0 {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned empty lines")
|
||||
}
|
||||
if syncType == "" {
|
||||
if len(lines) > 0 && lines[0].StartTimeMs > 0 {
|
||||
syncType = "LINE_SYNCED"
|
||||
} else {
|
||||
syncType = "UNSYNCED"
|
||||
}
|
||||
}
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: syncType,
|
||||
Instrumental: false,
|
||||
PlainLyrics: plainLyrics,
|
||||
Provider: "Spotify Lyrics API",
|
||||
Source: "Spotify Lyrics API",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func plainLyricsFromTimedLines(lines []LyricsLine) string {
|
||||
parts := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
words := strings.TrimSpace(line.Words)
|
||||
if words == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, words)
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func parseSpotifyLyricsResponseBody(body []byte) (*LyricsResponse, error) {
|
||||
var lrcPayload string
|
||||
if err := json.Unmarshal(body, &lrcPayload); err == nil {
|
||||
trimmed := strings.TrimSpace(lrcPayload)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned empty payload")
|
||||
}
|
||||
|
||||
lines := parseSyncedLyrics(trimmed)
|
||||
if len(lines) > 0 {
|
||||
return buildSpotifyLyricsResponse(lines, "LINE_SYNCED", plainLyricsFromTimedLines(lines))
|
||||
}
|
||||
|
||||
plainLines := plainTextLyricsLines(trimmed)
|
||||
return buildSpotifyLyricsResponse(plainLines, "UNSYNCED", trimmed)
|
||||
}
|
||||
|
||||
var apiResp SpotifyLyricsAPIResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.Error {
|
||||
msg := strings.TrimSpace(apiResp.Message)
|
||||
if msg == "" {
|
||||
msg = "Spotify Lyrics API returned error"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
lines := make([]LyricsLine, 0, len(apiResp.Lines))
|
||||
for _, line := range apiResp.Lines {
|
||||
words := strings.TrimSpace(line.Words)
|
||||
if words == "" {
|
||||
continue
|
||||
}
|
||||
startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag)
|
||||
lines = append(lines, LyricsLine{
|
||||
StartTimeMs: startMs,
|
||||
Words: words,
|
||||
EndTimeMs: 0,
|
||||
})
|
||||
}
|
||||
|
||||
for i := 0; i < len(lines)-1; i++ {
|
||||
nextStart := lines[i+1].StartTimeMs
|
||||
if nextStart > lines[i].StartTimeMs {
|
||||
lines[i].EndTimeMs = nextStart
|
||||
}
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
last := len(lines) - 1
|
||||
if lines[last].EndTimeMs == 0 {
|
||||
lines[last].EndTimeMs = lines[last].StartTimeMs + 5000
|
||||
}
|
||||
}
|
||||
|
||||
return buildSpotifyLyricsResponse(lines, apiResp.SyncType, plainLyricsFromTimedLines(lines))
|
||||
}
|
||||
|
||||
func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsResponse, error) {
|
||||
now := time.Now()
|
||||
if limitedUntil := getSpotifyLyricsRateLimitUntil(); limitedUntil.After(now) {
|
||||
@@ -450,7 +538,7 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo
|
||||
spotifyID = parsed.ID
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://spotify-lyrics-api-pi.vercel.app/?trackid=%s&format=lrc", url.QueryEscape(spotifyID))
|
||||
apiURL := fmt.Sprintf("https://lyrics.paxsenix.org/spotify/lyrics?id=%s", url.QueryEscape(spotifyID))
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
@@ -463,13 +551,18 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read Spotify Lyrics API response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
retryUntil := parseSpotifyRetryAfter(resp.Header.Get("Retry-After"), now)
|
||||
setSpotifyLyricsRateLimitUntil(retryUntil)
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err == nil {
|
||||
if err := json.Unmarshal(bodyBytes, &payload); err == nil {
|
||||
if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg))
|
||||
}
|
||||
@@ -480,63 +573,7 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var apiResp SpotifyLyricsAPIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.Error {
|
||||
msg := strings.TrimSpace(apiResp.Message)
|
||||
if msg == "" {
|
||||
msg = "Spotify Lyrics API returned error"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
result := &LyricsResponse{
|
||||
Lines: make([]LyricsLine, 0, len(apiResp.Lines)),
|
||||
SyncType: apiResp.SyncType,
|
||||
Instrumental: false,
|
||||
PlainLyrics: "",
|
||||
Provider: "Spotify Lyrics API",
|
||||
Source: "Spotify Lyrics API",
|
||||
}
|
||||
|
||||
for _, line := range apiResp.Lines {
|
||||
words := strings.TrimSpace(line.Words)
|
||||
if words == "" {
|
||||
continue
|
||||
}
|
||||
startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag)
|
||||
result.Lines = append(result.Lines, LyricsLine{
|
||||
StartTimeMs: startMs,
|
||||
Words: words,
|
||||
EndTimeMs: 0,
|
||||
})
|
||||
}
|
||||
|
||||
if len(result.Lines) > 1 {
|
||||
for i := 0; i < len(result.Lines)-1; i++ {
|
||||
nextStart := result.Lines[i+1].StartTimeMs
|
||||
if nextStart > result.Lines[i].StartTimeMs {
|
||||
result.Lines[i].EndTimeMs = nextStart
|
||||
}
|
||||
}
|
||||
last := len(result.Lines) - 1
|
||||
if result.Lines[last].EndTimeMs == 0 {
|
||||
result.Lines[last].EndTimeMs = result.Lines[last].StartTimeMs + 5000
|
||||
}
|
||||
}
|
||||
|
||||
if len(result.Lines) == 0 {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned empty lines")
|
||||
}
|
||||
|
||||
if result.SyncType == "" {
|
||||
result.SyncType = "LINE_SYNCED"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return parseSpotifyLyricsResponseBody(bodyBytes)
|
||||
}
|
||||
|
||||
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
|
||||
@@ -598,7 +635,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
||||
return lyricsHasUsableText(l)
|
||||
}
|
||||
|
||||
// Try extension lyrics providers first
|
||||
if len(extensionProviders) > 0 {
|
||||
for _, provider := range extensionProviders {
|
||||
GoLog("[Lyrics] Trying extension lyrics provider: %s\n", provider.extension.ID)
|
||||
@@ -621,13 +657,11 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
||||
return &cachedCopy, nil
|
||||
}
|
||||
|
||||
// Get configured provider order
|
||||
providerOrder := GetLyricsProviderOrder()
|
||||
simplifiedTrack := simplifyTrackName(trackName)
|
||||
|
||||
GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder)
|
||||
|
||||
// Cascade through all configured built-in providers
|
||||
for _, providerName := range providerOrder {
|
||||
GoLog("[Lyrics] Trying provider: %s\n", providerName)
|
||||
|
||||
|
||||
+65
-130
@@ -4,122 +4,25 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AppleMusicClient fetches lyrics from Apple Music.
|
||||
// Uses a scraped JWT token for search and a proxy for lyrics.
|
||||
// Uses Paxsenix endpoints for search and lyrics.
|
||||
type AppleMusicClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Apple Music token manager — singleton with mutex for thread safety
|
||||
type appleTokenManager struct {
|
||||
mu sync.Mutex
|
||||
token string
|
||||
}
|
||||
|
||||
var globalAppleTokenManager = &appleTokenManager{}
|
||||
|
||||
func (m *appleTokenManager) getToken(client *http.Client) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.token != "" {
|
||||
return m.token, nil
|
||||
}
|
||||
|
||||
// Step 1: Fetch the Apple Music beta page
|
||||
req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch Apple Music page: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read Apple Music page: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Find the index JS file URL
|
||||
indexJsRegex := regexp.MustCompile(`/assets/index~[^/]+\.js`)
|
||||
match := indexJsRegex.Find(body)
|
||||
if match == nil {
|
||||
return "", fmt.Errorf("could not find index JS script URL on Apple Music page")
|
||||
}
|
||||
|
||||
indexJsURL := "https://beta.music.apple.com" + string(match)
|
||||
|
||||
// Step 3: Fetch the JS file
|
||||
jsReq, err := http.NewRequest("GET", indexJsURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create JS request: %w", err)
|
||||
}
|
||||
jsReq.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
jsResp, err := client.Do(jsReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch Apple Music JS: %w", err)
|
||||
}
|
||||
defer jsResp.Body.Close()
|
||||
|
||||
jsBody, err := io.ReadAll(jsResp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read Apple Music JS: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Extract JWT token (starts with eyJh)
|
||||
tokenRegex := regexp.MustCompile(`eyJh[^"]*`)
|
||||
tokenMatch := tokenRegex.Find(jsBody)
|
||||
if tokenMatch == nil {
|
||||
return "", fmt.Errorf("could not find JWT token in Apple Music JS")
|
||||
}
|
||||
|
||||
m.token = string(tokenMatch)
|
||||
GoLog("[AppleMusic] Token obtained successfully (length: %d)\n", len(m.token))
|
||||
return m.token, nil
|
||||
}
|
||||
|
||||
func (m *appleTokenManager) clearToken() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.token = ""
|
||||
}
|
||||
|
||||
// Apple Music API response models
|
||||
type appleMusicSearchResponse struct {
|
||||
Results struct {
|
||||
Songs *struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
} `json:"data"`
|
||||
} `json:"songs"`
|
||||
} `json:"results"`
|
||||
Resources *struct {
|
||||
Songs map[string]struct {
|
||||
Attributes struct {
|
||||
Name string `json:"name"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
URL string `json:"url"`
|
||||
Artwork struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"artwork"`
|
||||
} `json:"attributes"`
|
||||
} `json:"songs"`
|
||||
} `json:"resources"`
|
||||
type appleMusicSearchResult struct {
|
||||
ID string `json:"id"`
|
||||
SongName string `json:"songName"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
Duration int `json:"duration"`
|
||||
}
|
||||
|
||||
// PaxResponse represents the lyrics proxy response for word-by-word / line lyrics
|
||||
@@ -150,32 +53,71 @@ func NewAppleMusicClient() *AppleMusicClient {
|
||||
}
|
||||
}
|
||||
|
||||
func selectBestAppleMusicSearchResult(results []appleMusicSearchResult, trackName, artistName string, durationSec float64) *appleMusicSearchResult {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
normalizedTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(trackName)))
|
||||
normalizedArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(artistName)))
|
||||
if normalizedArtist == "" {
|
||||
normalizedArtist = strings.ToLower(strings.TrimSpace(artistName))
|
||||
}
|
||||
|
||||
bestIndex := 0
|
||||
bestScore := -1
|
||||
for i := range results {
|
||||
result := &results[i]
|
||||
score := 0
|
||||
|
||||
candidateTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(result.SongName)))
|
||||
candidateArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(result.ArtistName)))
|
||||
|
||||
switch {
|
||||
case candidateTrack == normalizedTrack:
|
||||
score += 50
|
||||
case strings.Contains(candidateTrack, normalizedTrack) || strings.Contains(normalizedTrack, candidateTrack):
|
||||
score += 25
|
||||
}
|
||||
|
||||
switch {
|
||||
case candidateArtist == normalizedArtist:
|
||||
score += 60
|
||||
case strings.Contains(candidateArtist, normalizedArtist) || strings.Contains(normalizedArtist, candidateArtist):
|
||||
score += 30
|
||||
}
|
||||
|
||||
if durationSec > 0 && result.Duration > 0 {
|
||||
diff := math.Abs(float64(result.Duration)/1000.0 - durationSec)
|
||||
if diff <= durationToleranceSec {
|
||||
score += 20
|
||||
}
|
||||
}
|
||||
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
return &results[bestIndex]
|
||||
}
|
||||
|
||||
// SearchSong searches for a song on Apple Music and returns its ID.
|
||||
func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, error) {
|
||||
func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
|
||||
query := trackName + " " + artistName
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return "", fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
token, err := globalAppleTokenManager.getToken(c.httpClient)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("apple music token error: %w", err)
|
||||
}
|
||||
|
||||
encodedQuery := url.QueryEscape(query)
|
||||
searchURL := fmt.Sprintf(
|
||||
"https://amp-api.music.apple.com/v1/catalog/us/search?term=%s&types=songs&limit=5&l=en-US&platform=web&format[resources]=map&include[songs]=artists&extend=artistUrl",
|
||||
encodedQuery,
|
||||
)
|
||||
searchURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/search?q=%s", encodedQuery)
|
||||
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Origin", "https://music.apple.com")
|
||||
req.Header.Set("Referer", "https://music.apple.com/")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
@@ -185,25 +127,21 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 401 {
|
||||
globalAppleTokenManager.clearToken()
|
||||
return "", fmt.Errorf("apple music token expired")
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp appleMusicSearchResponse
|
||||
var searchResp []appleMusicSearchResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode apple music response: %w", err)
|
||||
}
|
||||
|
||||
if searchResp.Results.Songs == nil || len(searchResp.Results.Songs.Data) == 0 {
|
||||
best := selectBestAppleMusicSearchResult(searchResp, trackName, artistName, durationSec)
|
||||
if best == nil || strings.TrimSpace(best.ID) == "" {
|
||||
return "", fmt.Errorf("no songs found on apple music")
|
||||
}
|
||||
|
||||
return searchResp.Results.Songs.Data[0].ID, nil
|
||||
return strings.TrimSpace(best.ID), nil
|
||||
}
|
||||
|
||||
// FetchLyricsByID fetches lyrics from the paxsenix proxy using Apple Music song ID.
|
||||
@@ -239,15 +177,12 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
|
||||
return bodyStr, nil
|
||||
}
|
||||
|
||||
// formatPaxLyricsToLRC converts a pax proxy response to standard LRC format.
|
||||
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
|
||||
// Try to parse as PaxResponse first
|
||||
var paxResp paxResponse
|
||||
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil && paxResp.Content != nil {
|
||||
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord), nil
|
||||
}
|
||||
|
||||
// Try to parse as a direct list of PaxLyrics
|
||||
var directLyrics []paxLyrics
|
||||
if err := json.Unmarshal([]byte(rawJSON), &directLyrics); err == nil && len(directLyrics) > 0 {
|
||||
return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord), nil
|
||||
@@ -324,7 +259,7 @@ func (c *AppleMusicClient) FetchLyrics(
|
||||
durationSec float64,
|
||||
multiPersonWordByWord bool,
|
||||
) (*LyricsResponse, error) {
|
||||
songID, err := c.SearchSong(trackName, artistName)
|
||||
songID, err := c.SearchSong(trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package gobackend
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -16,7 +18,6 @@ type MusixmatchClient struct {
|
||||
baseURL string
|
||||
}
|
||||
|
||||
// Musixmatch proxy response models
|
||||
type musixmatchSearchResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
SongName string `json:"songName"`
|
||||
@@ -46,102 +47,105 @@ type musixmatchLyricsResponse struct {
|
||||
func NewMusixmatchClient() *MusixmatchClient {
|
||||
return &MusixmatchClient{
|
||||
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||
baseURL: "http://158.180.60.95",
|
||||
baseURL: "https://lyrics.paxsenix.org/musixmatch/lyrics",
|
||||
}
|
||||
}
|
||||
|
||||
// searchAndGetLyrics searches for a song and retrieves its lyrics in one call.
|
||||
// The Musixmatch proxy returns both search result and lyrics in a single response.
|
||||
func (c *MusixmatchClient) searchAndGetLyrics(trackName, artistName string) (*musixmatchSearchResponse, error) {
|
||||
func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, durationSec float64, lyricsType, language string) (string, error) {
|
||||
if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" {
|
||||
return nil, fmt.Errorf("empty track or artist name")
|
||||
return "", fmt.Errorf("empty track or artist name")
|
||||
}
|
||||
|
||||
encodedArtist := url.QueryEscape(artistName)
|
||||
encodedTrack := url.QueryEscape(trackName)
|
||||
|
||||
fullURL := fmt.Sprintf("%s/v2/full?artist=%s&track=%s", c.baseURL, encodedArtist, encodedTrack)
|
||||
params := url.Values{}
|
||||
params.Set("t", trackName)
|
||||
params.Set("a", artistName)
|
||||
params.Set("type", lyricsType)
|
||||
params.Set("format", "lrc")
|
||||
if durationSec > 0 {
|
||||
params.Set("d", fmt.Sprintf("%d", int(math.Round(durationSec))))
|
||||
}
|
||||
if strings.TrimSpace(language) != "" {
|
||||
params.Set("l", strings.ToLower(strings.TrimSpace(language)))
|
||||
}
|
||||
fullURL := c.baseURL + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("musixmatch search failed: %w", err)
|
||||
return "", fmt.Errorf("musixmatch request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read musixmatch response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode)
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
|
||||
return "", fmt.Errorf("musixmatch proxy returned HTTP %d: %s", resp.StatusCode, errMsg)
|
||||
}
|
||||
return "", fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result musixmatchSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode musixmatch response: %w", err)
|
||||
var lrcPayload string
|
||||
if err := json.Unmarshal(body, &lrcPayload); err == nil {
|
||||
lrcPayload = strings.TrimSpace(lrcPayload)
|
||||
if lrcPayload == "" {
|
||||
return "", fmt.Errorf("empty musixmatch lyrics payload")
|
||||
}
|
||||
return lrcPayload, nil
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
|
||||
return "", fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
if trimmed != "" && !strings.HasPrefix(trimmed, "{") {
|
||||
return trimmed, nil
|
||||
}
|
||||
return "", fmt.Errorf("failed to decode musixmatch response")
|
||||
}
|
||||
|
||||
// FetchLyricsInLanguage retrieves lyrics from Musixmatch for a specific language code.
|
||||
func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string) (*LyricsResponse, error) {
|
||||
func (c *MusixmatchClient) FetchLyricsInLanguage(trackName, artistName string, durationSec float64, language string) (*LyricsResponse, error) {
|
||||
lang := strings.ToLower(strings.TrimSpace(language))
|
||||
if songID <= 0 || lang == "" {
|
||||
return nil, fmt.Errorf("invalid song id or language")
|
||||
if lang == "" {
|
||||
return nil, fmt.Errorf("invalid language")
|
||||
}
|
||||
|
||||
fullURL := fmt.Sprintf("%s/v2/full?id=%d&lang=%s", c.baseURL, songID, url.QueryEscape(lang))
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "translate", lang)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("musixmatch language fetch failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("musixmatch language endpoint returned HTTP %d", resp.StatusCode)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result musixmatchSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode musixmatch language response: %w", err)
|
||||
lines := parseSyncedLyrics(lrcText)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
PlainLyrics: plainLyricsFromTimedLines(lines),
|
||||
Provider: "Musixmatch",
|
||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Prefer synced lyrics for selected language
|
||||
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
||||
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
Provider: "Musixmatch",
|
||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to unsynced lyrics for selected language
|
||||
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
||||
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
|
||||
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: result.UnsyncedLyrics.Lyrics,
|
||||
Provider: "Musixmatch",
|
||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||
}, nil
|
||||
}
|
||||
plainLines := plainTextLyricsLines(lrcText)
|
||||
if len(plainLines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: plainLines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: lrcText,
|
||||
Provider: "Musixmatch",
|
||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no lyrics found on musixmatch for language %s", lang)
|
||||
@@ -149,45 +153,39 @@ func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string)
|
||||
|
||||
// FetchLyrics searches Musixmatch and returns parsed LyricsResponse.
|
||||
func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec float64, preferredLanguage string) (*LyricsResponse, error) {
|
||||
result, err := c.searchAndGetLyrics(trackName, artistName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" && result.ID > 0 {
|
||||
localized, localizedErr := c.FetchLyricsInLanguage(result.ID, preferred)
|
||||
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" {
|
||||
localized, localizedErr := c.FetchLyricsInLanguage(trackName, artistName, durationSec, preferred)
|
||||
if localizedErr == nil {
|
||||
return localized, nil
|
||||
}
|
||||
GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr)
|
||||
}
|
||||
|
||||
// Prefer synced lyrics
|
||||
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
||||
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
Provider: "Musixmatch",
|
||||
Source: "Musixmatch",
|
||||
}, nil
|
||||
}
|
||||
lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "word", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Fall back to unsynced lyrics
|
||||
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
||||
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
|
||||
lines := parseSyncedLyrics(lrcText)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
PlainLyrics: plainLyricsFromTimedLines(lines),
|
||||
Provider: "Musixmatch",
|
||||
Source: "Musixmatch",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: result.UnsyncedLyrics.Lyrics,
|
||||
Provider: "Musixmatch",
|
||||
Source: "Musixmatch",
|
||||
}, nil
|
||||
}
|
||||
plainLines := plainTextLyricsLines(lrcText)
|
||||
if len(plainLines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: plainLines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: lrcText,
|
||||
Provider: "Musixmatch",
|
||||
Source: "Musixmatch",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no lyrics found on musixmatch")
|
||||
|
||||
@@ -9,13 +9,11 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// NeteaseClient fetches lyrics from NetEase Cloud Music (music.163.com).
|
||||
// This is a direct public API — no proxy dependency.
|
||||
// NeteaseClient fetches lyrics through Paxsenix's NetEase endpoints.
|
||||
type NeteaseClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Netease API response models
|
||||
type neteaseSearchResponse struct {
|
||||
Result struct {
|
||||
Songs []struct {
|
||||
@@ -60,12 +58,9 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
|
||||
return 0, fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
searchURL := "http://music.163.com/api/search/pc"
|
||||
searchURL := "https://lyrics.paxsenix.org/netease/search"
|
||||
params := url.Values{}
|
||||
params.Set("s", query)
|
||||
params.Set("type", "1")
|
||||
params.Set("limit", "1")
|
||||
params.Set("offset", "0")
|
||||
params.Set("q", query)
|
||||
|
||||
fullURL := searchURL + "?" + params.Encode()
|
||||
|
||||
@@ -103,12 +98,9 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
|
||||
|
||||
// FetchLyricsByID fetches synced lyrics for a given Netease song ID.
|
||||
func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includeRomanization bool) (string, error) {
|
||||
lyricsURL := "http://music.163.com/api/song/lyric"
|
||||
lyricsURL := "https://lyrics.paxsenix.org/netease/lyrics"
|
||||
params := url.Values{}
|
||||
params.Set("id", fmt.Sprintf("%d", songID))
|
||||
params.Set("lv", "1")
|
||||
params.Set("tv", "1")
|
||||
params.Set("rv", "1")
|
||||
|
||||
fullURL := lyricsURL + "?" + params.Encode()
|
||||
|
||||
@@ -172,7 +164,6 @@ func (c *NeteaseClient) FetchLyrics(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the LRC text into LyricsResponse
|
||||
lines := parseSyncedLyrics(lrcText)
|
||||
if len(lines) == 0 {
|
||||
// May be plain text lyrics without timestamps
|
||||
|
||||
@@ -1,46 +1,31 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// QQMusicClient fetches lyrics from QQ Music.
|
||||
// Search uses public QQ Music API, lyrics use the paxsenix proxy.
|
||||
// Uses Paxsenix metadata lookup for lyrics.
|
||||
type QQMusicClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// QQ Music search response models
|
||||
type qqMusicSearchResponse struct {
|
||||
Data struct {
|
||||
Song struct {
|
||||
List []struct {
|
||||
Title string `json:"title"`
|
||||
Singer []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"singer"`
|
||||
Album struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"album"`
|
||||
ID int64 `json:"id"`
|
||||
} `json:"list"`
|
||||
} `json:"song"`
|
||||
} `json:"data"`
|
||||
type qqLyricsMetadataRequest struct {
|
||||
Artist []string `json:"artist"`
|
||||
Album string `json:"album,omitempty"`
|
||||
SongID int64 `json:"songid,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Duration int64 `json:"duration,omitempty"`
|
||||
}
|
||||
|
||||
// QQ Music lyrics request payload for paxsenix proxy
|
||||
type qqLyricsPayload struct {
|
||||
Artist []string `json:"artist"`
|
||||
Album string `json:"album"`
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
type qqLyricsMetadataResponse struct {
|
||||
Lyrics []paxLyrics `json:"lyrics"`
|
||||
}
|
||||
|
||||
func NewQQMusicClient() *QQMusicClient {
|
||||
@@ -49,79 +34,29 @@ func NewQQMusicClient() *QQMusicClient {
|
||||
}
|
||||
}
|
||||
|
||||
// searchSong searches QQ Music and returns the song info needed for lyrics fetch.
|
||||
func (c *QQMusicClient) searchSong(trackName, artistName string) (*qqLyricsPayload, error) {
|
||||
query := trackName + " " + artistName
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return nil, fmt.Errorf("empty search query")
|
||||
// fetchLyricsByMetadata asks Paxsenix to resolve and return QQ lyrics using track metadata.
|
||||
func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, durationSec float64) (string, error) {
|
||||
payload := qqLyricsMetadataRequest{
|
||||
Artist: []string{artistName},
|
||||
Title: trackName,
|
||||
}
|
||||
if durationSec > 0 {
|
||||
payload.Duration = int64(math.Round(durationSec))
|
||||
}
|
||||
|
||||
searchURL := "https://c.y.qq.com/soso/fcgi-bin/client_search_cp"
|
||||
params := url.Values{}
|
||||
params.Set("format", "json")
|
||||
params.Set("inCharset", "utf8")
|
||||
params.Set("outCharset", "utf8")
|
||||
params.Set("platform", "yqq.json")
|
||||
params.Set("new_json", "1")
|
||||
params.Set("w", query)
|
||||
|
||||
fullURL := searchURL + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qqmusic search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("qqmusic search returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp qqMusicSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode qqmusic response: %w", err)
|
||||
}
|
||||
|
||||
if len(searchResp.Data.Song.List) == 0 {
|
||||
return nil, fmt.Errorf("no songs found on qqmusic")
|
||||
}
|
||||
|
||||
song := searchResp.Data.Song.List[0]
|
||||
|
||||
var artists []string
|
||||
for _, singer := range song.Singer {
|
||||
artists = append(artists, singer.Name)
|
||||
}
|
||||
|
||||
return &qqLyricsPayload{
|
||||
Artist: artists,
|
||||
Album: song.Album.Name,
|
||||
ID: song.ID,
|
||||
Title: song.Title,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// fetchLyricsByPayload fetches lyrics from the paxsenix proxy using QQ Music song info.
|
||||
func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string, error) {
|
||||
lyricsURL := "https://paxsenix.alwaysdata.net/getQQLyrics.php"
|
||||
lyricsURL := "https://lyrics.paxsenix.org/qq/lyrics-metadata"
|
||||
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", lyricsURL, bytes.NewReader(payloadBytes))
|
||||
req, err := http.NewRequest("POST", lyricsURL, strings.NewReader(string(payloadBytes)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
@@ -147,6 +82,17 @@ func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string,
|
||||
return bodyStr, nil
|
||||
}
|
||||
|
||||
func formatQQLyricsMetadataToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
|
||||
var response qqLyricsMetadataResponse
|
||||
if err := json.Unmarshal([]byte(rawJSON), &response); err != nil {
|
||||
return "", fmt.Errorf("failed to parse qq metadata lyrics response")
|
||||
}
|
||||
if len(response.Lyrics) == 0 {
|
||||
return "", fmt.Errorf("qq metadata lyrics response was empty")
|
||||
}
|
||||
return formatPaxContent("Syllable", response.Lyrics, multiPersonWordByWord), nil
|
||||
}
|
||||
|
||||
// FetchLyrics searches QQ Music and returns parsed LyricsResponse.
|
||||
func (c *QQMusicClient) FetchLyrics(
|
||||
trackName,
|
||||
@@ -154,12 +100,7 @@ func (c *QQMusicClient) FetchLyrics(
|
||||
durationSec float64,
|
||||
multiPersonWordByWord bool,
|
||||
) (*LyricsResponse, error) {
|
||||
payload, err := c.searchSong(trackName, artistName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rawLyrics, err := c.fetchLyricsByPayload(payload)
|
||||
rawLyrics, err := c.fetchLyricsByMetadata(trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -167,11 +108,13 @@ func (c *QQMusicClient) FetchLyrics(
|
||||
return nil, fmt.Errorf("qqmusic proxy returned non-lyric payload: %s", errMsg)
|
||||
}
|
||||
|
||||
// Try to parse as pax format (word-by-word or line)
|
||||
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
|
||||
lrcText, err := formatQQLyricsMetadataToLRC(rawLyrics, multiPersonWordByWord)
|
||||
if err != nil {
|
||||
// If pax parsing fails, try to use as direct LRC text
|
||||
lrcText = rawLyrics
|
||||
if fallback, fallbackErr := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord); fallbackErr == nil {
|
||||
lrcText = fallback
|
||||
} else {
|
||||
lrcText = rawLyrics
|
||||
}
|
||||
}
|
||||
|
||||
lines := parseSyncedLyrics(lrcText)
|
||||
@@ -184,7 +127,6 @@ func (c *QQMusicClient) FetchLyrics(
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Fall back to plain text
|
||||
resultLines := plainTextLyricsLines(lrcText)
|
||||
|
||||
if len(resultLines) > 0 {
|
||||
|
||||
+320
-6
@@ -552,6 +552,14 @@ func ExtractLyrics(filePath string) (string, error) {
|
||||
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") {
|
||||
meta, err := ReadID3Tags(filePath)
|
||||
if err == nil && meta != nil {
|
||||
@@ -581,6 +589,299 @@ func ExtractLyrics(filePath string) (string, error) {
|
||||
return extractLyricsFromSidecarLRC(filePath)
|
||||
}
|
||||
|
||||
func ReadM4ATags(filePath string) (*AudioMetadata, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ilst, err := findM4AIlstAtom(f, fi.Size())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
metadata := &AudioMetadata{}
|
||||
start := ilst.offset + ilst.headerSize
|
||||
end := ilst.offset + ilst.size
|
||||
for pos := start; pos+8 <= end; {
|
||||
header, err := readAtomHeaderAt(f, pos, fi.Size())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if header.size == 0 {
|
||||
header.size = end - pos
|
||||
}
|
||||
if header.size < header.headerSize {
|
||||
return nil, fmt.Errorf("invalid atom size for %s", header.typ)
|
||||
}
|
||||
|
||||
switch header.typ {
|
||||
case "\xa9nam":
|
||||
metadata.Title, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "\xa9ART":
|
||||
metadata.Artist, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "\xa9alb":
|
||||
metadata.Album, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "aART":
|
||||
metadata.AlbumArtist, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "\xa9day":
|
||||
metadata.Date, _ = readM4ATextValue(f, header, fi.Size())
|
||||
metadata.Year = metadata.Date
|
||||
case "\xa9gen":
|
||||
metadata.Genre, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "\xa9wrt":
|
||||
metadata.Composer, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "\xa9cmt":
|
||||
metadata.Comment, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "cprt":
|
||||
metadata.Copyright, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "\xa9lyr":
|
||||
metadata.Lyrics, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "trkn":
|
||||
metadata.TrackNumber, _ = readM4AIndexValue(f, header, fi.Size())
|
||||
case "disk":
|
||||
metadata.DiscNumber, _ = readM4AIndexValue(f, header, fi.Size())
|
||||
case "----":
|
||||
name, value, freeformErr := readM4AFreeformValue(f, header, fi.Size())
|
||||
if freeformErr == nil {
|
||||
switch strings.ToUpper(strings.TrimSpace(name)) {
|
||||
case "ISRC":
|
||||
metadata.ISRC = value
|
||||
case "LABEL", "ORGANIZATION":
|
||||
metadata.Label = value
|
||||
case "COMMENT":
|
||||
if metadata.Comment == "" {
|
||||
metadata.Comment = value
|
||||
}
|
||||
case "COMPOSER":
|
||||
if metadata.Composer == "" {
|
||||
metadata.Composer = value
|
||||
}
|
||||
case "COPYRIGHT":
|
||||
if metadata.Copyright == "" {
|
||||
metadata.Copyright = value
|
||||
}
|
||||
case "LYRICS", "UNSYNCEDLYRICS":
|
||||
if metadata.Lyrics == "" {
|
||||
metadata.Lyrics = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pos += header.size
|
||||
}
|
||||
|
||||
if metadata.Title == "" &&
|
||||
metadata.Artist == "" &&
|
||||
metadata.Album == "" &&
|
||||
metadata.AlbumArtist == "" &&
|
||||
metadata.Lyrics == "" &&
|
||||
metadata.TrackNumber == 0 &&
|
||||
metadata.DiscNumber == 0 {
|
||||
return nil, fmt.Errorf("no M4A tags found")
|
||||
}
|
||||
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func extractLyricsFromM4A(filePath string) (string, error) {
|
||||
metadata, err := ReadM4ATags(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if metadata == nil || strings.TrimSpace(metadata.Lyrics) == "" {
|
||||
return "", fmt.Errorf("no lyrics found in file")
|
||||
}
|
||||
return metadata.Lyrics, nil
|
||||
}
|
||||
|
||||
func extractCoverFromM4A(filePath string) ([]byte, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fileSize := fi.Size()
|
||||
|
||||
ilst, err := findM4AIlstAtom(f, fileSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bodyStart := ilst.offset + ilst.headerSize
|
||||
bodySize := ilst.size - ilst.headerSize
|
||||
|
||||
covr, found, err := findAtomInRange(f, bodyStart, bodySize, "covr", fileSize)
|
||||
if err != nil || !found {
|
||||
return nil, fmt.Errorf("cover atom not found")
|
||||
}
|
||||
|
||||
dataStart := covr.offset + covr.headerSize
|
||||
dataSize := covr.size - covr.headerSize
|
||||
|
||||
dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize)
|
||||
if err != nil || !found {
|
||||
return nil, fmt.Errorf("data atom not found in cover")
|
||||
}
|
||||
|
||||
// data atom: header + 4 bytes type indicator + 4 bytes locale
|
||||
imgStart := dataAtom.offset + dataAtom.headerSize + 8
|
||||
imgLen := dataAtom.size - dataAtom.headerSize - 8
|
||||
if imgLen <= 0 {
|
||||
return nil, fmt.Errorf("empty cover data")
|
||||
}
|
||||
|
||||
buf := make([]byte, imgLen)
|
||||
if _, err := f.ReadAt(buf, imgStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// findM4AIlstAtom locates the ilst atom that holds all iTunes-style tags.
|
||||
// It tries two common layouts:
|
||||
// 1. moov > udta > meta > ilst (iTunes, FFmpeg default)
|
||||
// 2. moov > meta > ilst (some encoders omit the udta wrapper)
|
||||
func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) {
|
||||
moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
|
||||
if err != nil || !found {
|
||||
return atomHeader{}, fmt.Errorf("moov not found")
|
||||
}
|
||||
|
||||
moovBodyStart := moov.offset + moov.headerSize
|
||||
moovBodySize := moov.size - moov.headerSize
|
||||
|
||||
// Path 1: moov > udta > meta > ilst
|
||||
if udta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "udta", fileSize); ok {
|
||||
udtaBodyStart := udta.offset + udta.headerSize
|
||||
udtaBodySize := udta.size - udta.headerSize
|
||||
if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 {
|
||||
metaBodyStart := meta.offset + meta.headerSize + 4
|
||||
metaBodySize := meta.size - meta.headerSize - 4
|
||||
if ilst, ok3, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok3 {
|
||||
return ilst, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Path 2: moov > meta > ilst (no udta wrapper)
|
||||
if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok {
|
||||
metaBodyStart := meta.offset + meta.headerSize + 4
|
||||
metaBodySize := meta.size - meta.headerSize - 4
|
||||
if ilst, ok2, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok2 {
|
||||
return ilst, nil
|
||||
}
|
||||
}
|
||||
|
||||
return atomHeader{}, fmt.Errorf("ilst not found (tried moov>udta>meta>ilst and moov>meta>ilst)")
|
||||
}
|
||||
|
||||
func readM4ADataAtomPayload(f *os.File, dataAtom atomHeader) ([]byte, error) {
|
||||
payloadStart := dataAtom.offset + dataAtom.headerSize + 8
|
||||
payloadLen := dataAtom.size - dataAtom.headerSize - 8
|
||||
if payloadLen <= 0 {
|
||||
return nil, fmt.Errorf("empty data atom in %s", dataAtom.typ)
|
||||
}
|
||||
|
||||
buf := make([]byte, payloadLen)
|
||||
if _, err := f.ReadAt(buf, payloadStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func readM4ADataPayload(f *os.File, parent atomHeader, fileSize int64) ([]byte, error) {
|
||||
dataStart := parent.offset + parent.headerSize
|
||||
dataSize := parent.size - parent.headerSize
|
||||
|
||||
dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize)
|
||||
if err != nil || !found {
|
||||
return nil, fmt.Errorf("data atom not found in %s", parent.typ)
|
||||
}
|
||||
return readM4ADataAtomPayload(f, dataAtom)
|
||||
}
|
||||
|
||||
func readM4ATextValue(f *os.File, parent atomHeader, fileSize int64) (string, error) {
|
||||
payload, err := readM4ADataPayload(f, parent, fileSize)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(strings.TrimRight(string(payload), "\x00")), nil
|
||||
}
|
||||
|
||||
func readM4AIndexValue(f *os.File, parent atomHeader, fileSize int64) (int, error) {
|
||||
payload, err := readM4ADataPayload(f, parent, fileSize)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(payload) < 4 {
|
||||
return 0, fmt.Errorf("index payload too short in %s", parent.typ)
|
||||
}
|
||||
return int(binary.BigEndian.Uint16(payload[2:4])), nil
|
||||
}
|
||||
|
||||
func readM4AFreeformValue(f *os.File, parent atomHeader, fileSize int64) (string, string, error) {
|
||||
start := parent.offset + parent.headerSize
|
||||
end := parent.offset + parent.size
|
||||
|
||||
var nameValue string
|
||||
var dataValue string
|
||||
for pos := start; pos+8 <= end; {
|
||||
header, err := readAtomHeaderAt(f, pos, fileSize)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if header.size == 0 {
|
||||
header.size = end - pos
|
||||
}
|
||||
if header.size < header.headerSize {
|
||||
return "", "", fmt.Errorf("invalid atom size for %s", header.typ)
|
||||
}
|
||||
|
||||
switch header.typ {
|
||||
case "mean":
|
||||
// Domain qualifier (e.g. "com.apple.iTunes") — not needed, skip.
|
||||
case "name":
|
||||
// The "name" atom payload is: 4-byte version/flags, then raw UTF-8 text.
|
||||
// It does NOT contain a nested "data" atom, so read the payload directly.
|
||||
payloadStart := header.offset + header.headerSize + 4
|
||||
payloadLen := header.size - header.headerSize - 4
|
||||
if payloadLen > 0 {
|
||||
buf := make([]byte, payloadLen)
|
||||
if _, readErr := f.ReadAt(buf, payloadStart); readErr == nil {
|
||||
nameValue = strings.TrimSpace(strings.TrimRight(string(buf), "\x00"))
|
||||
}
|
||||
}
|
||||
case "data":
|
||||
payload, payloadErr := readM4ADataAtomPayload(f, header)
|
||||
if payloadErr == nil {
|
||||
dataValue = strings.TrimSpace(strings.TrimRight(string(payload), "\x00"))
|
||||
}
|
||||
}
|
||||
|
||||
pos += header.size
|
||||
}
|
||||
|
||||
if nameValue == "" || dataValue == "" {
|
||||
return "", "", fmt.Errorf("freeform M4A tag incomplete")
|
||||
}
|
||||
|
||||
return nameValue, dataValue, nil
|
||||
}
|
||||
|
||||
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
|
||||
ext := filepath.Ext(filePath)
|
||||
base := strings.TrimSuffix(filePath, ext)
|
||||
@@ -743,15 +1044,28 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
|
||||
return AudioQuality{}, err
|
||||
}
|
||||
|
||||
buf := make([]byte, 24)
|
||||
buf := make([]byte, 32)
|
||||
if _, err := f.ReadAt(buf, sampleOffset); err != nil {
|
||||
return AudioQuality{}, fmt.Errorf("failed to read audio sample entry: %w", err)
|
||||
}
|
||||
|
||||
sampleRate := int(buf[22])<<8 | int(buf[23])
|
||||
bitDepth := 16
|
||||
if atomType == "alac" {
|
||||
bitDepth = 24
|
||||
// AudioSampleEntry layout from the box type field:
|
||||
// [0:4] type ("mp4a"/"alac")
|
||||
// [4:10] SampleEntry.reserved
|
||||
// [10:12] data_reference_index
|
||||
// [12:20] reserved[8]
|
||||
// [20:22] channelcount
|
||||
// [22:24] samplesize (bit depth)
|
||||
// [24:26] pre_defined
|
||||
// [26:28] reserved
|
||||
// [28:32] samplerate (16.16 fixed-point)
|
||||
sampleRate := int(buf[28])<<8 | int(buf[29])
|
||||
bitDepth := int(buf[22])<<8 | int(buf[23])
|
||||
if bitDepth <= 0 {
|
||||
bitDepth = 16
|
||||
if atomType == "alac" {
|
||||
bitDepth = 24
|
||||
}
|
||||
}
|
||||
|
||||
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
|
||||
@@ -874,7 +1188,7 @@ func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string
|
||||
|
||||
if bestIdx >= 0 {
|
||||
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 absolute, bestType, nil
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// mobile_deps.go
|
||||
// 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.
|
||||
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
// Required for gomobile bind to work
|
||||
_ "golang.org/x/mobile/bind"
|
||||
)
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
type TrackIDCacheEntry struct {
|
||||
TidalTrackID int64
|
||||
QobuzTrackID int64
|
||||
AmazonURL string
|
||||
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() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -235,8 +215,6 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
||||
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
|
||||
case "qobuz":
|
||||
preWarmQobuzCache(r.ISRC, r.SpotifyID)
|
||||
case "amazon":
|
||||
preWarmAmazonCache(r.ISRC, r.SpotifyID)
|
||||
}
|
||||
}(req)
|
||||
}
|
||||
@@ -256,12 +234,10 @@ func preWarmTidalCache(isrc, _, _ string) {
|
||||
// 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)
|
||||
func preWarmQobuzCache(isrc, spotifyID string) {
|
||||
// First, try to get QobuzID from SongLink - this is faster and more reliable
|
||||
if spotifyID != "" {
|
||||
client := NewSongLinkClient()
|
||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||
if err == nil && availability != nil && availability.QobuzID != "" {
|
||||
// Parse QobuzID to int64
|
||||
var trackID int64
|
||||
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)
|
||||
@@ -271,7 +247,6 @@ func preWarmQobuzCache(isrc, spotifyID string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Direct ISRC search on Qobuz API
|
||||
downloader := NewQobuzDownloader()
|
||||
track, err := downloader.SearchTrackByISRC(isrc)
|
||||
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 {
|
||||
var tracks []struct {
|
||||
ISRC string `json:"isrc"`
|
||||
|
||||
+31
-4
@@ -34,10 +34,16 @@ var (
|
||||
downloadDir string
|
||||
downloadDirMu sync.RWMutex
|
||||
|
||||
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
||||
multiMu sync.RWMutex
|
||||
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
||||
multiMu sync.RWMutex
|
||||
multiProgressDirty = true
|
||||
cachedMultiProgress = "{\"items\":{}}"
|
||||
)
|
||||
|
||||
func markMultiProgressDirtyLocked() {
|
||||
multiProgressDirty = true
|
||||
}
|
||||
|
||||
func getProgress() DownloadProgress {
|
||||
multiMu.RLock()
|
||||
defer multiMu.RUnlock()
|
||||
@@ -58,13 +64,25 @@ func getProgress() DownloadProgress {
|
||||
|
||||
func GetMultiProgress() string {
|
||||
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)
|
||||
if err != nil {
|
||||
return "{\"items\":{}}"
|
||||
}
|
||||
return string(jsonBytes)
|
||||
cachedMultiProgress = string(jsonBytes)
|
||||
multiProgressDirty = false
|
||||
return cachedMultiProgress
|
||||
}
|
||||
|
||||
func GetItemProgress(itemID string) string {
|
||||
@@ -90,6 +108,7 @@ func StartItemProgress(itemID string) {
|
||||
IsDownloading: true,
|
||||
Status: "downloading",
|
||||
}
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
|
||||
func SetItemBytesTotal(itemID string, total int64) {
|
||||
@@ -98,6 +117,7 @@ func SetItemBytesTotal(itemID string, total int64) {
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
item.BytesTotal = total
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +130,7 @@ func SetItemBytesReceived(itemID string, received int64) {
|
||||
if item.BytesTotal > 0 {
|
||||
item.Progress = float64(received) / float64(item.BytesTotal)
|
||||
}
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +144,7 @@ func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps floa
|
||||
if item.BytesTotal > 0 {
|
||||
item.Progress = float64(received) / float64(item.BytesTotal)
|
||||
}
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +156,7 @@ func CompleteItemProgress(itemID string) {
|
||||
item.Progress = 1.0
|
||||
item.IsDownloading = false
|
||||
item.Status = "completed"
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +172,7 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal
|
||||
if bytesTotal > 0 {
|
||||
item.BytesTotal = bytesTotal
|
||||
}
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +183,7 @@ func SetItemFinalizing(itemID string) {
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
item.Progress = 1.0
|
||||
item.Status = "finalizing"
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +192,7 @@ func RemoveItemProgress(itemID string) {
|
||||
defer multiMu.Unlock()
|
||||
|
||||
delete(multiProgress.Items, itemID)
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
|
||||
func ClearAllItemProgress() {
|
||||
@@ -174,6 +200,7 @@ func ClearAllItemProgress() {
|
||||
defer multiMu.Unlock()
|
||||
|
||||
multiProgress.Items = make(map[string]*ItemProgress)
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
|
||||
func setDownloadDir(path string) error {
|
||||
|
||||
+1091
-142
File diff suppressed because it is too large
Load Diff
+323
-3
@@ -1,6 +1,98 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseQobuzURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantType string
|
||||
wantID string
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
name: "store album url",
|
||||
input: "https://www.qobuz.com/us-en/album/harry-styles-harry-styles/0886446451985",
|
||||
wantType: "album",
|
||||
wantID: "0886446451985",
|
||||
},
|
||||
{
|
||||
name: "store playlist url",
|
||||
input: "https://www.qobuz.com/us-en/playlists/new-releases/2049430",
|
||||
wantType: "playlist",
|
||||
wantID: "2049430",
|
||||
},
|
||||
{
|
||||
name: "store artist url",
|
||||
input: "https://www.qobuz.com/us-en/interpreter/harry-styles/729886",
|
||||
wantType: "artist",
|
||||
wantID: "729886",
|
||||
},
|
||||
{
|
||||
name: "play track url",
|
||||
input: "https://play.qobuz.com/track/40681594",
|
||||
wantType: "track",
|
||||
wantID: "40681594",
|
||||
},
|
||||
{
|
||||
name: "custom scheme playlist url",
|
||||
input: "qobuzapp://playlist/2049430",
|
||||
wantType: "playlist",
|
||||
wantID: "2049430",
|
||||
},
|
||||
{
|
||||
name: "unsupported url",
|
||||
input: "https://example.com/not-qobuz",
|
||||
expectErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
gotType, gotID, err := parseQobuzURL(test.input)
|
||||
if test.expectErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if gotType != test.wantType || gotID != test.wantID {
|
||||
t.Fatalf("parseQobuzURL(%q) = (%q, %q), want (%q, %q)", test.input, gotType, gotID, test.wantType, test.wantID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractQobuzArtistAlbumIDs(t *testing.T) {
|
||||
body := []byte(`
|
||||
<div class="product__item">
|
||||
<button data-itemtype="album" data-itemId="yrpbt0lwm3g0y"></button>
|
||||
</div>
|
||||
<div class="product__item">
|
||||
<button data-itemtype="album" data-itemId="yrpbt0lwm3g0y"></button>
|
||||
</div>
|
||||
<div class="product__item">
|
||||
<button data-itemtype="album" data-itemId="0886446451985"></button>
|
||||
</div>
|
||||
`)
|
||||
|
||||
matches := qobuzArtistAlbumIDRegex.FindAllSubmatch(body, -1)
|
||||
if len(matches) != 3 {
|
||||
t.Fatalf("expected 3 regex matches, got %d", len(matches))
|
||||
}
|
||||
if string(matches[0][1]) != "yrpbt0lwm3g0y" {
|
||||
t.Fatalf("unexpected first album id: %q", matches[0][1])
|
||||
}
|
||||
if string(matches[2][1]) != "0886446451985" {
|
||||
t.Fatalf("unexpected last album id: %q", matches[2][1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractQobuzDownloadURLFromBody(t *testing.T) {
|
||||
t.Run("reads top-level download_url and quality metadata", func(t *testing.T) {
|
||||
@@ -106,16 +198,56 @@ func TestGetQobuzDebugKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQobuzMusicDLPayloadUsesOpenTrackURL(t *testing.T) {
|
||||
payloadBytes, err := buildQobuzMusicDLPayload(374610875, "7")
|
||||
if err != nil {
|
||||
t.Fatalf("buildQobuzMusicDLPayload returned error: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
|
||||
t.Fatalf("payload is not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
if got := payload["url"]; got != "https://open.qobuz.com/track/374610875" {
|
||||
t.Fatalf("payload url = %v, want open.qobuz.com track URL", got)
|
||||
}
|
||||
if got := payload["quality"]; got != "hi-res" {
|
||||
t.Fatalf("payload quality = %v, want hi-res", got)
|
||||
}
|
||||
if got := payload["upload_to_r2"]; got != false {
|
||||
t.Fatalf("payload upload_to_r2 = %v, want false", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) {
|
||||
body := []byte(`
|
||||
<button data-itemtype="album" data-itemId="0886446451985"></button>
|
||||
<button data-itemtype="album" data-itemId="0886446451985"></button>
|
||||
<button data-itemtype="album" data-itemId="pvv406bth40ya"></button>
|
||||
`)
|
||||
|
||||
got := extractQobuzAlbumIDsFromArtistHTML(body)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 unique album IDs, got %d (%v)", len(got), got)
|
||||
}
|
||||
if got[0] != "0886446451985" || got[1] != "pvv406bth40ya" {
|
||||
t.Fatalf("unexpected album IDs: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQobuzAvailableProviders(t *testing.T) {
|
||||
providers := NewQobuzDownloader().GetAvailableProviders()
|
||||
if len(providers) != 3 {
|
||||
t.Fatalf("expected 3 Qobuz providers, got %d", len(providers))
|
||||
if len(providers) != 5 {
|
||||
t.Fatalf("expected 5 Qobuz providers, got %d", len(providers))
|
||||
}
|
||||
|
||||
want := map[string]string{
|
||||
"musicdl": qobuzAPIKindMusicDL,
|
||||
"dabmusic": qobuzAPIKindStandard,
|
||||
"deeb": qobuzAPIKindStandard,
|
||||
"qbz": qobuzAPIKindStandard,
|
||||
"squid": qobuzAPIKindStandard,
|
||||
}
|
||||
|
||||
for _, provider := range providers {
|
||||
@@ -133,3 +265,191 @@ func TestQobuzAvailableProviders(t *testing.T) {
|
||||
t.Fatalf("missing providers: %v", want)
|
||||
}
|
||||
}
|
||||
|
||||
func testQobuzTrack(id int64, title, artist string, duration int) *QobuzTrack {
|
||||
track := &QobuzTrack{
|
||||
ID: id,
|
||||
Title: title,
|
||||
Duration: duration,
|
||||
}
|
||||
track.Performer.Name = artist
|
||||
return track
|
||||
}
|
||||
|
||||
func TestResolveQobuzTrackForRequestRejectsSongLinkMismatch(t *testing.T) {
|
||||
origGetTrackByID := qobuzGetTrackByIDFunc
|
||||
origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
|
||||
origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc
|
||||
origSongLinkCheck := songLinkCheckTrackAvailabilityFunc
|
||||
t.Cleanup(func() {
|
||||
qobuzGetTrackByIDFunc = origGetTrackByID
|
||||
qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC
|
||||
qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata
|
||||
songLinkCheckTrackAvailabilityFunc = origSongLinkCheck
|
||||
GetTrackIDCache().Clear()
|
||||
})
|
||||
GetTrackIDCache().Clear()
|
||||
|
||||
qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) {
|
||||
if trackID != 111 {
|
||||
t.Fatalf("unexpected track ID lookup: %d", trackID)
|
||||
}
|
||||
return testQobuzTrack(111, "Aperture", "Harry Styles", 180), nil
|
||||
}
|
||||
qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||
if isrc != "TESTISRC1" {
|
||||
t.Fatalf("unexpected ISRC lookup: %q", isrc)
|
||||
}
|
||||
if expectedDurationSec != 180 {
|
||||
t.Fatalf("unexpected duration: %d", expectedDurationSec)
|
||||
}
|
||||
return testQobuzTrack(222, "Taste Back", "Harry Styles", 180), nil
|
||||
}
|
||||
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, _, _ string, _ int) (*QobuzTrack, error) {
|
||||
t.Fatal("metadata fallback should not run when ISRC fallback succeeds")
|
||||
return nil, nil
|
||||
}
|
||||
songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||
if spotifyTrackID != "spotify-track-id" {
|
||||
t.Fatalf("unexpected spotify ID: %q", spotifyTrackID)
|
||||
}
|
||||
if isrc != "TESTISRC1" {
|
||||
t.Fatalf("unexpected SongLink ISRC: %q", isrc)
|
||||
}
|
||||
return &TrackAvailability{QobuzID: "111"}, nil
|
||||
}
|
||||
|
||||
req := DownloadRequest{
|
||||
ISRC: "TESTISRC1",
|
||||
SpotifyID: "spotify-track-id",
|
||||
TrackName: "Taste Back",
|
||||
ArtistName: "Harry Styles",
|
||||
DurationMS: 180000,
|
||||
}
|
||||
|
||||
track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if track == nil || track.ID != 222 || track.Title != "Taste Back" {
|
||||
t.Fatalf("unexpected resolved track: %+v", track)
|
||||
}
|
||||
|
||||
cached := GetTrackIDCache().Get(req.ISRC)
|
||||
if cached == nil || cached.QobuzTrackID != 222 {
|
||||
t.Fatalf("expected validated fallback track to be cached, got %+v", cached)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveQobuzTrackForRequestRejectsOdesliMismatch(t *testing.T) {
|
||||
origGetTrackByID := qobuzGetTrackByIDFunc
|
||||
origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
|
||||
origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc
|
||||
origSongLinkCheck := songLinkCheckTrackAvailabilityFunc
|
||||
t.Cleanup(func() {
|
||||
qobuzGetTrackByIDFunc = origGetTrackByID
|
||||
qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC
|
||||
qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata
|
||||
songLinkCheckTrackAvailabilityFunc = origSongLinkCheck
|
||||
})
|
||||
|
||||
qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) {
|
||||
if trackID != 333 {
|
||||
t.Fatalf("unexpected track ID lookup: %d", trackID)
|
||||
}
|
||||
return testQobuzTrack(333, "American Girls", "Harry Styles", 181), nil
|
||||
}
|
||||
qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, _ string, _ int) (*QobuzTrack, error) {
|
||||
t.Fatal("ISRC fallback should not run without an ISRC")
|
||||
return nil, nil
|
||||
}
|
||||
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||
if trackName != "Taste Back" || artistName != "Harry Styles" || expectedDurationSec != 181 {
|
||||
t.Fatalf("unexpected metadata fallback arguments: %q / %q / %d", trackName, artistName, expectedDurationSec)
|
||||
}
|
||||
return testQobuzTrack(444, "Taste Back", "Harry Styles", 181), nil
|
||||
}
|
||||
songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, _, _ string) (*TrackAvailability, error) {
|
||||
t.Fatal("SongLink should not run when Odesli QobuzID is provided")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
req := DownloadRequest{
|
||||
QobuzID: "333",
|
||||
TrackName: "Taste Back",
|
||||
ArtistName: "Harry Styles",
|
||||
DurationMS: 181000,
|
||||
}
|
||||
|
||||
track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if track == nil || track.ID != 444 || track.Title != "Taste Back" {
|
||||
t.Fatalf("unexpected resolved track: %+v", track)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveQobuzTrackForRequestUsesPrefixedQobuzIDWithoutSongLink(t *testing.T) {
|
||||
origGetTrackByID := qobuzGetTrackByIDFunc
|
||||
origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
|
||||
origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc
|
||||
origSongLinkCheck := songLinkCheckTrackAvailabilityFunc
|
||||
t.Cleanup(func() {
|
||||
qobuzGetTrackByIDFunc = origGetTrackByID
|
||||
qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC
|
||||
qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata
|
||||
songLinkCheckTrackAvailabilityFunc = origSongLinkCheck
|
||||
})
|
||||
|
||||
qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) {
|
||||
if trackID != 40681594 {
|
||||
t.Fatalf("unexpected track ID lookup: %d", trackID)
|
||||
}
|
||||
return testQobuzTrack(40681594, "Sign of the Times", "Harry Styles", 341), nil
|
||||
}
|
||||
qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, _ string, _ int) (*QobuzTrack, error) {
|
||||
t.Fatal("ISRC fallback should not run when request qobuz id succeeds")
|
||||
return nil, nil
|
||||
}
|
||||
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, _, _ string, _ int) (*QobuzTrack, error) {
|
||||
t.Fatal("metadata fallback should not run when request qobuz id succeeds")
|
||||
return nil, nil
|
||||
}
|
||||
songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, _, _ string) (*TrackAvailability, error) {
|
||||
t.Fatal("SongLink should not run when request qobuz id is provided")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
req := DownloadRequest{
|
||||
QobuzID: "qobuz:40681594",
|
||||
TrackName: "Sign of the Times",
|
||||
ArtistName: "Harry Styles",
|
||||
DurationMS: 341000,
|
||||
}
|
||||
|
||||
track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if track == nil || track.ID != 40681594 {
|
||||
t.Fatalf("unexpected resolved track: %+v", track)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQobuzTrackMatchesRequest_SongLinkBypassesArtistAndTitle(t *testing.T) {
|
||||
req := DownloadRequest{
|
||||
TrackName: "Ringišpil",
|
||||
ArtistName: "Djordje Balasevic",
|
||||
}
|
||||
|
||||
track := &QobuzTrack{
|
||||
Title: "Different Title",
|
||||
Duration: 0,
|
||||
}
|
||||
track.Performer.Name = "Different Artist"
|
||||
|
||||
if !qobuzTrackMatchesRequest(req, track, "Qobuz", "SongLink Qobuz ID", true) {
|
||||
t.Fatal("expected SongLink Qobuz source to bypass artist/title verification")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,6 @@ func (r *RateLimiter) WaitForSlot() {
|
||||
r.timestamps = append(r.timestamps, time.Now())
|
||||
}
|
||||
|
||||
// cleanOldTimestamps removes timestamps that are outside the current window
|
||||
func (r *RateLimiter) cleanOldTimestamps(now time.Time) {
|
||||
cutoff := now.Add(-r.window)
|
||||
validStart := 0
|
||||
|
||||
@@ -170,11 +170,9 @@ func JapaneseToRomaji(text string) string {
|
||||
}
|
||||
|
||||
func BuildSearchQuery(trackName, artistName string) string {
|
||||
// Convert Japanese to romaji
|
||||
trackRomaji := JapaneseToRomaji(trackName)
|
||||
artistRomaji := JapaneseToRomaji(artistName)
|
||||
|
||||
// Clean up the query - remove special characters that might interfere with search
|
||||
trackClean := cleanSearchQuery(trackRomaji)
|
||||
artistClean := cleanSearchQuery(artistRomaji)
|
||||
|
||||
@@ -196,16 +194,13 @@ func cleanSearchQuery(s string) string {
|
||||
func CleanToASCII(s string) string {
|
||||
var result strings.Builder
|
||||
for _, r := range s {
|
||||
// Keep only ASCII letters, numbers, spaces, and basic punctuation
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||
(r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' {
|
||||
result.WriteRune(r)
|
||||
} else if r == ',' || r == '.' {
|
||||
// Convert punctuation to space
|
||||
result.WriteRune(' ')
|
||||
}
|
||||
}
|
||||
// Clean up multiple spaces
|
||||
cleaned := strings.Join(strings.Fields(result.String()), " ")
|
||||
return strings.TrimSpace(cleaned)
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeSensitiveLogText(t *testing.T) {
|
||||
input := "access_token=abc123 Authorization:Bearer xyz456 https://api.example.com/cb?refresh_token=zzz"
|
||||
redacted := sanitizeSensitiveLogText(input)
|
||||
|
||||
if strings.Contains(redacted, "abc123") || strings.Contains(redacted, "xyz456") || strings.Contains(redacted, "zzz") {
|
||||
t.Fatalf("expected sensitive values to be redacted, got: %s", redacted)
|
||||
}
|
||||
if !strings.Contains(redacted, "[REDACTED]") {
|
||||
t.Fatalf("expected redaction marker in output, got: %s", redacted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateExtensionAuthURL(t *testing.T) {
|
||||
if err := validateExtensionAuthURL("https://accounts.example.com/oauth/authorize"); err != nil {
|
||||
t.Fatalf("expected valid auth URL, got error: %v", err)
|
||||
}
|
||||
|
||||
blocked := []string{
|
||||
"http://accounts.example.com/oauth/authorize",
|
||||
"https://user:pass@accounts.example.com/oauth/authorize",
|
||||
"https://localhost/oauth/authorize",
|
||||
}
|
||||
|
||||
for _, rawURL := range blocked {
|
||||
if err := validateExtensionAuthURL(rawURL); err == nil {
|
||||
t.Fatalf("expected URL to be blocked: %s", rawURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDomainRejectsEmbeddedCredentials(t *testing.T) {
|
||||
ext := &LoadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
Permissions: ExtensionPermissions{
|
||||
Network: []string{"api.example.com"},
|
||||
},
|
||||
},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
if err := runtime.validateDomain("https://user:pass@api.example.com/resource"); err == nil {
|
||||
t.Fatal("expected embedded URL credentials to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildStoreExtensionDestPath(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
|
||||
destPath, err := buildStoreExtensionDestPath(baseDir, "../evil/name")
|
||||
if err != nil {
|
||||
t.Fatalf("expected sanitized path to be generated, got error: %v", err)
|
||||
}
|
||||
|
||||
if !isPathWithinBase(baseDir, destPath) {
|
||||
t.Fatalf("expected destination path to remain under base dir: %s", destPath)
|
||||
}
|
||||
|
||||
baseName := filepath.Base(destPath)
|
||||
if strings.Contains(baseName, "/") || strings.Contains(baseName, `\`) {
|
||||
t.Fatalf("expected filename to be sanitized, got: %s", baseName)
|
||||
}
|
||||
if !strings.HasSuffix(baseName, ".spotiflac-ext") {
|
||||
t.Fatalf("expected .spotiflac-ext suffix, got: %s", baseName)
|
||||
}
|
||||
|
||||
if _, err := buildStoreExtensionDestPath(baseDir, " "); err == nil {
|
||||
t.Fatal("expected empty extension id to be rejected")
|
||||
}
|
||||
}
|
||||
+207
-54
@@ -1,6 +1,8 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -13,6 +15,10 @@ type SongLinkClient struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type songLinkPlatformLink struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type TrackAvailability struct {
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
Tidal bool `json:"tidal"`
|
||||
@@ -36,6 +42,13 @@ var (
|
||||
songLinkClientOnce sync.Once
|
||||
songLinkRegion = "US"
|
||||
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)
|
||||
}
|
||||
songLinkRetryConfig = DefaultRetryConfig
|
||||
)
|
||||
|
||||
func NewSongLinkClient() *SongLinkClient {
|
||||
@@ -109,7 +122,28 @@ func buildSongLinkURLByPlatform(platform, entityType, entityID, userCountry stri
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
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) {
|
||||
availability, pageErr := s.checkTrackAvailabilityFromSpotifyPage(spotifyTrackID)
|
||||
if pageErr == nil {
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
if !songLinkRateLimiter.TryAcquire() {
|
||||
return nil, fmt.Errorf("song.link page lookup failed: %w (SongLink local rate limit exceeded)", pageErr)
|
||||
}
|
||||
|
||||
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
||||
apiURL := buildSongLinkURLFromTarget(spotifyURL, "")
|
||||
@@ -119,10 +153,10 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
retryConfig := DefaultRetryConfig()
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API lookup failed: %w", pageErr, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -133,10 +167,10 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
return nil, fmt.Errorf("track not found on any streaming platform")
|
||||
}
|
||||
if resp.StatusCode == 429 {
|
||||
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API rate limit exceeded", pageErr)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API returned status %d", pageErr, resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
@@ -145,61 +179,145 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
}
|
||||
|
||||
var songLinkResp struct {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"linksByPlatform"`
|
||||
LinksByPlatform map[string]songLinkPlatformLink `json:"linksByPlatform"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
availability := &TrackAvailability{
|
||||
SpotifyID: spotifyTrackID,
|
||||
LogWarn("SongLink", "Spotify %s resolved via SongLink API after song.link page failure: %v", spotifyTrackID, pageErr)
|
||||
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, songLinkResp.LinksByPlatform), nil
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) checkTrackAvailabilityFromSpotifyPage(spotifyTrackID string) (*TrackAvailability, error) {
|
||||
pageURL := fmt.Sprintf("https://song.link/s/%s", spotifyTrackID)
|
||||
req, err := http.NewRequest("GET", pageURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create song.link page request: %w", err)
|
||||
}
|
||||
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch song.link page: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 404 {
|
||||
return nil, fmt.Errorf("track not found on song.link page")
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("song.link page returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
availability.Amazon = true
|
||||
availability.AmazonURL = amazonLink.URL
|
||||
body, err := ReadResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read song.link page: %w", err)
|
||||
}
|
||||
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.Deezer = true
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||
nextDataJSON, err := extractSongLinkNextDataJSON(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||
availability.Qobuz = true
|
||||
availability.QobuzURL = qobuzLink.URL
|
||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||
var pageData struct {
|
||||
Props struct {
|
||||
PageProps struct {
|
||||
PageData struct {
|
||||
Sections []struct {
|
||||
Links []struct {
|
||||
Platform string `json:"platform"`
|
||||
URL string `json:"url"`
|
||||
Show bool `json:"show"`
|
||||
} `json:"links"`
|
||||
} `json:"sections"`
|
||||
} `json:"pageData"`
|
||||
} `json:"pageProps"`
|
||||
} `json:"props"`
|
||||
}
|
||||
if err := json.Unmarshal(nextDataJSON, &pageData); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode song.link page data: %w", err)
|
||||
}
|
||||
|
||||
// Prefer youtubeMusic URLs — they bypass Cobalt login requirements
|
||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
}
|
||||
|
||||
// Fallback to regular youtube if youtubeMusic not available
|
||||
if !availability.YouTube {
|
||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = youtubeLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||
linksByPlatform := make(map[string]songLinkPlatformLink)
|
||||
for _, section := range pageData.Props.PageProps.PageData.Sections {
|
||||
for _, link := range section.Links {
|
||||
if !link.Show || strings.TrimSpace(link.URL) == "" {
|
||||
continue
|
||||
}
|
||||
linksByPlatform[link.Platform] = songLinkPlatformLink{URL: link.URL}
|
||||
}
|
||||
}
|
||||
|
||||
if len(linksByPlatform) == 0 {
|
||||
return nil, fmt.Errorf("song.link page contained no usable platform links")
|
||||
}
|
||||
|
||||
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, linksByPlatform), nil
|
||||
}
|
||||
|
||||
func extractSongLinkNextDataJSON(body []byte) ([]byte, error) {
|
||||
const startMarker = `<script id="__NEXT_DATA__" type="application/json">`
|
||||
const endMarker = `</script>`
|
||||
|
||||
start := bytes.Index(body, []byte(startMarker))
|
||||
if start < 0 {
|
||||
return nil, fmt.Errorf("song.link page missing __NEXT_DATA__")
|
||||
}
|
||||
start += len(startMarker)
|
||||
|
||||
end := bytes.Index(body[start:], []byte(endMarker))
|
||||
if end < 0 {
|
||||
return nil, fmt.Errorf("song.link page has unterminated __NEXT_DATA__")
|
||||
}
|
||||
|
||||
return body[start : start+end], nil
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) checkTrackAvailabilityFromISRC(isrc string) (*TrackAvailability, error) {
|
||||
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) {
|
||||
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
||||
if err != nil {
|
||||
@@ -229,7 +347,7 @@ func extractDeezerIDFromURL(deezerURL string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractQobuzIDFromURL extracts Qobuz track ID from URL
|
||||
// extractQobuzIDFromURL extracts Qobuz track ID from URL.
|
||||
// URL formats:
|
||||
// - https://www.qobuz.com/us-en/album/.../12345678 (album page with track highlight)
|
||||
// - https://open.qobuz.com/track/12345678
|
||||
@@ -240,29 +358,24 @@ func extractQobuzIDFromURL(qobuzURL string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Try to find /track/ID pattern first
|
||||
if strings.Contains(qobuzURL, "/track/") {
|
||||
parts := strings.Split(qobuzURL, "/track/")
|
||||
if len(parts) > 1 {
|
||||
idPart := parts[1]
|
||||
// Remove query parameters
|
||||
if idx := strings.Index(idPart, "?"); idx > 0 {
|
||||
idPart = idPart[:idx]
|
||||
}
|
||||
// Remove trailing slash or path
|
||||
if idx := strings.Index(idPart, "/"); idx > 0 {
|
||||
idPart = idPart[:idx]
|
||||
}
|
||||
idPart = strings.TrimSpace(idPart)
|
||||
// Validate it's a number
|
||||
if idPart != "" && isNumeric(idPart) {
|
||||
return idPart
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract from album URL with track highlight
|
||||
// Format: /album/albumname/trackid or ?trackId=12345678
|
||||
// Try to extract from album URL with track highlight (e.g. ?trackId=12345678)
|
||||
if strings.Contains(qobuzURL, "trackId=") {
|
||||
parts := strings.Split(qobuzURL, "trackId=")
|
||||
if len(parts) > 1 {
|
||||
@@ -281,7 +394,6 @@ func extractQobuzIDFromURL(qobuzURL string) string {
|
||||
parts := strings.Split(qobuzURL, "/")
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
part := parts[i]
|
||||
// Remove query parameters
|
||||
if idx := strings.Index(part, "?"); idx > 0 {
|
||||
part = part[:idx]
|
||||
}
|
||||
@@ -324,7 +436,6 @@ func extractYouTubeIDFromURL(youtubeURL string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Handle youtu.be short URLs
|
||||
if strings.Contains(youtubeURL, "youtu.be/") {
|
||||
parts := strings.Split(youtubeURL, "youtu.be/")
|
||||
if len(parts) >= 2 {
|
||||
@@ -339,7 +450,6 @@ func extractYouTubeIDFromURL(youtubeURL string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle youtube.com URLs with ?v= parameter
|
||||
parsed, err := url.Parse(youtubeURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
@@ -349,7 +459,6 @@ func extractYouTubeIDFromURL(youtubeURL string) string {
|
||||
return v
|
||||
}
|
||||
|
||||
// Handle /embed/ format
|
||||
if strings.Contains(parsed.Path, "/embed/") {
|
||||
parts := strings.Split(parsed.Path, "/embed/")
|
||||
if len(parts) >= 2 {
|
||||
@@ -406,7 +515,7 @@ func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAv
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
retryConfig := DefaultRetryConfig()
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check album availability: %w", err)
|
||||
@@ -478,7 +587,6 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
// checkAvailabilityFromDeezerSongLink is the original SongLink implementation for Deezer
|
||||
func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID string) (*TrackAvailability, error) {
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
@@ -490,7 +598,7 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
retryConfig := DefaultRetryConfig()
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||
@@ -595,7 +703,7 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
retryConfig := DefaultRetryConfig()
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||
@@ -676,6 +784,51 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
func buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID string, links map[string]songLinkPlatformLink) *TrackAvailability {
|
||||
availability := &TrackAvailability{
|
||||
SpotifyID: spotifyTrackID,
|
||||
}
|
||||
|
||||
if availability.SpotifyID == "" {
|
||||
if spotifyLink, ok := links["spotify"]; ok && spotifyLink.URL != "" {
|
||||
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||
}
|
||||
}
|
||||
if tidalLink, ok := links["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||
}
|
||||
if amazonLink, ok := links["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
availability.Amazon = true
|
||||
availability.AmazonURL = amazonLink.URL
|
||||
}
|
||||
if qobuzLink, ok := links["qobuz"]; ok && qobuzLink.URL != "" {
|
||||
availability.Qobuz = true
|
||||
availability.QobuzURL = qobuzLink.URL
|
||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||
}
|
||||
if deezerLink, ok := links["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.Deezer = true
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||
}
|
||||
if ytMusicLink, ok := links["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
}
|
||||
if !availability.YouTube {
|
||||
if youtubeLink, ok := links["youtube"]; ok && youtubeLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = youtubeLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||
}
|
||||
}
|
||||
|
||||
return availability
|
||||
}
|
||||
|
||||
func extractSpotifyIDFromURL(spotifyURL string) string {
|
||||
parts := strings.Split(spotifyURL, "/track/")
|
||||
if len(parts) > 1 {
|
||||
@@ -750,7 +903,7 @@ func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvaila
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
retryConfig := DefaultRetryConfig()
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return fn(req)
|
||||
}
|
||||
|
||||
func TestGetRetryAfterDurationMissingHeaderReturnsZero(t *testing.T) {
|
||||
resp := &http.Response{
|
||||
Header: make(http.Header),
|
||||
}
|
||||
|
||||
if got := getRetryAfterDuration(resp); got != 0 {
|
||||
t.Fatalf("getRetryAfterDuration() = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckTrackAvailabilityFromSpotifyPrefersSongLinkPage(t *testing.T) {
|
||||
client := &SongLinkClient{
|
||||
client: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case req.URL.Host == "api.song.link":
|
||||
t.Fatalf("api.song.link should not be called when song.link page succeeds")
|
||||
return nil, nil
|
||||
case req.URL.Host == "song.link" && req.URL.Path == "/s/testspotifyid":
|
||||
body := `<!DOCTYPE html><html><body><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"pageData":{"sections":[{"displayName":"Listen","links":[{"platform":"spotify","url":"https://open.spotify.com/track/testspotifyid","show":true},{"platform":"deezer","url":"https://www.deezer.com/track/908604612","show":true},{"platform":"amazonMusic","url":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C","show":true},{"platform":"tidal","url":"https://listen.tidal.com/track/134858527","show":true},{"platform":"qobuz","url":"https://open.qobuz.com/track/195125822","show":true},{"platform":"youtubeMusic","url":"https://music.youtube.com/watch?v=testvideoid1","show":true}]}]}}}}</script></body></html>`
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s", req.URL.String())
|
||||
return nil, nil
|
||||
}
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
availability, err := client.CheckTrackAvailability("testspotifyid", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckTrackAvailability() error = %v", err)
|
||||
}
|
||||
|
||||
if availability.SpotifyID != "testspotifyid" {
|
||||
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testspotifyid")
|
||||
}
|
||||
if !availability.Deezer || availability.DeezerID != "908604612" {
|
||||
t.Fatalf("Deezer availability = %+v, want DeezerID 908604612", availability)
|
||||
}
|
||||
if !availability.Amazon || !availability.Tidal || !availability.Qobuz || !availability.YouTube {
|
||||
t.Fatalf("availability flags = %+v, want Amazon/Tidal/Qobuz/YouTube true", availability)
|
||||
}
|
||||
if availability.YouTubeID != "testvideoid1" {
|
||||
t.Fatalf("YouTubeID = %q, want %q", availability.YouTubeID, "testvideoid1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckTrackAvailabilityFromSpotifyFallsBackToAPIWhenPageFails(t *testing.T) {
|
||||
origRetryConfig := songLinkRetryConfig
|
||||
songLinkRetryConfig = func() RetryConfig {
|
||||
return RetryConfig{
|
||||
MaxRetries: 0,
|
||||
InitialDelay: 0,
|
||||
MaxDelay: 0,
|
||||
BackoffFactor: 1,
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
songLinkRetryConfig = origRetryConfig
|
||||
}()
|
||||
|
||||
client := &SongLinkClient{
|
||||
client: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case req.URL.Host == "song.link" && req.URL.Path == "/s/testspotifyid":
|
||||
return &http.Response{
|
||||
StatusCode: 500,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("page failure")),
|
||||
Request: req,
|
||||
}, nil
|
||||
case req.URL.Host == "api.song.link":
|
||||
body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/testspotifyid"},"deezer":{"url":"https://www.deezer.com/track/908604612"},"amazonMusic":{"url":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C"},"tidal":{"url":"https://listen.tidal.com/track/134858527"},"qobuz":{"url":"https://open.qobuz.com/track/195125822"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=testvideoid1"}}}`
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s", req.URL.String())
|
||||
return nil, nil
|
||||
}
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
availability, err := client.CheckTrackAvailability("testspotifyid", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckTrackAvailability() error = %v", err)
|
||||
}
|
||||
|
||||
if availability.SpotifyID != "testspotifyid" {
|
||||
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testspotifyid")
|
||||
}
|
||||
if !availability.Deezer || availability.DeezerID != "908604612" {
|
||||
t.Fatalf("Deezer availability = %+v, want DeezerID 908604612", availability)
|
||||
}
|
||||
if !availability.Amazon || !availability.Tidal || !availability.Qobuz || !availability.YouTube {
|
||||
t.Fatalf("availability flags = %+v, want Amazon/Tidal/Qobuz/YouTube true", availability)
|
||||
}
|
||||
if availability.YouTubeID != "testvideoid1" {
|
||||
t.Fatalf("YouTubeID = %q, want %q", availability.YouTubeID, "testvideoid1")
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const DefaultSpotFetchAPIBaseURL = "https://spotify.afkarxyz.fun/api"
|
||||
|
||||
// GetSpotifyDataWithAPI fetches Spotify metadata through SpotFetch-compatible API.
|
||||
// This is used as a fallback when direct Spotify API access is blocked/limited.
|
||||
func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL, apiBaseURL string) (interface{}, error) {
|
||||
parsed, err := parseSpotifyURI(spotifyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid Spotify URL: %w", err)
|
||||
}
|
||||
|
||||
base := strings.TrimSpace(apiBaseURL)
|
||||
if base == "" {
|
||||
base = DefaultSpotFetchAPIBaseURL
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(base, "/"), parsed.Type, parsed.ID)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create SpotFetch API request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := NewHTTPClientWithTimeout(30 * time.Second)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SpotFetch API request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("SpotFetch API error: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read SpotFetch API response: %w", err)
|
||||
}
|
||||
|
||||
switch parsed.Type {
|
||||
case "track":
|
||||
var trackResp TrackResponse
|
||||
if err := json.Unmarshal(bodyBytes, &trackResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode track response: %w", err)
|
||||
}
|
||||
return trackResp, nil
|
||||
case "album":
|
||||
var albumResp AlbumResponsePayload
|
||||
if err := json.Unmarshal(bodyBytes, &albumResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode album response: %w", err)
|
||||
}
|
||||
return &albumResp, nil
|
||||
case "playlist":
|
||||
var playlistResp PlaylistResponsePayload
|
||||
if err := json.Unmarshal(bodyBytes, &playlistResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode playlist response: %w", err)
|
||||
}
|
||||
return playlistResp, nil
|
||||
case "artist":
|
||||
var artistResp ArtistResponsePayload
|
||||
if err := json.Unmarshal(bodyBytes, &artistResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode artist response: %w", err)
|
||||
}
|
||||
return &artistResp, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
|
||||
}
|
||||
}
|
||||
+5
-29
@@ -9,7 +9,6 @@ import (
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -64,45 +63,20 @@ var (
|
||||
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) {
|
||||
credentialsMu.Lock()
|
||||
defer credentialsMu.Unlock()
|
||||
customClientID = clientID
|
||||
customClientSecret = clientSecret
|
||||
customClientID = ""
|
||||
customClientSecret = ""
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -183,6 +157,8 @@ type AlbumResponsePayload struct {
|
||||
}
|
||||
|
||||
type PlaylistInfoMetadata struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Images string `json:"images,omitempty"`
|
||||
Tracks struct {
|
||||
Total int `json:"total"`
|
||||
} `json:"tracks"`
|
||||
|
||||
+1055
-43
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,222 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseTidalURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantType string
|
||||
wantID string
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
name: "track url",
|
||||
input: "https://tidal.com/track/77616174",
|
||||
wantType: "track",
|
||||
wantID: "77616174",
|
||||
},
|
||||
{
|
||||
name: "browse album url",
|
||||
input: "https://listen.tidal.com/browse/album/77616169",
|
||||
wantType: "album",
|
||||
wantID: "77616169",
|
||||
},
|
||||
{
|
||||
name: "artist url",
|
||||
input: "https://www.tidal.com/artist/3852143",
|
||||
wantType: "artist",
|
||||
wantID: "3852143",
|
||||
},
|
||||
{
|
||||
name: "playlist url",
|
||||
input: "https://tidal.com/playlist/edf3b7d2-cb42-41d7-93c0-afa2a395521b",
|
||||
wantType: "playlist",
|
||||
wantID: "edf3b7d2-cb42-41d7-93c0-afa2a395521b",
|
||||
},
|
||||
{
|
||||
name: "unsupported host",
|
||||
input: "https://example.com/track/123",
|
||||
expectErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
gotType, gotID, err := parseTidalURL(test.input)
|
||||
if test.expectErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if gotType != test.wantType || gotID != test.wantID {
|
||||
t.Fatalf("parseTidalURL(%q) = (%q, %q), want (%q, %q)", test.input, gotType, gotID, test.wantType, test.wantID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTidalRequestTrackID(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want int64
|
||||
ok bool
|
||||
}{
|
||||
{input: "40681594", want: 40681594, ok: true},
|
||||
{input: "tidal:40681594", want: 40681594, ok: true},
|
||||
{input: " tidal:40681594 ", want: 40681594, ok: true},
|
||||
{input: "", want: 0, ok: false},
|
||||
{input: "tidal:not-a-number", want: 0, ok: false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
got, ok := parseTidalRequestTrackID(test.input)
|
||||
if got != test.want || ok != test.ok {
|
||||
t.Fatalf("parseTidalRequestTrackID(%q) = (%d, %v), want (%d, %v)", test.input, got, ok, test.want, test.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTidalImageURL(t *testing.T) {
|
||||
got := tidalImageURL("fc18a64b-d76b-4582-962a-224cb05193f3", "1280x1280")
|
||||
want := "https://resources.tidal.com/images/fc18a64b/d76b/4582/962a/224cb05193f3/1280x1280.jpg"
|
||||
if got != want {
|
||||
t.Fatalf("tidalImageURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTidalTrackToTrackMetadata(t *testing.T) {
|
||||
track := &TidalTrack{
|
||||
ID: 77616174,
|
||||
Title: "Bruckner: Symphony No. 5",
|
||||
ISRC: "GBUM71507433",
|
||||
Duration: 1172,
|
||||
TrackNumber: 5,
|
||||
VolumeNumber: 1,
|
||||
URL: "http://www.tidal.com/track/77616174",
|
||||
}
|
||||
track.Artist.ID = 3852143
|
||||
track.Artist.Name = "Staatskapelle Berlin"
|
||||
track.Artists = []struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Picture string `json:"picture"`
|
||||
}{
|
||||
{ID: 3852143, Name: "Staatskapelle Berlin", Type: "MAIN"},
|
||||
{ID: 12430, Name: "Daniel Barenboim", Type: "FEATURED"},
|
||||
}
|
||||
track.Album.ID = 77616169
|
||||
track.Album.Title = "Bruckner: Symphonies 4-9"
|
||||
track.Album.Cover = "fc18a64b-d76b-4582-962a-224cb05193f3"
|
||||
track.Album.ReleaseDate = "2016-02-26"
|
||||
|
||||
got := tidalTrackToTrackMetadata(track)
|
||||
if got.SpotifyID != "tidal:77616174" {
|
||||
t.Fatalf("unexpected track ID: %q", got.SpotifyID)
|
||||
}
|
||||
if got.Artists != "Staatskapelle Berlin, Daniel Barenboim" {
|
||||
t.Fatalf("unexpected artists: %q", got.Artists)
|
||||
}
|
||||
if got.AlbumID != "tidal:77616169" {
|
||||
t.Fatalf("unexpected album ID: %q", got.AlbumID)
|
||||
}
|
||||
if got.ArtistID != "tidal:3852143" {
|
||||
t.Fatalf("unexpected artist ID: %q", got.ArtistID)
|
||||
}
|
||||
if got.Images == "" || got.ExternalURL != "https://www.tidal.com/track/77616174" {
|
||||
t.Fatalf("unexpected image/url: %q / %q", got.Images, got.ExternalURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTidalAlbumToArtistAlbum(t *testing.T) {
|
||||
album := &tidalPublicAlbum{
|
||||
ID: 77616169,
|
||||
Title: "Bruckner: Symphonies 4-9",
|
||||
Type: "ALBUM",
|
||||
Cover: "fc18a64b-d76b-4582-962a-224cb05193f3",
|
||||
ReleaseDate: "2016-02-26",
|
||||
NumberOfTracks: 23,
|
||||
Artists: []tidalPublicArtist{
|
||||
{ID: 3852143, Name: "Staatskapelle Berlin", Type: "MAIN"},
|
||||
{ID: 12430, Name: "Daniel Barenboim", Type: "FEATURED"},
|
||||
},
|
||||
}
|
||||
|
||||
got := tidalAlbumToArtistAlbum(album)
|
||||
if got.ID != "tidal:77616169" {
|
||||
t.Fatalf("unexpected album ID: %q", got.ID)
|
||||
}
|
||||
if got.AlbumType != "album" {
|
||||
t.Fatalf("unexpected album type: %q", got.AlbumType)
|
||||
}
|
||||
if got.Artists != "Staatskapelle Berlin, Daniel Barenboim" {
|
||||
t.Fatalf("unexpected artists: %q", got.Artists)
|
||||
}
|
||||
if got.Images == "" {
|
||||
t.Fatalf("expected image URL, got empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTidalAlbumToArtistAlbumWithFallbackType(t *testing.T) {
|
||||
album := &tidalPublicAlbum{
|
||||
ID: 490623904,
|
||||
Title: "LET 'EM KNOW",
|
||||
Cover: "fc18a64b-d76b-4582-962a-224cb05193f3",
|
||||
NumberOfTracks: 1,
|
||||
}
|
||||
|
||||
got := tidalAlbumToArtistAlbumWithType(album, "single")
|
||||
if got.AlbumType != "single" {
|
||||
t.Fatalf("unexpected fallback album type: %q", got.AlbumType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTidalArtistAlbumTypeFromModuleTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
title string
|
||||
want string
|
||||
}{
|
||||
{title: "Albums", want: "album"},
|
||||
{title: "EP & Singles", want: "single"},
|
||||
{title: "Compilations", want: "album"},
|
||||
{title: "Appears On", want: "album"},
|
||||
{title: "Unknown", want: ""},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
if got := tidalArtistAlbumTypeFromModuleTitle(test.title); got != test.want {
|
||||
t.Fatalf("tidalArtistAlbumTypeFromModuleTitle(%q) = %q, want %q", test.title, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTidalPlaylistImageUsesOrigin(t *testing.T) {
|
||||
got := tidalImageURL("e6b59fd3-6995-40f0-8a32-174db3a8f4f2", "origin")
|
||||
want := "https://resources.tidal.com/images/e6b59fd3/6995/40f0/8a32/174db3a8f4f2/origin.jpg"
|
||||
if got != want {
|
||||
t.Fatalf("unexpected origin playlist image URL: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTidalPlaylistOwnerName(t *testing.T) {
|
||||
editorial := &tidalPublicPlaylist{Type: "EDITORIAL"}
|
||||
if got := tidalPlaylistOwnerName(editorial); got != "TIDAL" {
|
||||
t.Fatalf("unexpected editorial owner: %q", got)
|
||||
}
|
||||
|
||||
artist := &tidalPublicPlaylist{Type: "ARTIST"}
|
||||
if got := tidalPlaylistOwnerName(artist); got != "Artist" {
|
||||
t.Fatalf("unexpected artist owner: %q", got)
|
||||
}
|
||||
|
||||
user := &tidalPublicPlaylist{}
|
||||
user.Creator.Name = "djtest"
|
||||
if got := tidalPlaylistOwnerName(user); got != "djtest" {
|
||||
t.Fatalf("unexpected creator owner: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,25 @@ package gobackend
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
func writeNormalizedArtistRune(b *strings.Builder, r rune) {
|
||||
switch r {
|
||||
case 'đ':
|
||||
b.WriteString("dj")
|
||||
case 'ß':
|
||||
b.WriteString("ss")
|
||||
case 'æ':
|
||||
b.WriteString("ae")
|
||||
case 'œ':
|
||||
b.WriteString("oe")
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeLooseTitle collapses separators/punctuation so titles like
|
||||
// "Doctor / Cops" and "Doctor _ Cops" can still match.
|
||||
func normalizeLooseTitle(title string) string {
|
||||
@@ -22,11 +39,39 @@ func normalizeLooseTitle(title string) string {
|
||||
b.WriteRune(r)
|
||||
case unicode.IsSpace(r):
|
||||
b.WriteByte(' ')
|
||||
// Treat common separators as spaces.
|
||||
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
|
||||
b.WriteByte(' ')
|
||||
default:
|
||||
// Drop other punctuation/symbols (including emoji) for loose matching.
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(strings.Fields(b.String()), " ")
|
||||
}
|
||||
|
||||
// normalizeLooseArtistName folds diacritics and common separators so artist
|
||||
// verification is resilient to variants like "Özkent" vs "Ozkent".
|
||||
func normalizeLooseArtistName(name string) string {
|
||||
trimmed := strings.TrimSpace(strings.ToLower(name))
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
decomposed := norm.NFD.String(trimmed)
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(len(decomposed))
|
||||
|
||||
for _, r := range decomposed {
|
||||
switch {
|
||||
case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Mc, r), unicode.Is(unicode.Me, r):
|
||||
continue
|
||||
case unicode.IsLetter(r), unicode.IsNumber(r):
|
||||
writeNormalizedArtistRune(&b, r)
|
||||
case unicode.IsSpace(r):
|
||||
b.WriteByte(' ')
|
||||
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
|
||||
b.WriteByte(' ')
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,3 +113,51 @@ func normalizeSymbolOnlyTitle(title string) string {
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// resolvedTrackInfo holds the metadata fetched from a provider for verification.
|
||||
type resolvedTrackInfo struct {
|
||||
Title string
|
||||
ArtistName string
|
||||
ISRC string
|
||||
Duration int
|
||||
SkipNameVerification bool
|
||||
}
|
||||
|
||||
// trackMatchesRequest checks whether a resolved track from a provider matches
|
||||
// the original download request. Returns true if the track is a plausible match.
|
||||
func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool {
|
||||
exactISRCMatch := req.ISRC != "" &&
|
||||
resolved.ISRC != "" &&
|
||||
strings.EqualFold(strings.TrimSpace(req.ISRC), strings.TrimSpace(resolved.ISRC))
|
||||
|
||||
if !exactISRCMatch && !resolved.SkipNameVerification {
|
||||
if req.ArtistName != "" && resolved.ArtistName != "" &&
|
||||
!artistsMatch(req.ArtistName, resolved.ArtistName) {
|
||||
GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n",
|
||||
logPrefix, req.ArtistName, resolved.ArtistName)
|
||||
return false
|
||||
}
|
||||
|
||||
if req.TrackName != "" && resolved.Title != "" &&
|
||||
!titlesMatch(req.TrackName, resolved.Title) {
|
||||
GoLog("[%s] Verification failed: title mismatch — expected '%s', got '%s'\n",
|
||||
logPrefix, req.TrackName, resolved.Title)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
expectedDurationSec := req.DurationMS / 1000
|
||||
if expectedDurationSec > 0 && resolved.Duration > 0 {
|
||||
diff := expectedDurationSec - resolved.Duration
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff > 10 {
|
||||
GoLog("[%s] Verification failed: duration mismatch — expected %ds, got %ds\n",
|
||||
logPrefix, expectedDurationSec, resolved.Duration)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -21,6 +21,40 @@ func TestNormalizeLooseTitle_EmojiAndSymbols(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackMatchesRequest_SongLinkBypassesArtistAndTitle(t *testing.T) {
|
||||
req := DownloadRequest{
|
||||
TrackName: "Ringišpil",
|
||||
ArtistName: "Djordje Balasevic",
|
||||
}
|
||||
resolved := resolvedTrackInfo{
|
||||
Title: "Completely Different Title",
|
||||
ArtistName: "Totally Different Artist",
|
||||
SkipNameVerification: true,
|
||||
}
|
||||
|
||||
if !trackMatchesRequest(req, resolved, "test") {
|
||||
t.Fatal("expected SongLink-resolved track to bypass artist/title verification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackMatchesRequest_SongLinkStillChecksDuration(t *testing.T) {
|
||||
req := DownloadRequest{
|
||||
TrackName: "Ringišpil",
|
||||
ArtistName: "Djordje Balasevic",
|
||||
DurationMS: 180000,
|
||||
}
|
||||
resolved := resolvedTrackInfo{
|
||||
Title: "Completely Different Title",
|
||||
ArtistName: "Totally Different Artist",
|
||||
Duration: 240,
|
||||
SkipNameVerification: true,
|
||||
}
|
||||
|
||||
if trackMatchesRequest(req, resolved, "test") {
|
||||
t.Fatal("expected SongLink-resolved track with large duration mismatch to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTitlesMatch_SeparatorVariants(t *testing.T) {
|
||||
if !titlesMatch("Doctor / Cops", "Doctor _ Cops") {
|
||||
t.Fatal("expected tidal titlesMatch to accept / vs _ variant")
|
||||
|
||||
@@ -1,695 +0,0 @@
|
||||
// Package gobackend - YouTube download via Cobalt API (lossy-only provider)
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type YouTubeDownloader struct {
|
||||
client *http.Client
|
||||
apiURL string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
const spotubeBaseURL = "https://spotubedl.com"
|
||||
|
||||
var (
|
||||
globalYouTubeDownloader *YouTubeDownloader
|
||||
youtubeDownloaderOnce sync.Once
|
||||
)
|
||||
|
||||
type YouTubeQuality string
|
||||
|
||||
const (
|
||||
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
|
||||
YouTubeQualityOpus128 YouTubeQuality = "opus_128"
|
||||
YouTubeQualityMP3128 YouTubeQuality = "mp3_128"
|
||||
YouTubeQualityMP3256 YouTubeQuality = "mp3_256"
|
||||
YouTubeQualityMP3320 YouTubeQuality = "mp3_320"
|
||||
)
|
||||
|
||||
var (
|
||||
youtubeOpusSupportedBitrates = []int{128, 256}
|
||||
youtubeMp3SupportedBitrates = []int{128, 256, 320}
|
||||
)
|
||||
|
||||
type CobaltRequest struct {
|
||||
URL string `json:"url"`
|
||||
AudioBitrate string `json:"audioBitrate,omitempty"`
|
||||
AudioFormat string `json:"audioFormat,omitempty"`
|
||||
DownloadMode string `json:"downloadMode,omitempty"`
|
||||
FilenameStyle string `json:"filenameStyle,omitempty"`
|
||||
DisableMetadata bool `json:"disableMetadata,omitempty"`
|
||||
}
|
||||
|
||||
type CobaltResponse struct {
|
||||
Status string `json:"status"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
Error *struct {
|
||||
Code string `json:"code"`
|
||||
Context *struct {
|
||||
Service string `json:"service,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
} `json:"context,omitempty"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type YouTubeDownloadResult struct {
|
||||
FilePath string
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
ReleaseDate string
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
Format string // "opus" or "mp3"
|
||||
Bitrate int
|
||||
LyricsLRC string
|
||||
CoverData []byte
|
||||
}
|
||||
|
||||
func NewYouTubeDownloader() *YouTubeDownloader {
|
||||
youtubeDownloaderOnce.Do(func() {
|
||||
globalYouTubeDownloader = &YouTubeDownloader{
|
||||
client: NewHTTPClientWithTimeout(120 * time.Second),
|
||||
apiURL: "https://api.qwkuns.me",
|
||||
}
|
||||
})
|
||||
return globalYouTubeDownloader
|
||||
}
|
||||
|
||||
func extractBitrateFromQuality(raw string, defaultBitrate int) int {
|
||||
parts := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return (r < '0' || r > '9')
|
||||
})
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
part := parts[i]
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if parsed, err := strconv.Atoi(part); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return defaultBitrate
|
||||
}
|
||||
|
||||
func nearestSupportedBitrate(value int, supported []int) int {
|
||||
nearest := supported[0]
|
||||
nearestDistance := absInt(value - nearest)
|
||||
|
||||
for _, option := range supported[1:] {
|
||||
distance := absInt(value - option)
|
||||
// On tie prefer higher quality.
|
||||
if distance < nearestDistance || (distance == nearestDistance && option > nearest) {
|
||||
nearest = option
|
||||
nearestDistance = distance
|
||||
}
|
||||
}
|
||||
|
||||
return nearest
|
||||
}
|
||||
|
||||
func absInt(value int) int {
|
||||
if value < 0 {
|
||||
return -value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalized YouTubeQuality) {
|
||||
normalizedRaw := strings.ToLower(strings.TrimSpace(raw))
|
||||
|
||||
if strings.HasPrefix(normalizedRaw, "opus") {
|
||||
parsed := extractBitrateFromQuality(normalizedRaw, 256)
|
||||
finalBitrate := nearestSupportedBitrate(parsed, youtubeOpusSupportedBitrates)
|
||||
return "opus", finalBitrate, YouTubeQuality(fmt.Sprintf("opus_%d", finalBitrate))
|
||||
}
|
||||
|
||||
if strings.HasPrefix(normalizedRaw, "mp3") {
|
||||
parsed := extractBitrateFromQuality(normalizedRaw, 320)
|
||||
finalBitrate := nearestSupportedBitrate(parsed, youtubeMp3SupportedBitrates)
|
||||
return "mp3", finalBitrate, YouTubeQuality(fmt.Sprintf("mp3_%d", finalBitrate))
|
||||
}
|
||||
|
||||
// Backward compatibility for legacy symbolic values.
|
||||
switch normalizedRaw {
|
||||
case "opus_256", "opus256", "opus":
|
||||
return "opus", 256, YouTubeQualityOpus256
|
||||
case "opus_128", "opus128":
|
||||
return "opus", 128, YouTubeQualityOpus128
|
||||
case "mp3_320", "mp3320", "mp3", "":
|
||||
return "mp3", 320, YouTubeQualityMP3320
|
||||
case "mp3_256", "mp3256":
|
||||
return "mp3", 256, YouTubeQualityMP3256
|
||||
case "mp3_128", "mp3128":
|
||||
return "mp3", 128, YouTubeQualityMP3128
|
||||
default:
|
||||
return "mp3", 320, YouTubeQualityMP3320
|
||||
}
|
||||
}
|
||||
|
||||
// SearchYouTube returns a YouTube Music search URL for the given track
|
||||
func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) {
|
||||
query := fmt.Sprintf("%s %s", artistName, trackName)
|
||||
searchQuery := url.QueryEscape(query)
|
||||
|
||||
GoLog("[YouTube] Search query: %s\n", query)
|
||||
|
||||
youtubeMusicURL := fmt.Sprintf("https://music.youtube.com/search?q=%s", searchQuery)
|
||||
|
||||
return youtubeMusicURL, nil
|
||||
}
|
||||
|
||||
func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQuality) (*CobaltResponse, error) {
|
||||
y.mu.Lock()
|
||||
defer y.mu.Unlock()
|
||||
|
||||
audioFormat, bitrate, _ := parseYouTubeQualityInput(string(quality))
|
||||
audioBitrate := strconv.Itoa(bitrate)
|
||||
|
||||
// Try SpotubeDL first (primary)
|
||||
var spotubeErr error
|
||||
videoID, extractErr := ExtractYouTubeVideoID(youtubeURL)
|
||||
if extractErr == nil {
|
||||
GoLog("[YouTube] Requesting from SpotubeDL: videoID=%s (format: %s, bitrate: %s)\n",
|
||||
videoID, audioFormat, audioBitrate)
|
||||
|
||||
resp, err := y.requestSpotubeDL(videoID, audioFormat, audioBitrate)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
spotubeErr = err
|
||||
GoLog("[YouTube] SpotubeDL failed: %v, trying Cobalt fallback...\n", err)
|
||||
} else {
|
||||
GoLog("[YouTube] Could not extract video ID: %v, skipping SpotubeDL\n", extractErr)
|
||||
}
|
||||
|
||||
// Fallback: direct Cobalt API (api.qwkuns.me)
|
||||
cobaltURL := toYouTubeMusicURL(youtubeURL)
|
||||
GoLog("[YouTube] Requesting from Cobalt API: %s (format: %s, bitrate: %s)\n",
|
||||
cobaltURL, audioFormat, audioBitrate)
|
||||
|
||||
resp, err := y.requestCobaltDirect(cobaltURL, audioFormat, audioBitrate)
|
||||
if err != nil {
|
||||
if spotubeErr != nil {
|
||||
return nil, fmt.Errorf("all download methods failed: spotubedl: %v, cobalt: %v", spotubeErr, err)
|
||||
}
|
||||
return nil, fmt.Errorf("all download methods failed: spotubedl: extractErr=%v, cobalt: %v", extractErr, err)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// requestCobaltDirect sends a download request to the primary Cobalt API.
|
||||
func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) {
|
||||
reqBody := CobaltRequest{
|
||||
URL: videoURL,
|
||||
AudioFormat: audioFormat,
|
||||
AudioBitrate: audioBitrate,
|
||||
DownloadMode: "audio",
|
||||
FilenameStyle: "basic",
|
||||
DisableMetadata: true,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", y.apiURL, strings.NewReader(string(jsonData)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := DoRequestWithUserAgent(y.client, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cobalt API request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Cobalt API response status: %d\n", resp.StatusCode)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("cobalt API returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var cobaltResp CobaltResponse
|
||||
if err := json.Unmarshal(body, &cobaltResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if cobaltResp.Status == "error" && cobaltResp.Error != nil {
|
||||
return nil, fmt.Errorf("cobalt error: %s", cobaltResp.Error.Code)
|
||||
}
|
||||
|
||||
if cobaltResp.Status != "tunnel" && cobaltResp.Status != "redirect" {
|
||||
return nil, fmt.Errorf("unexpected cobalt status: %s", cobaltResp.Status)
|
||||
}
|
||||
|
||||
if cobaltResp.URL == "" {
|
||||
return nil, fmt.Errorf("no download URL in response")
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Got download URL from Cobalt (status: %s)\n", cobaltResp.Status)
|
||||
return &cobaltResp, nil
|
||||
}
|
||||
|
||||
// requestSpotubeDL uses SpotubeDL as a Cobalt proxy (they handle auth to yt-dl.click instances).
|
||||
// Engines v3/v2 are MP3-oriented outputs, so we only use them for MP3 requests.
|
||||
func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate string) (*CobaltResponse, error) {
|
||||
engines := []string{"v1"}
|
||||
if strings.EqualFold(audioFormat, "mp3") {
|
||||
engines = append(engines, "v3", "v2")
|
||||
}
|
||||
var lastErr error
|
||||
|
||||
for _, engine := range engines {
|
||||
resp, err := y.requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
lastErr = err
|
||||
GoLog("[YouTube] SpotubeDL (%s) failed: %v\n", engine, err)
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("no SpotubeDL engine available")
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (y *YouTubeDownloader) requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine string) (*CobaltResponse, error) {
|
||||
apiURL := fmt.Sprintf("%s/api/download/%s?engine=%s&format=%s&quality=%s",
|
||||
spotubeBaseURL, videoID, url.QueryEscape(engine), url.QueryEscape(audioFormat), url.QueryEscape(audioBitrate))
|
||||
|
||||
GoLog("[YouTube] Requesting from SpotubeDL (%s): %s\n", engine, apiURL)
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := DoRequestWithUserAgent(y.client, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("spotubedl request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
GoLog("[YouTube] SpotubeDL (%s) response status: %d\n", engine, resp.StatusCode)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("spotubedl(%s) returned status %d: %s", engine, resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
URL string `json:"url"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse spotubedl response: %w", err)
|
||||
}
|
||||
|
||||
downloadURL := strings.TrimSpace(result.URL)
|
||||
if downloadURL == "" {
|
||||
if result.Error != "" {
|
||||
return nil, fmt.Errorf("spotubedl(%s) error: %s", engine, result.Error)
|
||||
}
|
||||
if result.Message != "" {
|
||||
return nil, fmt.Errorf("spotubedl(%s) message: %s", engine, result.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("no download URL from spotubedl(%s)", engine)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(downloadURL, "/") {
|
||||
downloadURL = spotubeBaseURL + downloadURL
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(downloadURL, "http://") && !strings.HasPrefix(downloadURL, "https://") {
|
||||
return nil, fmt.Errorf("invalid download URL from spotubedl(%s): %s", engine, downloadURL)
|
||||
}
|
||||
|
||||
filename := strings.TrimSpace(result.Filename)
|
||||
if filename == "" {
|
||||
if parsedURL, parseErr := url.Parse(downloadURL); parseErr == nil {
|
||||
if queryFilename := strings.TrimSpace(parsedURL.Query().Get("filename")); queryFilename != "" {
|
||||
if decodedFilename, decodeErr := url.QueryUnescape(queryFilename); decodeErr == nil {
|
||||
filename = decodedFilename
|
||||
} else {
|
||||
filename = queryFilename
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Got download URL from SpotubeDL (%s)\n", engine)
|
||||
return &CobaltResponse{
|
||||
Status: "tunnel",
|
||||
URL: downloadURL,
|
||||
Filename: filename,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (y *YouTubeDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
ctx = initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
}
|
||||
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := DoRequestWithUserAgent(y.client, req)
|
||||
if err != nil {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
expectedSize := resp.ContentLength
|
||||
if expectedSize > 0 && itemID != "" {
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
|
||||
out, err := openOutputForWrite(outputPath, outputFD)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
|
||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||
|
||||
var written int64
|
||||
if itemID != "" {
|
||||
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
||||
written, err = io.Copy(progressWriter, resp.Body)
|
||||
} else {
|
||||
written, err = io.Copy(bufWriter, resp.Body)
|
||||
}
|
||||
|
||||
flushErr := bufWriter.Flush()
|
||||
closeErr := out.Close()
|
||||
|
||||
if err != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||
}
|
||||
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Download completed: %d bytes written\n", written)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func BuildYouTubeSearchURL(trackName, artistName string) string {
|
||||
query := fmt.Sprintf("%s %s official audio", artistName, trackName)
|
||||
return fmt.Sprintf("https://music.youtube.com/search?q=%s", url.QueryEscape(query))
|
||||
}
|
||||
|
||||
func BuildYouTubeWatchURL(videoID string) string {
|
||||
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
|
||||
}
|
||||
|
||||
// isYouTubeVideoID checks if s is an 11-char YouTube video ID
|
||||
func isYouTubeVideoID(s string) bool {
|
||||
if len(s) != 11 {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func IsYouTubeURL(urlStr string) bool {
|
||||
lower := strings.ToLower(urlStr)
|
||||
return strings.Contains(lower, "youtube.com") ||
|
||||
strings.Contains(lower, "youtu.be") ||
|
||||
strings.Contains(lower, "music.youtube.com")
|
||||
}
|
||||
|
||||
// toYouTubeMusicURL converts any YouTube URL to music.youtube.com format.
|
||||
// YouTube Music URLs bypass the login requirement that affects regular YouTube videos on Cobalt.
|
||||
func toYouTubeMusicURL(rawURL string) string {
|
||||
videoID, err := ExtractYouTubeVideoID(rawURL)
|
||||
if err != nil {
|
||||
return rawURL
|
||||
}
|
||||
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
|
||||
}
|
||||
|
||||
func ExtractYouTubeVideoID(urlStr string) (string, error) {
|
||||
if strings.Contains(urlStr, "youtu.be/") {
|
||||
parts := strings.Split(urlStr, "youtu.be/")
|
||||
if len(parts) >= 2 {
|
||||
videoID := strings.Split(parts[1], "?")[0]
|
||||
videoID = strings.Split(videoID, "&")[0]
|
||||
return strings.TrimSpace(videoID), nil
|
||||
}
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
// /watch?v=
|
||||
if v := parsed.Query().Get("v"); v != "" {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// /embed/
|
||||
if strings.Contains(parsed.Path, "/embed/") {
|
||||
parts := strings.Split(parsed.Path, "/embed/")
|
||||
if len(parts) >= 2 {
|
||||
return strings.Split(parts[1], "/")[0], nil
|
||||
}
|
||||
}
|
||||
|
||||
// /v/
|
||||
if strings.Contains(parsed.Path, "/v/") {
|
||||
parts := strings.Split(parsed.Path, "/v/")
|
||||
if len(parts) >= 2 {
|
||||
return strings.Split(parts[1], "/")[0], nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not extract video ID from URL")
|
||||
}
|
||||
|
||||
func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
|
||||
downloader := NewYouTubeDownloader()
|
||||
|
||||
format, bitrate, quality := parseYouTubeQualityInput(req.Quality)
|
||||
|
||||
// URL lookup priority: YouTube video ID > Spotify ID > Deezer ID > ISRC
|
||||
var youtubeURL string
|
||||
var lookupErr error
|
||||
|
||||
// SpotifyID might actually be a YouTube video ID (from YT Music extension)
|
||||
if req.SpotifyID != "" && isYouTubeVideoID(req.SpotifyID) {
|
||||
youtubeURL = BuildYouTubeWatchURL(req.SpotifyID)
|
||||
GoLog("[YouTube] SpotifyID appears to be YouTube video ID, using directly: %s\n", youtubeURL)
|
||||
}
|
||||
|
||||
// Try Spotify ID via SongLink
|
||||
if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) {
|
||||
GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID)
|
||||
songlink := NewSongLinkClient()
|
||||
youtubeURL, lookupErr = songlink.GetYouTubeURLFromSpotify(req.SpotifyID)
|
||||
if lookupErr != nil {
|
||||
GoLog("[YouTube] SongLink Spotify lookup failed: %v\n", lookupErr)
|
||||
} else {
|
||||
GoLog("[YouTube] Found YouTube URL via SongLink (Spotify): %s\n", youtubeURL)
|
||||
}
|
||||
}
|
||||
|
||||
// Try Deezer ID via SongLink
|
||||
if youtubeURL == "" && req.DeezerID != "" {
|
||||
GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID)
|
||||
songlink := NewSongLinkClient()
|
||||
youtubeURL, lookupErr = songlink.GetYouTubeURLFromDeezer(req.DeezerID)
|
||||
if lookupErr != nil {
|
||||
GoLog("[YouTube] SongLink Deezer lookup failed: %v\n", lookupErr)
|
||||
} else {
|
||||
GoLog("[YouTube] Found YouTube URL via SongLink (Deezer): %s\n", youtubeURL)
|
||||
}
|
||||
}
|
||||
|
||||
// Try ISRC via SongLink
|
||||
if youtubeURL == "" && req.ISRC != "" {
|
||||
GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC)
|
||||
songlink := NewSongLinkClient()
|
||||
availability, isrcErr := songlink.CheckTrackAvailability("", req.ISRC)
|
||||
if isrcErr == nil && availability.YouTube && availability.YouTubeURL != "" {
|
||||
youtubeURL = availability.YouTubeURL
|
||||
GoLog("[YouTube] Found YouTube URL via SongLink (ISRC): %s\n", youtubeURL)
|
||||
} else if isrcErr != nil {
|
||||
GoLog("[YouTube] SongLink ISRC lookup failed: %v\n", isrcErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Cobalt requires direct video URLs, not search URLs
|
||||
if youtubeURL == "" {
|
||||
return YouTubeDownloadResult{}, fmt.Errorf("could not find YouTube URL for track: %s - %s (no Spotify/Deezer ID available or track not on YouTube)", req.ArtistName, req.TrackName)
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Requesting download from Cobalt for: %s\n", youtubeURL)
|
||||
|
||||
cobaltResp, err := downloader.GetDownloadURL(youtubeURL, quality)
|
||||
if err != nil {
|
||||
return YouTubeDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
|
||||
ext := ".mp3"
|
||||
if format == "opus" {
|
||||
ext = ".opus"
|
||||
}
|
||||
|
||||
// Some SpotubeDL engines may return a different output container than requested.
|
||||
// Respect the provider-reported filename to avoid saving MP3 bytes with .opus extension.
|
||||
if cobaltResp != nil && cobaltResp.Filename != "" {
|
||||
lowerName := strings.ToLower(strings.TrimSpace(cobaltResp.Filename))
|
||||
switch {
|
||||
case strings.HasSuffix(lowerName, ".mp3"):
|
||||
ext = ".mp3"
|
||||
format = "mp3"
|
||||
case strings.HasSuffix(lowerName, ".opus"), strings.HasSuffix(lowerName, ".ogg"):
|
||||
ext = ".opus"
|
||||
format = "opus"
|
||||
}
|
||||
}
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
|
||||
"title": req.TrackName,
|
||||
"artist": req.ArtistName,
|
||||
"album": req.AlbumName,
|
||||
"track": req.TrackNumber,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"date": req.ReleaseDate,
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
filename = sanitizeFilename(filename) + ext
|
||||
|
||||
var outputPath string
|
||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||
if isSafOutput {
|
||||
outputPath = strings.TrimSpace(req.OutputPath)
|
||||
if outputPath == "" && isFDOutput(req.OutputFD) {
|
||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||
}
|
||||
} else {
|
||||
outputPath = req.OutputDir + "/" + filename
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Downloading to: %s\n", outputPath)
|
||||
|
||||
// Parallel fetch cover art + lyrics
|
||||
var parallelResult *ParallelDownloadResult
|
||||
if req.EmbedLyrics || req.CoverURL != "" {
|
||||
GoLog("[YouTube] Starting parallel fetch for cover and lyrics...\n")
|
||||
parallelResult = FetchCoverAndLyricsParallel(
|
||||
req.CoverURL,
|
||||
req.EmbedMaxQualityCover,
|
||||
req.SpotifyID,
|
||||
req.TrackName,
|
||||
req.ArtistName,
|
||||
req.EmbedLyrics,
|
||||
int64(req.DurationMS),
|
||||
)
|
||||
}
|
||||
|
||||
if err := downloader.DownloadFile(cobaltResp.URL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
||||
return YouTubeDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
lyricsLRC := ""
|
||||
var coverData []byte
|
||||
if parallelResult != nil {
|
||||
if parallelResult.LyricsLRC != "" {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
GoLog("[YouTube] Got lyrics from lrclib (%d lines)\n", len(parallelResult.LyricsData.Lines))
|
||||
}
|
||||
if parallelResult.CoverData != nil {
|
||||
coverData = parallelResult.CoverData
|
||||
GoLog("[YouTube] Got cover art (%d bytes)\n", len(coverData))
|
||||
}
|
||||
}
|
||||
|
||||
return YouTubeDownloadResult{
|
||||
FilePath: outputPath,
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
TrackNumber: req.TrackNumber,
|
||||
DiscNumber: req.DiscNumber,
|
||||
ISRC: req.ISRC,
|
||||
Format: format,
|
||||
Bitrate: bitrate,
|
||||
LyricsLRC: lyricsLRC,
|
||||
CoverData: coverData,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseYouTubeQualityInput_OpusNormalizesToSupportedBitrates(t *testing.T) {
|
||||
format, bitrate, normalized := parseYouTubeQualityInput("opus_160")
|
||||
if format != "opus" {
|
||||
t.Fatalf("expected opus format, got %s", format)
|
||||
}
|
||||
if bitrate != 128 {
|
||||
t.Fatalf("expected 128 bitrate, got %d", bitrate)
|
||||
}
|
||||
if normalized != YouTubeQualityOpus128 {
|
||||
t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus128, normalized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseYouTubeQualityInput_Mp3NormalizesToSupportedBitrates(t *testing.T) {
|
||||
format, bitrate, normalized := parseYouTubeQualityInput("mp3_192")
|
||||
if format != "mp3" {
|
||||
t.Fatalf("expected mp3 format, got %s", format)
|
||||
}
|
||||
if bitrate != 256 {
|
||||
t.Fatalf("expected 256 bitrate, got %d", bitrate)
|
||||
}
|
||||
if normalized != YouTubeQualityMP3256 {
|
||||
t.Fatalf("expected %s normalized, got %s", YouTubeQualityMP3256, normalized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
|
||||
_, opusBitrate, _ := parseYouTubeQualityInput("opus_999")
|
||||
if opusBitrate != 256 {
|
||||
t.Fatalf("expected opus normalization to 256, got %d", opusBitrate)
|
||||
}
|
||||
|
||||
_, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1")
|
||||
if mp3Bitrate != 128 {
|
||||
t.Fatalf("expected mp3 normalization to 128, got %d", mp3Bitrate)
|
||||
}
|
||||
}
|
||||
+212
-64
@@ -15,6 +15,9 @@ import Gobackend // Import Go framework
|
||||
private var libraryScanProgressEventSink: FlutterEventSink?
|
||||
private var lastLibraryScanProgressPayload: String?
|
||||
|
||||
/// Currently accessed security-scoped URL for library folder
|
||||
private var activeSecurityScopedURL: URL?
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
@@ -150,45 +153,6 @@ import Gobackend // Import Go framework
|
||||
var error: NSError?
|
||||
|
||||
switch call.method {
|
||||
case "parseSpotifyUrl":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let url = args["url"] as! String
|
||||
let response = GobackendParseSpotifyURL(url, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "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":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let spotifyId = args["spotify_id"] as! String
|
||||
@@ -396,6 +360,26 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "searchTidalAll":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let query = args["query"] as! String
|
||||
let trackLimit = args["track_limit"] as? Int ?? 15
|
||||
let artistLimit = args["artist_limit"] as? Int ?? 3
|
||||
let filter = args["filter"] as? String ?? ""
|
||||
let response = GobackendSearchTidalAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "searchQobuzAll":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let query = args["query"] as! String
|
||||
let trackLimit = args["track_limit"] as? Int ?? 15
|
||||
let artistLimit = args["artist_limit"] as? Int ?? 3
|
||||
let filter = args["filter"] as? String ?? ""
|
||||
let response = GobackendSearchQobuzAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getDeezerRelatedArtists":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let artistId = args["artist_id"] as! String
|
||||
@@ -412,6 +396,22 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
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":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let url = args["url"] as! String
|
||||
@@ -419,6 +419,13 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
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":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let url = args["url"] as! String
|
||||
@@ -455,13 +462,6 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getSpotifyMetadataWithFallback":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let url = args["url"] as! String
|
||||
let response = GobackendGetSpotifyMetadataWithDeezerFallback(url, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "checkAvailabilityFromDeezerID":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let deezerTrackId = args["deezer_track_id"] as! String
|
||||
@@ -492,13 +492,6 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
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":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let tracksJson = args["tracks"] as! String
|
||||
@@ -514,17 +507,6 @@ import Gobackend // Import Go framework
|
||||
GobackendClearTrackCache()
|
||||
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
|
||||
case "getLogs":
|
||||
let response = GobackendGetLogs()
|
||||
@@ -647,6 +629,20 @@ import Gobackend // Import Go framework
|
||||
let response = GobackendSearchTracksWithExtensionsJSON(query, Int(limit), &error)
|
||||
if let error = error { throw error }
|
||||
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":
|
||||
let args = call.arguments as! [String: Any]
|
||||
@@ -838,6 +834,23 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
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":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let forceRefresh = args["force_refresh"] as? Bool ?? false
|
||||
@@ -922,6 +935,26 @@ import Gobackend // Import Go framework
|
||||
let response = GobackendReadAudioMetadataJSON(filePath, &error)
|
||||
if let error = error { throw error }
|
||||
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
|
||||
case "setLyricsProviders":
|
||||
@@ -953,6 +986,15 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
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:
|
||||
throw NSError(
|
||||
domain: "SpotiFLAC",
|
||||
@@ -961,6 +1003,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 {
|
||||
|
||||
@@ -17,7 +17,6 @@ final _routerProvider = Provider<GoRouter>((ref) {
|
||||
settingsProvider.select((s) => s.hasCompletedTutorial),
|
||||
);
|
||||
|
||||
// Determine initial location based on app state
|
||||
String initialLocation;
|
||||
if (isFirstLaunch) {
|
||||
initialLocation = '/setup';
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '3.7.0';
|
||||
static const String buildNumber = '103';
|
||||
static const String version = '4.1.2';
|
||||
static const String buildNumber = '119';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
static const String appName = 'SpotiFLAC';
|
||||
|
||||
/// Shows "Internal" in debug builds, actual version in release.
|
||||
static String get displayVersion => kDebugMode ? 'Internal' : version;
|
||||
|
||||
static const String appName = 'SpotiFLAC Mobile';
|
||||
static const String copyright = '© 2026 SpotiFLAC';
|
||||
|
||||
|
||||
static const String mobileAuthor = 'zarzet';
|
||||
static const String originalAuthor = 'afkarxyz';
|
||||
|
||||
|
||||
static const String githubRepo = 'zarzet/SpotiFLAC-Mobile';
|
||||
static const String githubUrl = 'https://github.com/$githubRepo';
|
||||
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
|
||||
|
||||
static const String originalGithubUrl =
|
||||
'https://github.com/afkarxyz/SpotiFLAC';
|
||||
|
||||
static const String kofiUrl = 'https://ko-fi.com/zarzet';
|
||||
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
|
||||
}
|
||||
|
||||
+1481
-1588
File diff suppressed because it is too large
Load Diff
+1294
-1270
File diff suppressed because it is too large
Load Diff
+920
-906
File diff suppressed because it is too large
Load Diff
+3343
-3916
File diff suppressed because it is too large
Load Diff
+911
-903
File diff suppressed because it is too large
Load Diff
+911
-899
File diff suppressed because it is too large
Load Diff
+1041
-1033
File diff suppressed because it is too large
Load Diff
+1000
-980
File diff suppressed because it is too large
Load Diff
+1106
-1106
File diff suppressed because it is too large
Load Diff
+915
-903
File diff suppressed because it is too large
Load Diff
+3340
-3910
File diff suppressed because it is too large
Load Diff
+1030
-1056
File diff suppressed because it is too large
Load Diff
+1045
-1041
File diff suppressed because it is too large
Load Diff
+3578
-4710
File diff suppressed because it is too large
Load Diff
+713
-1399
File diff suppressed because it is too large
Load Diff
+3431
-1551
File diff suppressed because it is too large
Load Diff
+12
-864
File diff suppressed because it is too large
Load Diff
+410
-1096
File diff suppressed because it is too large
Load Diff
+410
-1096
File diff suppressed because it is too large
Load Diff
+410
-1096
File diff suppressed because it is too large
Load Diff
+3028
-4004
File diff suppressed because it is too large
Load Diff
+486
-1172
File diff suppressed because it is too large
Load Diff
+626
-1312
File diff suppressed because it is too large
Load Diff
+414
-1100
File diff suppressed because it is too large
Load Diff
+12
-864
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user