Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18bc079632 | |||
| 4091a9c499 | |||
| 9346f2d149 | |||
| 8ab52959e8 | |||
| bad95e99c8 | |||
| dbd7fd70be | |||
| 125d070cfe | |||
| 15acf181d1 | |||
| e049f9b868 | |||
| 6a886c5276 | |||
| 1ec190bfe7 | |||
| 7ca032b3f5 | |||
| 13b917d1a0 | |||
| 961072e2ac | |||
| 8a7815268b | |||
| c7e1ffd926 | |||
| 729ab01a5f | |||
| 0a16be4395 | |||
| 47cdb5564a | |||
| f7d5a24d17 | |||
| 8daff4d0a4 | |||
| a38d66fd41 | |||
| 0cab01780d | |||
| 4afc14dee8 | |||
| 00753ffe86 | |||
| 523b1edc44 | |||
| 4966a84614 | |||
| 9247a775fa | |||
| b185b51b31 | |||
| d98960d053 | |||
| d417743654 | |||
| c4bea124fb | |||
| c37410b5de | |||
| b90c94125c | |||
| efbf5d4c5b | |||
| 35532b0c73 | |||
| 4c09b988e4 | |||
| c673581c32 | |||
| bcd718b178 | |||
| 2b9357cb6d | |||
| 26d84041c7 | |||
| 93b4047143 | |||
| a6d488696b | |||
| 3dbd131e49 | |||
| 57cb575483 | |||
| 24ef66be4c | |||
| d07a49f605 | |||
| 4eba28db7a | |||
| b73a3f8912 | |||
| 9f47f2ce85 | |||
| f2aca734a3 | |||
| 09cb637a86 | |||
| 11e7034cec | |||
| f12c18d76b | |||
| 0da39a1b8b | |||
| f29fe5054c | |||
| c8c0164964 | |||
| 52dd657913 | |||
| c30f9fe412 | |||
| bea5dd1d4a | |||
| 8726a0858a | |||
| 74bc747599 | |||
| cbc8fdcb0c | |||
| 3b79b4f1ca | |||
| 5692a76650 | |||
| 7a009ad0af | |||
| e5e75e7092 | |||
| 01b8fd2480 | |||
| ee807a44cc | |||
| c9b905eb18 | |||
| e9c7bf830e | |||
| 8bc97d5bd3 | |||
| f2c241c323 | |||
| 9c512ffe28 | |||
| 53a1da6249 | |||
| d4274e8ca8 | |||
| 49a9f12841 | |||
| d7fa040e3c | |||
| 9baa1e2088 | |||
| 482457205a | |||
| 3b2ec319e2 | |||
| a0f7e75a9a | |||
| c725e53e4c | |||
| 1d7c43a302 | |||
| df7c1c5bb7 | |||
| bb05353b7e | |||
| 7ac92d77e5 |
@@ -0,0 +1,123 @@
|
|||||||
|
name: Bug Report
|
||||||
|
description: Report a bug or unexpected behavior
|
||||||
|
title: "[Bug]: "
|
||||||
|
labels: ["bug"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for taking the time to report a bug! Please fill out the form below.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Checklist
|
||||||
|
description: Please confirm the following before submitting
|
||||||
|
options:
|
||||||
|
- label: I have searched existing issues and this bug hasn't been reported yet
|
||||||
|
required: true
|
||||||
|
- label: I am using the latest version of SpotiFLAC (Stable Version)
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Bug Description
|
||||||
|
description: A clear and concise description of what the bug is
|
||||||
|
placeholder: Describe the bug...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to Reproduce
|
||||||
|
description: Steps to reproduce the behavior
|
||||||
|
placeholder: |
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '...'
|
||||||
|
3. See error
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: Expected Behavior
|
||||||
|
description: What did you expect to happen?
|
||||||
|
placeholder: Describe what you expected...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: actual
|
||||||
|
attributes:
|
||||||
|
label: Actual Behavior
|
||||||
|
description: What actually happened?
|
||||||
|
placeholder: Describe what actually happened...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: App Version
|
||||||
|
description: Which version of SpotiFLAC are you using? (Check in Settings > About)
|
||||||
|
placeholder: "e.g., v2.2.0"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: platform
|
||||||
|
attributes:
|
||||||
|
label: Platform
|
||||||
|
description: Which platform are you using?
|
||||||
|
options:
|
||||||
|
- Android
|
||||||
|
- iOS
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: device
|
||||||
|
attributes:
|
||||||
|
label: Device & OS Version
|
||||||
|
description: What device and OS version are you using?
|
||||||
|
placeholder: "e.g., Samsung Galaxy S24, Android 14"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: download-service
|
||||||
|
attributes:
|
||||||
|
label: Download Service
|
||||||
|
description: Which download service were you using when the bug occurred?
|
||||||
|
options:
|
||||||
|
- Tidal
|
||||||
|
- Qobuz
|
||||||
|
- Amazon Music
|
||||||
|
- Deezer (search only)
|
||||||
|
- Not applicable
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Logs / Screenshots
|
||||||
|
description: |
|
||||||
|
If applicable, add logs or screenshots to help explain your problem.
|
||||||
|
|
||||||
|
**To get logs:**
|
||||||
|
1. Go to Settings > Options > Detailed Logging (turn ON)
|
||||||
|
2. Reproduce the bug
|
||||||
|
3. Go to Settings > Logs
|
||||||
|
4. Tap Share button to export logs
|
||||||
|
placeholder: Paste logs or drag & drop screenshots here...
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
description: Any other context about the problem
|
||||||
|
placeholder: Add any other context...
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: README
|
||||||
|
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
|
||||||
|
about: Check the README for setup instructions and FAQ
|
||||||
|
- name: Extension Development Guide
|
||||||
|
url: https://zarz.moe/docs
|
||||||
|
about: Documentation for building SpotiFLAC extensions
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
name: Download Issue
|
||||||
|
description: Report issues with downloading specific tracks or albums
|
||||||
|
title: "[Download]: "
|
||||||
|
labels: ["download-issue"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Having trouble downloading a specific track or album? Please provide details below.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Checklist
|
||||||
|
description: Please confirm the following before submitting
|
||||||
|
options:
|
||||||
|
- label: I have tried downloading with a different service (Tidal/Qobuz/Amazon)
|
||||||
|
required: true
|
||||||
|
- label: I am using the latest version of SpotiFLAC (Stable Version)
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: issue-type
|
||||||
|
attributes:
|
||||||
|
label: Issue Type
|
||||||
|
description: What kind of download issue are you experiencing?
|
||||||
|
options:
|
||||||
|
- Track not found on service
|
||||||
|
- Wrong track downloaded
|
||||||
|
- Download fails/errors
|
||||||
|
- Metadata incorrect
|
||||||
|
- Audio quality issue
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: spotify-url
|
||||||
|
attributes:
|
||||||
|
label: Spotify URL
|
||||||
|
description: The Spotify URL of the track/album you're trying to download
|
||||||
|
placeholder: "https://open.spotify.com/track/..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: track-info
|
||||||
|
attributes:
|
||||||
|
label: Track Info
|
||||||
|
description: Artist name and track title
|
||||||
|
placeholder: "Artist - Track Title"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: download-service
|
||||||
|
attributes:
|
||||||
|
label: Download Service
|
||||||
|
description: Which service did you try to download from?
|
||||||
|
options:
|
||||||
|
- Tidal
|
||||||
|
- Qobuz
|
||||||
|
- Amazon Music
|
||||||
|
- All services
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: search-service
|
||||||
|
attributes:
|
||||||
|
label: Search Service
|
||||||
|
description: Which search service are you using?
|
||||||
|
options:
|
||||||
|
- Spotify
|
||||||
|
- Deezer
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Description
|
||||||
|
description: Describe the issue in detail
|
||||||
|
placeholder: |
|
||||||
|
What happened? What did you expect?
|
||||||
|
If wrong track was downloaded, what track was downloaded instead?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: App Version
|
||||||
|
description: Which version of SpotiFLAC are you using?
|
||||||
|
placeholder: "e.g., v2.2.0"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: screenshots
|
||||||
|
attributes:
|
||||||
|
label: Screenshots / Logs
|
||||||
|
description: |
|
||||||
|
If applicable, add screenshots or logs.
|
||||||
|
|
||||||
|
**To get logs:**
|
||||||
|
1. Go to Settings > Options > Detailed Logging (turn ON)
|
||||||
|
2. Try downloading the track again
|
||||||
|
3. Go to Settings > Logs
|
||||||
|
4. Tap Share button to export logs
|
||||||
|
placeholder: Drag & drop screenshots or paste logs here...
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
name: Extension API Feature Request
|
||||||
|
description: Request new API features or capabilities for extension development
|
||||||
|
title: "[Extension API]: "
|
||||||
|
labels: ["enhancement", "extension-api"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for helping improve the SpotiFLAC Extension API!
|
||||||
|
This form is for extension developers who need new features or capabilities that don't exist yet.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Checklist
|
||||||
|
description: Please confirm the following before submitting
|
||||||
|
options:
|
||||||
|
- label: I have read the [Extension Development Guide](https://github.com/zarzet/SpotiFLAC-Mobile/blob/main/docs/EXTENSION_DEVELOPMENT.md)
|
||||||
|
required: true
|
||||||
|
- label: I have searched existing issues and this API feature hasn't been requested yet
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: extension_goal
|
||||||
|
attributes:
|
||||||
|
label: What are you trying to build?
|
||||||
|
description: Describe the extension or feature you're developing
|
||||||
|
placeholder: "I'm building an extension that downloads from [service name] / provides metadata from [source]..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: current_limitation
|
||||||
|
attributes:
|
||||||
|
label: Current API Limitation
|
||||||
|
description: What's missing or limiting in the current extension API?
|
||||||
|
placeholder: |
|
||||||
|
The current API doesn't support:
|
||||||
|
- [missing feature 1]
|
||||||
|
- [missing feature 2]
|
||||||
|
|
||||||
|
This prevents me from...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: proposed_api
|
||||||
|
attributes:
|
||||||
|
label: Proposed API / Feature
|
||||||
|
description: Describe the API or feature you'd like to see added
|
||||||
|
placeholder: |
|
||||||
|
I would like to have:
|
||||||
|
- A new function `api.newFeature()` that does X
|
||||||
|
- A new manifest field `newOption` that enables Y
|
||||||
|
- Access to Z capability...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: use_case
|
||||||
|
attributes:
|
||||||
|
label: Use Case Example
|
||||||
|
description: Provide a code example of how you would use this feature
|
||||||
|
placeholder: |
|
||||||
|
```javascript
|
||||||
|
// Example usage in extension code
|
||||||
|
function download(request, progressCallback) {
|
||||||
|
const result = api.proposedFeature(params);
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: api_category
|
||||||
|
attributes:
|
||||||
|
label: API Category
|
||||||
|
description: What category does this feature fall under?
|
||||||
|
options:
|
||||||
|
- HTTP/Network API
|
||||||
|
- File System API
|
||||||
|
- Storage API
|
||||||
|
- FFmpeg/Audio Processing
|
||||||
|
- Manifest Options
|
||||||
|
- Runtime Functions
|
||||||
|
- UI Integration
|
||||||
|
- Authentication
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: priority
|
||||||
|
attributes:
|
||||||
|
label: How critical is this for your extension?
|
||||||
|
options:
|
||||||
|
- Blocker - Cannot build my extension without this
|
||||||
|
- High - Major functionality depends on this
|
||||||
|
- Medium - Would significantly improve my extension
|
||||||
|
- Low - Nice to have
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: workaround
|
||||||
|
attributes:
|
||||||
|
label: Current Workaround
|
||||||
|
description: Are you using any workaround currently? If so, describe it.
|
||||||
|
placeholder: "Currently I'm working around this by..."
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
description: Add any other context, links to similar APIs, or examples from other platforms
|
||||||
|
placeholder: "Similar feature in other platforms: ..."
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
name: Feature Request
|
||||||
|
description: Suggest a new feature or improvement
|
||||||
|
title: "[Feature]: "
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for suggesting a feature! Please fill out the form below.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Checklist
|
||||||
|
description: Please confirm the following before submitting
|
||||||
|
options:
|
||||||
|
- label: I have searched existing issues and this feature hasn't been requested yet
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: Problem / Motivation
|
||||||
|
description: Is your feature request related to a problem? Please describe.
|
||||||
|
placeholder: "A clear description of what the problem is. Ex: I'm always frustrated when..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: Proposed Solution
|
||||||
|
description: Describe the solution you'd like
|
||||||
|
placeholder: A clear description of what you want to happen...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives Considered
|
||||||
|
description: Describe any alternative solutions or features you've considered
|
||||||
|
placeholder: Other approaches you've thought about...
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: category
|
||||||
|
attributes:
|
||||||
|
label: Category
|
||||||
|
description: What category does this feature fall under?
|
||||||
|
options:
|
||||||
|
- UI/UX Improvement
|
||||||
|
- Download Feature
|
||||||
|
- New Service Integration
|
||||||
|
- Metadata/Tagging
|
||||||
|
- Performance
|
||||||
|
- Settings/Configuration
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
description: Add any other context, mockups, or screenshots about the feature request
|
||||||
|
placeholder: Add any other context or screenshots...
|
||||||
@@ -3,13 +3,13 @@ name: Release
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- "v*"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
description: 'Version tag (e.g., v1.0.0)'
|
description: "Version tag (e.g., v1.0.0)"
|
||||||
required: true
|
required: true
|
||||||
default: 'v1.0.0'
|
default: "v1.0.0"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Get version first (quick job)
|
# Get version first (quick job)
|
||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
VERSION="${GITHUB_REF#refs/tags/}"
|
VERSION="${GITHUB_REF#refs/tags/}"
|
||||||
fi
|
fi
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
# Check if version contains -preview, -beta, -rc, or -alpha (NOT -hotfix)
|
# Check if version contains -preview, -beta, -rc, or -alpha (NOT -hotfix)
|
||||||
VERSION_LOWER=$(echo "$VERSION" | tr '[:upper:]' '[:lower:]')
|
VERSION_LOWER=$(echo "$VERSION" | tr '[:upper:]' '[:lower:]')
|
||||||
if [[ "$VERSION_LOWER" == *"-preview"* ]] || [[ "$VERSION_LOWER" == *"-beta"* ]] || [[ "$VERSION_LOWER" == *"-rc"* ]] || [[ "$VERSION_LOWER" == *"-alpha"* ]]; then
|
if [[ "$VERSION_LOWER" == *"-preview"* ]] || [[ "$VERSION_LOWER" == *"-beta"* ]] || [[ "$VERSION_LOWER" == *"-rc"* ]] || [[ "$VERSION_LOWER" == *"-alpha"* ]]; then
|
||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
build-android:
|
build-android:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: get-version
|
needs: get-version
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Free disk space
|
- name: Free disk space
|
||||||
run: |
|
run: |
|
||||||
@@ -65,13 +65,13 @@ jobs:
|
|||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: "temurin"
|
||||||
java-version: '17'
|
java-version: "17"
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.21'
|
go-version: "1.21"
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
# Cache Gradle for faster builds
|
# Cache Gradle for faster builds
|
||||||
@@ -85,7 +85,20 @@ jobs:
|
|||||||
restore-keys: gradle-${{ runner.os }}-
|
restore-keys: gradle-${{ runner.os }}-
|
||||||
|
|
||||||
- name: Install Android SDK & NDK
|
- name: Install Android SDK & NDK
|
||||||
uses: android-actions/setup-android@v3
|
run: |
|
||||||
|
# Use pre-installed Android SDK on GitHub runners
|
||||||
|
echo "ANDROID_HOME=$ANDROID_HOME"
|
||||||
|
echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT"
|
||||||
|
|
||||||
|
# Accept licenses
|
||||||
|
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
||||||
|
|
||||||
|
# Install NDK r27d LTS (required for 16KB page size support on Android 15+)
|
||||||
|
# Platform android-36 and build-tools 36.0.0 for targetSdk 36 (Android 16)
|
||||||
|
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;27.3.13750724" "platforms;android-36" "build-tools;36.0.0"
|
||||||
|
|
||||||
|
# Set NDK path
|
||||||
|
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/27.3.13750724" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Install gomobile
|
- name: Install gomobile
|
||||||
run: |
|
run: |
|
||||||
@@ -103,7 +116,7 @@ jobs:
|
|||||||
- name: Setup Flutter
|
- name: Setup Flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
channel: 'stable'
|
channel: "stable"
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: Get Flutter dependencies
|
- name: Get Flutter dependencies
|
||||||
@@ -113,7 +126,14 @@ jobs:
|
|||||||
run: dart run flutter_launcher_icons
|
run: dart run flutter_launcher_icons
|
||||||
|
|
||||||
- name: Build APK (Release - unsigned)
|
- name: Build APK (Release - unsigned)
|
||||||
run: flutter build apk --release --split-per-abi
|
run: |
|
||||||
|
flutter build apk --release --split-per-abi || true
|
||||||
|
# Verify APKs were created
|
||||||
|
ls -la build/app/outputs/flutter-apk/
|
||||||
|
if [ ! -f "build/app/outputs/flutter-apk/app-arm64-v8a-release.apk" ]; then
|
||||||
|
echo "ERROR: APK not found!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Sign APKs
|
- name: Sign APKs
|
||||||
uses: r0adkll/sign-android-release@v1
|
uses: r0adkll/sign-android-release@v1
|
||||||
@@ -125,7 +145,7 @@ jobs:
|
|||||||
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
|
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||||
env:
|
env:
|
||||||
BUILD_TOOLS_VERSION: "34.0.0"
|
BUILD_TOOLS_VERSION: "36.0.0"
|
||||||
|
|
||||||
- name: Rename APKs
|
- name: Rename APKs
|
||||||
run: |
|
run: |
|
||||||
@@ -145,8 +165,8 @@ jobs:
|
|||||||
|
|
||||||
build-ios:
|
build-ios:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
needs: get-version # Only depends on version, NOT android build!
|
needs: get-version # Only depends on version, NOT android build!
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -154,7 +174,7 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.21'
|
go-version: "1.21"
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
# Cache CocoaPods
|
# Cache CocoaPods
|
||||||
@@ -182,51 +202,51 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
ls -la ios/Frameworks/
|
ls -la ios/Frameworks/
|
||||||
ls -la ios/Frameworks/Gobackend.xcframework/ || (echo "ERROR: XCFramework not found!" && exit 1)
|
ls -la ios/Frameworks/Gobackend.xcframework/ || (echo "ERROR: XCFramework not found!" && exit 1)
|
||||||
|
|
||||||
- name: Add XCFramework to Xcode project
|
- name: Add XCFramework to Xcode project
|
||||||
run: |
|
run: |
|
||||||
# Install xcodeproj gem for modifying Xcode project
|
# Install xcodeproj gem for modifying Xcode project
|
||||||
sudo gem install xcodeproj
|
sudo gem install xcodeproj
|
||||||
|
|
||||||
# Create Ruby script to add framework
|
# Create Ruby script to add framework
|
||||||
cat > add_framework.rb << 'EOF'
|
cat > add_framework.rb << 'EOF'
|
||||||
require 'xcodeproj'
|
require 'xcodeproj'
|
||||||
|
|
||||||
project_path = 'ios/Runner.xcodeproj'
|
project_path = 'ios/Runner.xcodeproj'
|
||||||
project = Xcodeproj::Project.open(project_path)
|
project = Xcodeproj::Project.open(project_path)
|
||||||
|
|
||||||
# Get the main target
|
# Get the main target
|
||||||
target = project.targets.find { |t| t.name == 'Runner' }
|
target = project.targets.find { |t| t.name == 'Runner' }
|
||||||
|
|
||||||
# Get or create Frameworks group
|
# Get or create Frameworks group
|
||||||
frameworks_group = project.main_group.find_subpath('Frameworks', true)
|
frameworks_group = project.main_group.find_subpath('Frameworks', true)
|
||||||
frameworks_group ||= project.main_group.new_group('Frameworks')
|
frameworks_group ||= project.main_group.new_group('Frameworks')
|
||||||
|
|
||||||
# Add XCFramework reference
|
# Add XCFramework reference
|
||||||
framework_path = 'Frameworks/Gobackend.xcframework'
|
framework_path = 'Frameworks/Gobackend.xcframework'
|
||||||
framework_ref = frameworks_group.new_file(framework_path, :project)
|
framework_ref = frameworks_group.new_file(framework_path, :project)
|
||||||
|
|
||||||
# Add to frameworks build phase
|
# Add to frameworks build phase
|
||||||
frameworks_build_phase = target.frameworks_build_phase
|
frameworks_build_phase = target.frameworks_build_phase
|
||||||
frameworks_build_phase.add_file_reference(framework_ref)
|
frameworks_build_phase.add_file_reference(framework_ref)
|
||||||
|
|
||||||
# Add to embed frameworks build phase
|
# Add to embed frameworks build phase
|
||||||
embed_phase = target.build_phases.find { |p| p.is_a?(Xcodeproj::Project::Object::PBXCopyFilesBuildPhase) && p.name == 'Embed Frameworks' }
|
embed_phase = target.build_phases.find { |p| p.is_a?(Xcodeproj::Project::Object::PBXCopyFilesBuildPhase) && p.name == 'Embed Frameworks' }
|
||||||
if embed_phase
|
if embed_phase
|
||||||
build_file = embed_phase.add_file_reference(framework_ref)
|
build_file = embed_phase.add_file_reference(framework_ref)
|
||||||
build_file.settings = { 'ATTRIBUTES' => ['CodeSignOnCopy', 'RemoveHeadersOnCopy'] }
|
build_file.settings = { 'ATTRIBUTES' => ['CodeSignOnCopy', 'RemoveHeadersOnCopy'] }
|
||||||
end
|
end
|
||||||
|
|
||||||
project.save
|
project.save
|
||||||
puts "Successfully added Gobackend.xcframework to Xcode project"
|
puts "Successfully added Gobackend.xcframework to Xcode project"
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
ruby add_framework.rb
|
ruby add_framework.rb
|
||||||
|
|
||||||
- name: Setup Flutter
|
- name: Setup Flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
channel: 'stable'
|
channel: "stable"
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
# Swap pubspec for iOS build (includes ffmpeg_kit_flutter)
|
# Swap pubspec for iOS build (includes ffmpeg_kit_flutter)
|
||||||
@@ -253,18 +273,44 @@ jobs:
|
|||||||
run: dart run flutter_launcher_icons
|
run: dart run flutter_launcher_icons
|
||||||
|
|
||||||
- name: Build iOS (unsigned)
|
- name: Build iOS (unsigned)
|
||||||
run: flutter build ios --release --no-codesign
|
run: |
|
||||||
|
# Build Flutter iOS without codesigning
|
||||||
|
flutter build ios --release --no-codesign --config-only
|
||||||
|
|
||||||
|
# Use xcodebuild with code signing disabled
|
||||||
|
cd ios
|
||||||
|
xcodebuild -workspace Runner.xcworkspace \
|
||||||
|
-scheme Runner \
|
||||||
|
-configuration Release \
|
||||||
|
-sdk iphoneos \
|
||||||
|
-destination 'generic/platform=iOS' \
|
||||||
|
-archivePath build/Runner.xcarchive \
|
||||||
|
archive \
|
||||||
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
CODE_SIGNING_REQUIRED=NO \
|
||||||
|
CODE_SIGN_IDENTITY="" \
|
||||||
|
DEVELOPMENT_TEAM=""
|
||||||
|
|
||||||
- name: Create IPA
|
- name: Create IPA
|
||||||
run: |
|
run: |
|
||||||
VERSION=${{ needs.get-version.outputs.version }}
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
mkdir -p build/ios/ipa
|
mkdir -p build/ios/ipa
|
||||||
cd build/ios/iphoneos
|
cd ios/build/Runner.xcarchive/Products/Applications
|
||||||
mkdir Payload
|
mkdir Payload
|
||||||
cp -r Runner.app Payload/
|
cp -r Runner.app Payload/
|
||||||
zip -r ../ipa/SpotiFLAC-${VERSION}-ios-unsigned.ipa Payload
|
# Use absolute path to avoid relative path issues
|
||||||
|
zip -r $GITHUB_WORKSPACE/build/ios/ipa/SpotiFLAC-${VERSION}-ios-unsigned.ipa Payload
|
||||||
rm -rf Payload
|
rm -rf Payload
|
||||||
|
|
||||||
|
- name: Verify IPA created
|
||||||
|
run: |
|
||||||
|
ls -la build/ios/ipa/
|
||||||
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
|
if [ ! -f "build/ios/ipa/SpotiFLAC-${VERSION}-ios-unsigned.ipa" ]; then
|
||||||
|
echo "ERROR: IPA not created!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Upload IPA artifact
|
- name: Upload IPA artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -276,7 +322,7 @@ jobs:
|
|||||||
needs: [get-version, build-android, build-ios]
|
needs: [get-version, build-android, build-ios]
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -286,21 +332,23 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
VERSION=${{ needs.get-version.outputs.version }}
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
VERSION_NUM=${VERSION#v} # Remove 'v' prefix
|
VERSION_NUM=${VERSION#v} # Remove 'v' prefix
|
||||||
|
|
||||||
echo "Looking for version: $VERSION_NUM"
|
echo "Looking for version: $VERSION_NUM"
|
||||||
|
|
||||||
# Extract changelog section for this version using sed
|
# Extract changelog section for this version using sed
|
||||||
# Find the line with version, then print until next version header or end
|
# 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)
|
CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md)
|
||||||
|
|
||||||
# If no changelog found, use default message
|
# If no changelog found, use default message
|
||||||
if [ -z "$CHANGELOG" ]; then
|
if [ -z "$CHANGELOG" ]; then
|
||||||
echo "No changelog found for version $VERSION_NUM"
|
echo "No changelog found for version $VERSION_NUM"
|
||||||
CHANGELOG="See CHANGELOG.md for details."
|
CHANGELOG="See CHANGELOG.md for details."
|
||||||
else
|
else
|
||||||
echo "Found changelog content"
|
echo "Found changelog content"
|
||||||
|
# Remove trailing --- separator if present (CHANGELOG uses --- between versions)
|
||||||
|
CHANGELOG=$(echo "$CHANGELOG" | sed '/^---$/d')
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Save to file for multiline support
|
# Save to file for multiline support
|
||||||
echo "$CHANGELOG" > /tmp/changelog.txt
|
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||||
echo "Extracted changelog:"
|
echo "Extracted changelog:"
|
||||||
@@ -322,32 +370,34 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
VERSION=${{ needs.get-version.outputs.version }}
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
cat > /tmp/release_body.txt << 'HEADER'
|
cat > /tmp/release_body.txt << 'HEADER'
|
||||||
## SpotiFLAC $VERSION
|
|
||||||
|
|
||||||
Download Spotify tracks in FLAC quality from Tidal, Qobuz & Amazon Music.
|
|
||||||
|
|
||||||
### What's New
|
### What's New
|
||||||
HEADER
|
HEADER
|
||||||
|
|
||||||
# Replace $VERSION in header
|
|
||||||
sed -i "s/\$VERSION/$VERSION/g" /tmp/release_body.txt
|
|
||||||
|
|
||||||
cat /tmp/changelog.txt >> /tmp/release_body.txt
|
cat /tmp/changelog.txt >> /tmp/release_body.txt
|
||||||
|
|
||||||
|
REPO_OWNER="${{ github.repository_owner }}"
|
||||||
|
REPO_NAME="${{ github.event.repository.name }}"
|
||||||
|
|
||||||
cat >> /tmp/release_body.txt << FOOTER
|
cat >> /tmp/release_body.txt << FOOTER
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Downloads
|
### Downloads
|
||||||
- **Android (arm64)**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended)
|
|
||||||
- **Android (arm32)**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices)
|
#### Android
|
||||||
|
- **arm64**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended for modern devices)
|
||||||
|
- **arm32**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices)
|
||||||
|
|
||||||
|
#### iOS
|
||||||
- **iOS**: \`SpotiFLAC-${VERSION}-ios-unsigned.ipa\` (sideload required)
|
- **iOS**: \`SpotiFLAC-${VERSION}-ios-unsigned.ipa\` (sideload required)
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
**Android**: Enable "Install from unknown sources" and install the APK
|
**Android**: Enable "Install from unknown sources" and install the APK
|
||||||
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
||||||
|
|
||||||
|
  
|
||||||
FOOTER
|
FOOTER
|
||||||
|
|
||||||
echo "Release body:"
|
echo "Release body:"
|
||||||
cat /tmp/release_body.txt
|
cat /tmp/release_body.txt
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ Thumbs.db
|
|||||||
# Reference folder (development only)
|
# Reference folder (development only)
|
||||||
referensi/
|
referensi/
|
||||||
|
|
||||||
# Development notes
|
# Documentation (development only, published separately)
|
||||||
COMPARISON_PC_vs_ANDROID.md
|
docs/
|
||||||
|
|
||||||
# Old spotiflac_android folder (moved to root)
|
# Old spotiflac_android folder (moved to root)
|
||||||
spotiflac_android/
|
spotiflac_android/
|
||||||
@@ -38,7 +38,7 @@ go_backend/*.xcframework/
|
|||||||
|
|
||||||
# Android
|
# Android
|
||||||
android/.gradle/
|
android/.gradle/
|
||||||
android/app/libs/
|
android/app/libs/gobackend.aar
|
||||||
android/local.properties
|
android/local.properties
|
||||||
android/*.iml
|
android/*.iml
|
||||||
android/key.properties
|
android/key.properties
|
||||||
@@ -52,3 +52,10 @@ ios/Pods/
|
|||||||
ios/.symlinks/
|
ios/.symlinks/
|
||||||
ios/Flutter/Flutter.framework/
|
ios/Flutter/Flutter.framework/
|
||||||
ios/Flutter/Flutter.podspec
|
ios/Flutter/Flutter.podspec
|
||||||
|
android/app/libs/gobackend-sources.jar
|
||||||
|
|
||||||
|
# Extension folder
|
||||||
|
extension/
|
||||||
|
|
||||||
|
# Agent instructions
|
||||||
|
AGENTS.md
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
|
[](https://www.virustotal.com/gui/file/09c6260e9ebaf2ff0d15f30deda939642f41887f11aad602ac697cb37fa0308c/)
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
@@ -22,14 +23,45 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
|||||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
## Metadata Source
|
||||||
|
|
||||||
|
SpotiFLAC supports two metadata sources for searching tracks:
|
||||||
|
|
||||||
|
| Source | Pros | Cons |
|
||||||
|
|--------|------|------|
|
||||||
|
| **Deezer** (Default) | No developer account needed, rate limit per user IP | Slightly less comprehensive catalog |
|
||||||
|
| **Spotify** | More comprehensive catalog, better search results | Requires developer API credentials to avoid rate limiting |
|
||||||
|
|
||||||
|
### Using Spotify
|
||||||
|
To use Spotify as your search source without hitting rate limits:
|
||||||
|
1. Create a Spotify Developer account at [developer.spotify.com](https://developer.spotify.com)
|
||||||
|
2. Create an app to get your Client ID and Client Secret
|
||||||
|
3. Go to **Settings > Options > Spotify API > Change from Deezer to Spotify > Input Custom Credentials**
|
||||||
|
4. Enter your Client ID and Secret
|
||||||
|
5. Change **Search Source** to Spotify
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
### 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**
|
||||||
|
|
||||||
|
### Developing Extensions
|
||||||
|
Want to create your own extension? Check out the [Extension Development Guide](https://zarz.moe/docs) for complete documentation.
|
||||||
|
|
||||||
## Other project
|
## Other project
|
||||||
|
|
||||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
||||||
|
|
||||||
## Disclaimer
|
[](https://ko-fi.com/zarzet)
|
||||||
|
|
||||||
> **iOS Support**: This app is primarily tested on Android. iOS support is experimental and may have bugs — the developer is too poor to afford an iPhone for proper testing. If you encounter issues on iOS, please report them!
|
## Disclaimer
|
||||||
|
|
||||||
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
import java.io.FileInputStream
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
@@ -7,9 +10,9 @@ plugins {
|
|||||||
|
|
||||||
// Load keystore properties for local builds
|
// Load keystore properties for local builds
|
||||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
val keystoreProperties = java.util.Properties()
|
val keystoreProperties = Properties()
|
||||||
if (keystorePropertiesFile.exists()) {
|
if (keystorePropertiesFile.exists()) {
|
||||||
keystoreProperties.load(java.io.FileInputStream(keystorePropertiesFile))
|
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -32,10 +35,10 @@ android {
|
|||||||
signingConfigs {
|
signingConfigs {
|
||||||
if (keystorePropertiesFile.exists()) {
|
if (keystorePropertiesFile.exists()) {
|
||||||
create("release") {
|
create("release") {
|
||||||
keyAlias = keystoreProperties["keyAlias"] as String
|
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||||
keyPassword = keystoreProperties["keyPassword"] as String
|
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||||
storeFile = file(keystoreProperties["storeFile"] as String)
|
storeFile = file(keystoreProperties.getProperty("storeFile"))
|
||||||
storePassword = keystoreProperties["storePassword"] as String
|
storePassword = keystoreProperties.getProperty("storePassword")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,7 +46,7 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.zarz.spotiflac"
|
applicationId = "com.zarz.spotiflac"
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = 34
|
targetSdk = 36
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
multiDexEnabled = true
|
multiDexEnabled = true
|
||||||
@@ -94,8 +97,10 @@ repositories {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
implementation(files("libs/gobackend.aar"))
|
|
||||||
implementation(files("libs/ffmpeg-kit-with-lame.aar"))
|
// Include all AAR and JAR files from libs folder
|
||||||
|
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
||||||
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,14 @@
|
|||||||
-keep class io.flutter.** { *; }
|
-keep class io.flutter.** { *; }
|
||||||
-keep class io.flutter.plugins.** { *; }
|
-keep class io.flutter.plugins.** { *; }
|
||||||
|
|
||||||
|
# Ignore missing Play Core classes (not used, but referenced by Flutter)
|
||||||
|
-dontwarn com.google.android.play.core.splitcompat.**
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.**
|
||||||
|
-dontwarn com.google.android.play.core.tasks.**
|
||||||
|
|
||||||
|
# Ignore missing javax.xml.stream (not used on Android)
|
||||||
|
-dontwarn javax.xml.stream.**
|
||||||
|
|
||||||
# Go backend (gobackend.aar)
|
# Go backend (gobackend.aar)
|
||||||
-keep class gobackend.** { *; }
|
-keep class gobackend.** { *; }
|
||||||
-keep class go.** { *; }
|
-keep class go.** { *; }
|
||||||
@@ -14,6 +22,9 @@
|
|||||||
-keep class com.arthenica.ffmpegkit.** { *; }
|
-keep class com.arthenica.ffmpegkit.** { *; }
|
||||||
-keep class com.arthenica.smartexception.** { *; }
|
-keep class com.arthenica.smartexception.** { *; }
|
||||||
|
|
||||||
|
# Apache Tika (if used by FFmpeg)
|
||||||
|
-dontwarn org.apache.tika.**
|
||||||
|
|
||||||
# Keep native methods
|
# Keep native methods
|
||||||
-keepclasseswithmembernames class * {
|
-keepclasseswithmembernames class * {
|
||||||
native <methods>;
|
native <methods>;
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import androidx.core.app.NotificationCompat
|
|||||||
/**
|
/**
|
||||||
* Foreground service to keep downloads running when app is in background.
|
* Foreground service to keep downloads running when app is in background.
|
||||||
* This prevents Android from killing the download process or throttling network.
|
* This prevents Android from killing the download process or throttling network.
|
||||||
|
*
|
||||||
|
* Note: Android 15+ (API 35+) has a 6-hour timeout for dataSync foreground services.
|
||||||
|
* The service will be stopped automatically after 6 hours of cumulative runtime in 24 hours.
|
||||||
*/
|
*/
|
||||||
class DownloadService : Service() {
|
class DownloadService : Service() {
|
||||||
|
|
||||||
@@ -106,6 +109,19 @@ class DownloadService : Service() {
|
|||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the foreground service timeout is reached (Android 15+, API 35+).
|
||||||
|
* dataSync services have a 6-hour limit in a 24-hour period.
|
||||||
|
* 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()
|
||||||
|
}
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
private fun createNotificationChannel() {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val channel = NotificationChannel(
|
val channel = NotificationChannel(
|
||||||
|
|||||||
@@ -180,6 +180,13 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
"readFileMetadata" -> {
|
||||||
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.readFileMetadata(filePath)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
"startDownloadService" -> {
|
"startDownloadService" -> {
|
||||||
val trackName = call.argument<String>("track_name") ?: ""
|
val trackName = call.argument<String>("track_name") ?: ""
|
||||||
val artistName = call.argument<String>("artist_name") ?: ""
|
val artistName = call.argument<String>("artist_name") ?: ""
|
||||||
@@ -211,6 +218,418 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
"hasSpotifyCredentials" -> {
|
||||||
|
val hasCredentials = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.checkSpotifyCredentials()
|
||||||
|
}
|
||||||
|
result.success(hasCredentials)
|
||||||
|
}
|
||||||
|
"preWarmTrackCache" -> {
|
||||||
|
val tracksJson = call.argument<String>("tracks") ?: "[]"
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.preWarmTrackCacheJSON(tracksJson)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"getTrackCacheSize" -> {
|
||||||
|
val size = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getTrackCacheSize()
|
||||||
|
}
|
||||||
|
result.success(size.toInt())
|
||||||
|
}
|
||||||
|
"clearTrackCache" -> {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.clearTrackIDCache()
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
// Deezer API methods
|
||||||
|
"searchDeezerAll" -> {
|
||||||
|
val query = call.argument<String>("query") ?: ""
|
||||||
|
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||||
|
val artistLimit = call.argument<Int>("artist_limit") ?: 3
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.searchDeezerAll(query, trackLimit.toLong(), artistLimit.toLong())
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"getDeezerMetadata" -> {
|
||||||
|
val resourceType = call.argument<String>("resource_type") ?: ""
|
||||||
|
val resourceId = call.argument<String>("resource_id") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getDeezerMetadata(resourceType, resourceId)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"parseDeezerUrl" -> {
|
||||||
|
val url = call.argument<String>("url") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.parseDeezerURLExport(url)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"searchDeezerByISRC" -> {
|
||||||
|
val isrc = call.argument<String>("isrc") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.searchDeezerByISRC(isrc)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"convertSpotifyToDeezer" -> {
|
||||||
|
val resourceType = call.argument<String>("resource_type") ?: ""
|
||||||
|
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.convertSpotifyToDeezer(resourceType, spotifyId)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"getSpotifyMetadataWithFallback" -> {
|
||||||
|
val url = call.argument<String>("url") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getSpotifyMetadataWithDeezerFallback(url)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
// Log methods
|
||||||
|
"getLogs" -> {
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getLogs()
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"getLogsSince" -> {
|
||||||
|
val index = call.argument<Int>("index") ?: 0
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getLogsSince(index.toLong())
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"clearLogs" -> {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.clearLogs()
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"getLogCount" -> {
|
||||||
|
val count = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getLogCount()
|
||||||
|
}
|
||||||
|
result.success(count.toInt())
|
||||||
|
}
|
||||||
|
"setLoggingEnabled" -> {
|
||||||
|
val enabled = call.argument<Boolean>("enabled") ?: false
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.setLoggingEnabled(enabled)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
// Extension System methods
|
||||||
|
"initExtensionSystem" -> {
|
||||||
|
val extensionsDir = call.argument<String>("extensions_dir") ?: ""
|
||||||
|
val dataDir = call.argument<String>("data_dir") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.initExtensionSystem(extensionsDir, dataDir)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"loadExtensionsFromDir" -> {
|
||||||
|
val dirPath = call.argument<String>("dir_path") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.loadExtensionsFromDir(dirPath)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"loadExtensionFromPath" -> {
|
||||||
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.loadExtensionFromPath(filePath)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"unloadExtension" -> {
|
||||||
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.unloadExtensionByID(extensionId)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"removeExtension" -> {
|
||||||
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.removeExtensionByID(extensionId)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"upgradeExtension" -> {
|
||||||
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.upgradeExtensionFromPath(filePath)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"checkExtensionUpgrade" -> {
|
||||||
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.checkExtensionUpgradeFromPath(filePath)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"getInstalledExtensions" -> {
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getInstalledExtensions()
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"setExtensionEnabled" -> {
|
||||||
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
|
val enabled = call.argument<Boolean>("enabled") ?: false
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.setExtensionEnabledByID(extensionId, enabled)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"setProviderPriority" -> {
|
||||||
|
val priorityJson = call.argument<String>("priority") ?: "[]"
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.setProviderPriorityJSON(priorityJson)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"getProviderPriority" -> {
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getProviderPriorityJSON()
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"setMetadataProviderPriority" -> {
|
||||||
|
val priorityJson = call.argument<String>("priority") ?: "[]"
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.setMetadataProviderPriorityJSON(priorityJson)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"getMetadataProviderPriority" -> {
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getMetadataProviderPriorityJSON()
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"getExtensionSettings" -> {
|
||||||
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getExtensionSettingsJSON(extensionId)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"setExtensionSettings" -> {
|
||||||
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
|
val settingsJson = call.argument<String>("settings") ?: "{}"
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.setExtensionSettingsJSON(extensionId, settingsJson)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"searchTracksWithExtensions" -> {
|
||||||
|
val query = call.argument<String>("query") ?: ""
|
||||||
|
val limit = call.argument<Int>("limit") ?: 20
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.searchTracksWithExtensionsJSON(query, limit.toLong())
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"downloadWithExtensions" -> {
|
||||||
|
val requestJson = call.arguments as String
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.downloadWithExtensionsJSON(requestJson)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"removeExtension" -> {
|
||||||
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.removeExtensionByID(extensionId)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"cleanupExtensions" -> {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.cleanupExtensions()
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
// Extension Auth API methods
|
||||||
|
"getExtensionPendingAuth" -> {
|
||||||
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getExtensionPendingAuthJSON(extensionId)
|
||||||
|
}
|
||||||
|
if (response.isNullOrEmpty()) {
|
||||||
|
result.success(null)
|
||||||
|
} else {
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"setExtensionAuthCode" -> {
|
||||||
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
|
val authCode = call.argument<String>("auth_code") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.setExtensionAuthCodeByID(extensionId, authCode)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"setExtensionTokens" -> {
|
||||||
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
|
val accessToken = call.argument<String>("access_token") ?: ""
|
||||||
|
val refreshToken = call.argument<String>("refresh_token") ?: ""
|
||||||
|
val expiresIn = call.argument<Int>("expires_in") ?: 0
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.setExtensionTokensByID(extensionId, accessToken, refreshToken, expiresIn.toLong())
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"clearExtensionPendingAuth" -> {
|
||||||
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.clearExtensionPendingAuthByID(extensionId)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"isExtensionAuthenticated" -> {
|
||||||
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
|
val isAuth = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.isExtensionAuthenticatedByID(extensionId)
|
||||||
|
}
|
||||||
|
result.success(isAuth)
|
||||||
|
}
|
||||||
|
"getAllPendingAuthRequests" -> {
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getAllPendingAuthRequestsJSON()
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
// Extension FFmpeg API
|
||||||
|
"getPendingFFmpegCommand" -> {
|
||||||
|
val commandId = call.argument<String>("command_id") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getPendingFFmpegCommandJSON(commandId)
|
||||||
|
}
|
||||||
|
if (response.isNullOrEmpty()) {
|
||||||
|
result.success(null)
|
||||||
|
} else {
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"setFFmpegCommandResult" -> {
|
||||||
|
val commandId = call.argument<String>("command_id") ?: ""
|
||||||
|
val success = call.argument<Boolean>("success") ?: false
|
||||||
|
val output = call.argument<String>("output") ?: ""
|
||||||
|
val error = call.argument<String>("error") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.setFFmpegCommandResultByID(commandId, success, output, error)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"getAllPendingFFmpegCommands" -> {
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getAllPendingFFmpegCommandsJSON()
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
// Extension Custom Search API
|
||||||
|
"customSearchWithExtension" -> {
|
||||||
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
|
val query = call.argument<String>("query") ?: ""
|
||||||
|
val optionsJson = call.argument<String>("options") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.customSearchWithExtensionJSON(extensionId, query, optionsJson)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"getSearchProviders" -> {
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getSearchProvidersJSON()
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
// Extension URL Handler API
|
||||||
|
"handleURLWithExtension" -> {
|
||||||
|
val url = call.argument<String>("url") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.handleURLWithExtensionJSON(url)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"findURLHandler" -> {
|
||||||
|
val url = call.argument<String>("url") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.findURLHandlerJSON(url)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"getURLHandlers" -> {
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getURLHandlersJSON()
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
// Extension Post-Processing API
|
||||||
|
"runPostProcessing" -> {
|
||||||
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
|
val metadataJson = call.argument<String>("metadata") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.runPostProcessingJSON(filePath, metadataJson)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"getPostProcessingProviders" -> {
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getPostProcessingProvidersJSON()
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
// Extension Store
|
||||||
|
"initExtensionStore" -> {
|
||||||
|
val cacheDir = call.argument<String>("cache_dir") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.initExtensionStoreJSON(cacheDir)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"getStoreExtensions" -> {
|
||||||
|
val forceRefresh = call.argument<Boolean>("force_refresh") ?: false
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getStoreExtensionsJSON(forceRefresh)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"searchStoreExtensions" -> {
|
||||||
|
val query = call.argument<String>("query") ?: ""
|
||||||
|
val category = call.argument<String>("category") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.searchStoreExtensionsJSON(query, category)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"getStoreCategories" -> {
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getStoreCategoriesJSON()
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"downloadStoreExtension" -> {
|
||||||
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
|
val destDir = call.argument<String>("dest_dir") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.downloadStoreExtensionJSON(extensionId, destDir)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"clearStoreCache" -> {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.clearStoreCacheJSON()
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 932 B |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 651 B |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 70 KiB |
@@ -105,6 +105,78 @@ class FFmpegServiceIOS {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Embed metadata and cover art to FLAC file
|
||||||
|
/// Returns the file path on success, null on failure
|
||||||
|
static Future<String?> embedMetadata({
|
||||||
|
required String flacPath,
|
||||||
|
String? coverPath,
|
||||||
|
Map<String, String>? metadata,
|
||||||
|
}) async {
|
||||||
|
final tempOutput = '$flacPath.tmp';
|
||||||
|
|
||||||
|
// Construct command
|
||||||
|
final StringBuffer cmdBuffer = StringBuffer();
|
||||||
|
cmdBuffer.write('-i "$flacPath" ');
|
||||||
|
|
||||||
|
// Add cover input if available
|
||||||
|
if (coverPath != null) {
|
||||||
|
cmdBuffer.write('-i "$coverPath" ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map audio stream
|
||||||
|
cmdBuffer.write('-map 0:a ');
|
||||||
|
|
||||||
|
// Map cover stream if available
|
||||||
|
if (coverPath != null) {
|
||||||
|
cmdBuffer.write('-map 1:0 ');
|
||||||
|
cmdBuffer.write('-c:v copy ');
|
||||||
|
cmdBuffer.write('-disposition:v attached_pic ');
|
||||||
|
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
||||||
|
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy audio codec (don't re-encode)
|
||||||
|
cmdBuffer.write('-c:a copy ');
|
||||||
|
|
||||||
|
// Add text metadata
|
||||||
|
if (metadata != null) {
|
||||||
|
metadata.forEach((key, value) {
|
||||||
|
// Sanitize value: escape double quotes
|
||||||
|
final sanitizedValue = value.replaceAll('"', '\\"');
|
||||||
|
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdBuffer.write('"$tempOutput" -y');
|
||||||
|
|
||||||
|
final command = cmdBuffer.toString();
|
||||||
|
_log.d('Executing FFmpeg command: $command');
|
||||||
|
|
||||||
|
final result = await _execute(command);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
try {
|
||||||
|
await File(flacPath).delete();
|
||||||
|
await File(tempOutput).rename(flacPath);
|
||||||
|
return flacPath;
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Failed to replace file after metadata embed: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up temp file if exists
|
||||||
|
try {
|
||||||
|
final tempFile = File(tempOutput);
|
||||||
|
if (await tempFile.exists()) {
|
||||||
|
await tempFile.delete();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
_log.e('Metadata/Cover embed failed: ${result.output}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if FFmpeg is available
|
/// Check if FFmpeg is available
|
||||||
static Future<bool> isAvailable() async {
|
static Future<bool> isAvailable() async {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -10,15 +11,26 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC)
|
// AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC)
|
||||||
type AmazonDownloader struct {
|
type AmazonDownloader struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
regions []string // us, eu regions for DoubleDouble service
|
regions []string // us, eu regions for DoubleDouble service
|
||||||
|
lastAPICallTime time.Time // Rate limiting: track last API call
|
||||||
|
apiCallCount int // Rate limiting: counter per minute
|
||||||
|
apiCallResetTime time.Time // Rate limiting: reset time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Global Amazon downloader instance for connection reuse
|
||||||
|
globalAmazonDownloader *AmazonDownloader
|
||||||
|
amazonDownloaderOnce sync.Once
|
||||||
|
amazonRateLimitMu sync.Mutex // Mutex for rate limiting
|
||||||
|
)
|
||||||
|
|
||||||
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
|
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
|
||||||
type DoubleDoubleSubmitResponse struct {
|
type DoubleDoubleSubmitResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
@@ -40,46 +52,46 @@ type DoubleDoubleStatusResponse struct {
|
|||||||
func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||||
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||||
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||||
|
|
||||||
// Exact match
|
// Exact match
|
||||||
if normExpected == normFound {
|
if normExpected == normFound {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if one contains the other
|
// Check if one contains the other
|
||||||
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check first artist (before comma or feat)
|
// Check first artist (before comma or feat)
|
||||||
expectedFirst := strings.Split(normExpected, ",")[0]
|
expectedFirst := strings.Split(normExpected, ",")[0]
|
||||||
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
||||||
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
||||||
expectedFirst = strings.TrimSpace(expectedFirst)
|
expectedFirst = strings.TrimSpace(expectedFirst)
|
||||||
|
|
||||||
foundFirst := strings.Split(normFound, ",")[0]
|
foundFirst := strings.Split(normFound, ",")[0]
|
||||||
foundFirst = strings.Split(foundFirst, " feat")[0]
|
foundFirst = strings.Split(foundFirst, " feat")[0]
|
||||||
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
||||||
foundFirst = strings.TrimSpace(foundFirst)
|
foundFirst = strings.TrimSpace(foundFirst)
|
||||||
|
|
||||||
if expectedFirst == foundFirst {
|
if expectedFirst == foundFirst {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if first artist is contained in the other
|
// Check if first artist is contained in the other
|
||||||
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
||||||
// assume they're the same artist with different transliteration
|
// assume they're the same artist with different transliteration
|
||||||
expectedASCII := amazonIsASCIIString(expectedArtist)
|
expectedASCII := amazonIsASCIIString(expectedArtist)
|
||||||
foundASCII := amazonIsASCIIString(foundArtist)
|
foundASCII := amazonIsASCIIString(foundArtist)
|
||||||
if expectedASCII != foundASCII {
|
if expectedASCII != foundASCII {
|
||||||
fmt.Printf("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
GoLog("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,12 +105,57 @@ func amazonIsASCIIString(s string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAmazonDownloader creates a new Amazon downloader using DoubleDouble service
|
// NewAmazonDownloader creates a new Amazon downloader (returns singleton for connection reuse)
|
||||||
func NewAmazonDownloader() *AmazonDownloader {
|
func NewAmazonDownloader() *AmazonDownloader {
|
||||||
return &AmazonDownloader{
|
amazonDownloaderOnce.Do(func() {
|
||||||
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
|
globalAmazonDownloader = &AmazonDownloader{
|
||||||
regions: []string{"us", "eu"}, // Same regions as PC
|
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
|
||||||
|
regions: []string{"us", "eu"}, // Same regions as PC
|
||||||
|
apiCallResetTime: time.Now(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalAmazonDownloader
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForRateLimit implements rate limiting similar to PC version
|
||||||
|
// Max 9 requests per minute with 7 second delay between requests
|
||||||
|
func (a *AmazonDownloader) waitForRateLimit() {
|
||||||
|
amazonRateLimitMu.Lock()
|
||||||
|
defer amazonRateLimitMu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Reset counter every minute
|
||||||
|
if now.Sub(a.apiCallResetTime) >= time.Minute {
|
||||||
|
a.apiCallCount = 0
|
||||||
|
a.apiCallResetTime = now
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we've hit the limit (9 requests per minute), wait until next minute
|
||||||
|
if a.apiCallCount >= 9 {
|
||||||
|
waitTime := time.Minute - now.Sub(a.apiCallResetTime)
|
||||||
|
if waitTime > 0 {
|
||||||
|
GoLog("[Amazon] Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
|
||||||
|
time.Sleep(waitTime)
|
||||||
|
a.apiCallCount = 0
|
||||||
|
a.apiCallResetTime = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add delay between requests (7 seconds like PC version)
|
||||||
|
if !a.lastAPICallTime.IsZero() {
|
||||||
|
timeSinceLastCall := now.Sub(a.lastAPICallTime)
|
||||||
|
minDelay := 7 * time.Second
|
||||||
|
if timeSinceLastCall < minDelay {
|
||||||
|
waitTime := minDelay - timeSinceLastCall
|
||||||
|
GoLog("[Amazon] Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
|
||||||
|
time.Sleep(waitTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update tracking
|
||||||
|
a.lastAPICallTime = time.Now()
|
||||||
|
a.apiCallCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAvailableAPIs returns list of available DoubleDouble regions
|
// GetAvailableAPIs returns list of available DoubleDouble regions
|
||||||
@@ -113,26 +170,28 @@ func (a *AmazonDownloader) GetAvailableAPIs() []string {
|
|||||||
return apis
|
return apis
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC)
|
// downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC)
|
||||||
// This uses submit → poll → download mechanism
|
// This uses submit → poll → download mechanism
|
||||||
// Internal function - not exported to gomobile
|
// Internal function - not exported to gomobile
|
||||||
func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir string) (string, string, string, error) {
|
func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string) (string, string, string, error) {
|
||||||
var lastError error
|
var lastError error
|
||||||
|
|
||||||
for _, region := range a.regions {
|
for _, region := range a.regions {
|
||||||
fmt.Printf("[Amazon] Trying region: %s...\n", region)
|
GoLog("[Amazon] Trying region: %s...\n", region)
|
||||||
|
|
||||||
// Build base URL for DoubleDouble service
|
// Build base URL for DoubleDouble service
|
||||||
// Decode base64 service URL (same as PC)
|
// Decode base64 service URL (same as PC)
|
||||||
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https://
|
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https://
|
||||||
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
|
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
|
||||||
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
||||||
|
|
||||||
// Step 1: Submit download request
|
// Step 1: Submit download request with rate limiting
|
||||||
encodedURL := url.QueryEscape(amazonURL)
|
encodedURL := url.QueryEscape(amazonURL)
|
||||||
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
|
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
|
||||||
|
|
||||||
|
// Apply rate limiting before request (like PC version)
|
||||||
|
a.waitForRateLimit()
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", submitURL, nil)
|
req, err := http.NewRequest("GET", submitURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastError = fmt.Errorf("failed to create request: %w", err)
|
lastError = fmt.Errorf("failed to create request: %w", err)
|
||||||
@@ -142,15 +201,43 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
|||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
fmt.Println("[Amazon] Submitting download request...")
|
fmt.Println("[Amazon] Submitting download request...")
|
||||||
resp, err := a.client.Do(req)
|
|
||||||
if err != nil {
|
// Retry logic for 429 errors (like PC version: 3 retries with 15s wait)
|
||||||
lastError = fmt.Errorf("failed to submit request: %w", err)
|
var resp *http.Response
|
||||||
continue
|
maxRetries := 3
|
||||||
|
for retry := 0; retry < maxRetries; retry++ {
|
||||||
|
resp, err = a.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
lastError = fmt.Errorf("failed to submit request: %w", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == 429 { // Too Many Requests
|
||||||
|
resp.Body.Close()
|
||||||
|
if retry < maxRetries-1 {
|
||||||
|
waitTime := 15 * time.Second
|
||||||
|
GoLog("[Amazon] Rate limited (429), waiting %v before retry %d/%d...\n", waitTime, retry+2, maxRetries)
|
||||||
|
time.Sleep(waitTime)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lastError = fmt.Errorf("API rate limit exceeded after %d retries", maxRetries)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
resp.Body.Close()
|
||||||
|
lastError = fmt.Errorf("submit failed with status %d", resp.StatusCode)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success - break retry loop
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
if err != nil || lastError != nil {
|
||||||
resp.Body.Close()
|
if resp != nil {
|
||||||
lastError = fmt.Errorf("submit failed with status %d", resp.StatusCode)
|
resp.Body.Close()
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +255,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
|||||||
}
|
}
|
||||||
|
|
||||||
downloadID := submitResp.ID
|
downloadID := submitResp.ID
|
||||||
fmt.Printf("[Amazon] Download ID: %s\n", downloadID)
|
GoLog("[Amazon] Download ID: %s\n", downloadID)
|
||||||
|
|
||||||
// Step 2: Poll for completion
|
// Step 2: Poll for completion
|
||||||
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
|
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
|
||||||
@@ -223,7 +310,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
|||||||
trackName := status.Current.Name
|
trackName := status.Current.Name
|
||||||
artist := status.Current.Artist
|
artist := status.Current.Artist
|
||||||
|
|
||||||
fmt.Printf("[Amazon] Downloading: %s - %s\n", artist, trackName)
|
GoLog("[Amazon] Downloading: %s - %s\n", artist, trackName)
|
||||||
return fileURL, trackName, artist, nil
|
return fileURL, trackName, artist, nil
|
||||||
|
|
||||||
} else if status.Status == "error" {
|
} else if status.Status == "error" {
|
||||||
@@ -257,7 +344,6 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
|||||||
return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError)
|
return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
// Initialize item progress (required for all downloads)
|
// Initialize item progress (required for all downloads)
|
||||||
@@ -283,39 +369,70 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expectedSize := resp.ContentLength
|
||||||
// Set total bytes if available
|
// Set total bytes if available
|
||||||
if resp.ContentLength > 0 && itemID != "" {
|
if expectedSize > 0 && itemID != "" {
|
||||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := os.Create(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer out.Close()
|
|
||||||
|
|
||||||
// Use item progress writer
|
// Use buffered writer for better performance (256KB buffer)
|
||||||
var bytesWritten int64
|
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||||
|
|
||||||
|
// Use item progress writer with buffered output
|
||||||
|
var written int64
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
pw := NewItemProgressWriter(out, itemID)
|
pw := NewItemProgressWriter(bufWriter, itemID)
|
||||||
bytesWritten, err = io.Copy(pw, resp.Body)
|
written, err = io.Copy(pw, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
// Fallback: direct copy without progress tracking
|
// Fallback: direct copy without progress tracking
|
||||||
bytesWritten, err = io.Copy(out, resp.Body)
|
written, err = io.Copy(bufWriter, resp.Body)
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write file: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(bytesWritten)/(1024*1024))
|
// Flush buffer before checking for errors
|
||||||
|
flushErr := bufWriter.Flush()
|
||||||
|
closeErr := out.Close()
|
||||||
|
|
||||||
|
// Check for any errors
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("download interrupted: %w", err)
|
||||||
|
}
|
||||||
|
if flushErr != nil {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file size if Content-Length was provided
|
||||||
|
if expectedSize > 0 && written != expectedSize {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AmazonDownloadResult contains download result with quality info
|
// AmazonDownloadResult contains download result with quality info
|
||||||
type AmazonDownloadResult struct {
|
type AmazonDownloadResult struct {
|
||||||
FilePath string
|
FilePath string
|
||||||
BitDepth int
|
BitDepth int
|
||||||
SampleRate int
|
SampleRate int
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
Album string
|
||||||
|
ReleaseDate string
|
||||||
|
TrackNumber int
|
||||||
|
DiscNumber int
|
||||||
|
ISRC string
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadFromAmazon downloads a track using the request parameters
|
// downloadFromAmazon downloads a track using the request parameters
|
||||||
@@ -330,7 +447,22 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
|
|
||||||
// Get Amazon URL from SongLink
|
// Get Amazon URL from SongLink
|
||||||
songlink := NewSongLinkClient()
|
songlink := NewSongLinkClient()
|
||||||
availability, err := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
var availability *TrackAvailability
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx")
|
||||||
|
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
||||||
|
// Extract Deezer ID and use Deezer-based lookup
|
||||||
|
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
||||||
|
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||||
|
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
||||||
|
} else if req.SpotifyID != "" {
|
||||||
|
// Use Spotify ID
|
||||||
|
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||||
|
} else {
|
||||||
|
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
||||||
}
|
}
|
||||||
@@ -354,12 +486,12 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
|
|
||||||
// Verify artist matches
|
// Verify artist matches
|
||||||
if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) {
|
if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) {
|
||||||
fmt.Printf("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName)
|
GoLog("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName)
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
|
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log match found
|
// Log match found
|
||||||
fmt.Printf("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
|
GoLog("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
|
||||||
|
|
||||||
// Build filename using Spotify metadata (more accurate)
|
// Build filename using Spotify metadata (more accurate)
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
@@ -378,11 +510,29 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download file with item ID for progress tracking
|
// START PARALLEL: Fetch cover and lyrics while downloading audio
|
||||||
|
var parallelResult *ParallelDownloadResult
|
||||||
|
parallelDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(parallelDone)
|
||||||
|
parallelResult = FetchCoverAndLyricsParallel(
|
||||||
|
req.CoverURL,
|
||||||
|
req.EmbedMaxQualityCover,
|
||||||
|
req.SpotifyID,
|
||||||
|
req.TrackName,
|
||||||
|
req.ArtistName,
|
||||||
|
req.EmbedLyrics,
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Download audio file with item ID for progress tracking
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait for parallel operations to complete
|
||||||
|
<-parallelDone
|
||||||
|
|
||||||
// Set progress to 100% and status to finalizing (before embedding)
|
// Set progress to 100% and status to finalizing (before embedding)
|
||||||
// This makes the UI show "Finalizing..." while embedding happens
|
// This makes the UI show "Finalizing..." while embedding happens
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
@@ -392,78 +542,109 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
|
|
||||||
// Log track info from DoubleDouble (for debugging)
|
// Log track info from DoubleDouble (for debugging)
|
||||||
if trackName != "" && artistName != "" {
|
if trackName != "" && artistName != "" {
|
||||||
fmt.Printf("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
|
GoLog("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read existing metadata from downloaded file BEFORE embedding
|
||||||
|
// Amazon/DoubleDouble files often have correct track/disc numbers that we should preserve
|
||||||
|
existingMeta, metaErr := ReadMetadata(outputPath)
|
||||||
|
actualTrackNum := req.TrackNumber
|
||||||
|
actualDiscNum := req.DiscNumber
|
||||||
|
|
||||||
|
if metaErr == nil && existingMeta != nil {
|
||||||
|
// Use file metadata if it has valid track/disc numbers and request doesn't have them
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed metadata using Spotify data (more accurate than DoubleDouble)
|
// Embed metadata using Spotify data (more accurate than DoubleDouble)
|
||||||
|
// But preserve track/disc numbers from file if they were better
|
||||||
metadata := Metadata{
|
metadata := Metadata{
|
||||||
Title: req.TrackName,
|
Title: req.TrackName,
|
||||||
Artist: req.ArtistName,
|
Artist: req.ArtistName,
|
||||||
Album: req.AlbumName,
|
Album: req.AlbumName,
|
||||||
AlbumArtist: req.AlbumArtist,
|
AlbumArtist: req.AlbumArtist,
|
||||||
Date: req.ReleaseDate,
|
Date: req.ReleaseDate,
|
||||||
TrackNumber: req.TrackNumber,
|
TrackNumber: actualTrackNum,
|
||||||
TotalTracks: req.TotalTracks,
|
TotalTracks: req.TotalTracks,
|
||||||
DiscNumber: req.DiscNumber,
|
DiscNumber: actualDiscNum,
|
||||||
ISRC: req.ISRC,
|
ISRC: req.ISRC,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download cover to memory (avoids file permission issues on Android)
|
// Use cover data from parallel fetch
|
||||||
var coverData []byte
|
var coverData []byte
|
||||||
if req.CoverURL != "" {
|
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||||
fmt.Println("[Amazon] Downloading cover to memory...")
|
coverData = parallelResult.CoverData
|
||||||
data, err := downloadCoverToMemory(req.CoverURL, req.EmbedMaxQualityCover)
|
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||||
if err == nil {
|
|
||||||
coverData = data
|
|
||||||
fmt.Printf("[Amazon] Cover downloaded successfully (%d bytes)\n", len(coverData))
|
|
||||||
} else {
|
|
||||||
fmt.Printf("[Amazon] Warning: failed to download cover: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed lyrics if enabled
|
// Embed lyrics from parallel fetch
|
||||||
if req.EmbedLyrics {
|
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
fmt.Println("[Amazon] Fetching lyrics...")
|
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||||
lyricsClient := NewLyricsClient()
|
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||||
lyrics, lyricsErr := lyricsClient.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName)
|
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||||
if lyricsErr != nil {
|
|
||||||
fmt.Printf("[Amazon] Warning: lyrics fetch error: %v\n", lyricsErr)
|
|
||||||
} else if lyrics == nil || len(lyrics.Lines) == 0 {
|
|
||||||
fmt.Println("[Amazon] No lyrics found for this track")
|
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[Amazon] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
|
fmt.Println("[Amazon] Lyrics embedded successfully")
|
||||||
lrcContent := convertToLRC(lyrics)
|
|
||||||
if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil {
|
|
||||||
fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
|
||||||
} else {
|
|
||||||
fmt.Println("[Amazon] Lyrics embedded successfully")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else if req.EmbedLyrics {
|
||||||
|
fmt.Println("[Amazon] No lyrics available from parallel fetch")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
|
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
|
||||||
|
|
||||||
// Read actual quality from the downloaded FLAC file
|
// Read actual quality from the downloaded FLAC file
|
||||||
// Amazon API doesn't provide quality info, but we can read it from the file itself
|
// Amazon API doesn't provide quality info, but we can read it from the file itself
|
||||||
quality, err := GetAudioQuality(outputPath)
|
quality, err := GetAudioQuality(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||||
// Return 0 to indicate unknown quality
|
} else {
|
||||||
return AmazonDownloadResult{
|
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
FilePath: outputPath,
|
|
||||||
BitDepth: 0,
|
|
||||||
SampleRate: 0,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
// Read metadata from file AFTER embedding to get accurate values
|
||||||
|
// This ensures we return what's actually in the file
|
||||||
|
finalMeta, metaReadErr := ReadMetadata(outputPath)
|
||||||
|
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 != "" {
|
||||||
|
// Use date from file if available
|
||||||
|
req.ReleaseDate = finalMeta.Date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to ISRC index for fast duplicate checking
|
||||||
|
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||||
|
|
||||||
|
bitDepth := 0
|
||||||
|
sampleRate := 0
|
||||||
|
if err == nil {
|
||||||
|
bitDepth = quality.BitDepth
|
||||||
|
sampleRate = quality.SampleRate
|
||||||
|
}
|
||||||
|
|
||||||
return AmazonDownloadResult{
|
return AmazonDownloadResult{
|
||||||
FilePath: outputPath,
|
FilePath: outputPath,
|
||||||
BitDepth: quality.BitDepth,
|
BitDepth: bitDepth,
|
||||||
SampleRate: quality.SampleRate,
|
SampleRate: sampleRate,
|
||||||
|
Title: req.TrackName,
|
||||||
|
Artist: req.ArtistName,
|
||||||
|
Album: req.AlbumName,
|
||||||
|
ReleaseDate: req.ReleaseDate,
|
||||||
|
TrackNumber: actualTrackNum,
|
||||||
|
DiscNumber: actualDiscNum,
|
||||||
|
ISRC: req.ISRC,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,20 @@ import (
|
|||||||
|
|
||||||
// Spotify image size codes (same as PC version)
|
// Spotify image size codes (same as PC version)
|
||||||
const (
|
const (
|
||||||
spotifySize640 = "ab67616d0000b273" // 640x640
|
spotifySize300 = "ab67616d00001e02" // 300x300 (small)
|
||||||
|
spotifySize640 = "ab67616d0000b273" // 640x640 (medium)
|
||||||
spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000)
|
spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// convertSmallToMedium upgrades 300x300 cover URL to 640x640
|
||||||
|
// Same logic as PC version for consistency
|
||||||
|
func convertSmallToMedium(imageURL string) string {
|
||||||
|
if strings.Contains(imageURL, spotifySize300) {
|
||||||
|
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||||
|
}
|
||||||
|
return imageURL
|
||||||
|
}
|
||||||
|
|
||||||
// downloadCoverToMemory downloads cover art and returns as bytes (no file creation)
|
// downloadCoverToMemory downloads cover art and returns as bytes (no file creation)
|
||||||
// This avoids file permission issues on Android
|
// This avoids file permission issues on Android
|
||||||
func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||||
@@ -20,17 +30,27 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("no cover URL provided")
|
return nil, fmt.Errorf("no cover URL provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[Cover] Downloading cover from: %s\n", coverURL)
|
GoLog("[Cover] Original URL: %s", coverURL)
|
||||||
|
|
||||||
// Upgrade to max quality if requested
|
// First upgrade small (300) to medium (640) - always do this
|
||||||
downloadURL := coverURL
|
downloadURL := convertSmallToMedium(coverURL)
|
||||||
|
if downloadURL != coverURL {
|
||||||
|
GoLog("[Cover] Upgraded 300x300 → 640x640")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then upgrade to max quality if requested
|
||||||
if maxQuality {
|
if maxQuality {
|
||||||
downloadURL = upgradeToMaxQuality(coverURL)
|
maxURL := upgradeToMaxQuality(downloadURL)
|
||||||
if downloadURL != coverURL {
|
if maxURL != downloadURL {
|
||||||
fmt.Printf("[Cover] Upgraded to max quality URL: %s\n", downloadURL)
|
downloadURL = maxURL
|
||||||
|
GoLog("[Cover] Upgraded to max resolution (~2000x2000)")
|
||||||
|
} else {
|
||||||
|
GoLog("[Cover] Max resolution not available, using 640x640")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GoLog("[Cover] Final URL: %s", downloadURL)
|
||||||
|
|
||||||
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
||||||
|
|
||||||
// Create request with User-Agent (required by Spotify CDN)
|
// Create request with User-Agent (required by Spotify CDN)
|
||||||
@@ -54,12 +74,25 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("failed to read cover data: %w", err)
|
return nil, fmt.Errorf("failed to read cover data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[Cover] Downloaded %d bytes\n", len(data))
|
// Calculate approximate resolution from file size
|
||||||
|
// JPEG ~2000x2000 is typically 300-600KB, 640x640 is ~50-100KB
|
||||||
|
sizeKB := len(data) / 1024
|
||||||
|
var resolution string
|
||||||
|
if sizeKB > 200 {
|
||||||
|
resolution = "~2000x2000 (hi-res)"
|
||||||
|
} else if sizeKB > 50 {
|
||||||
|
resolution = "~640x640"
|
||||||
|
} else {
|
||||||
|
resolution = "~300x300"
|
||||||
|
}
|
||||||
|
GoLog("[Cover] Downloaded %d KB (%s)", sizeKB, resolution)
|
||||||
|
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// upgradeToMaxQuality upgrades Spotify cover URL to maximum quality
|
// upgradeToMaxQuality upgrades Spotify cover URL to maximum quality
|
||||||
// Uses same logic as PC version - replaces 640x640 size code with max resolution
|
// Same logic as PC version - directly replaces 640x640 size code with max resolution
|
||||||
|
// No HEAD verification needed - Spotify CDN always serves max resolution if available
|
||||||
func upgradeToMaxQuality(coverURL string) string {
|
func upgradeToMaxQuality(coverURL string) string {
|
||||||
// Spotify image URLs can be upgraded by changing the size parameter
|
// Spotify image URLs can be upgraded by changing the size parameter
|
||||||
// Format: https://i.scdn.co/image/ab67616d0000b273...
|
// Format: https://i.scdn.co/image/ab67616d0000b273...
|
||||||
@@ -67,21 +100,7 @@ func upgradeToMaxQuality(coverURL string) string {
|
|||||||
// ab67616d000082c1 = Max resolution (~2000x2000)
|
// ab67616d000082c1 = Max resolution (~2000x2000)
|
||||||
|
|
||||||
if strings.Contains(coverURL, spotifySize640) {
|
if strings.Contains(coverURL, spotifySize640) {
|
||||||
// Try max resolution first
|
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||||
maxURL := strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
|
||||||
|
|
||||||
// Verify max resolution URL is available
|
|
||||||
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
|
||||||
req, err := http.NewRequest("HEAD", maxURL, nil)
|
|
||||||
if err == nil {
|
|
||||||
resp, err := DoRequestWithUserAgent(client, req)
|
|
||||||
if err == nil {
|
|
||||||
resp.Body.Close()
|
|
||||||
if resp.StatusCode == http.StatusOK {
|
|
||||||
return maxURL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return coverURL
|
return coverURL
|
||||||
@@ -93,9 +112,12 @@ func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always upgrade small to medium first
|
||||||
|
result := convertSmallToMedium(imageURL)
|
||||||
|
|
||||||
if maxQuality {
|
if maxQuality {
|
||||||
return upgradeToMaxQuality(imageURL)
|
result = upgradeToMaxQuality(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
return imageURL
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,750 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
deezerBaseURL = "https://api.deezer.com/2.0"
|
||||||
|
deezerSearchURL = deezerBaseURL + "/search"
|
||||||
|
deezerTrackURL = deezerBaseURL + "/track/%s"
|
||||||
|
deezerAlbumURL = deezerBaseURL + "/album/%s"
|
||||||
|
deezerArtistURL = deezerBaseURL + "/artist/%s"
|
||||||
|
deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
|
||||||
|
|
||||||
|
deezerCacheTTL = 10 * time.Minute
|
||||||
|
|
||||||
|
// Parallel ISRC fetching settings
|
||||||
|
deezerMaxParallelISRC = 10 // Max concurrent ISRC fetches
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeezerClient handles Deezer API interactions (no auth required)
|
||||||
|
type DeezerClient struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
searchCache map[string]*cacheEntry
|
||||||
|
albumCache map[string]*cacheEntry
|
||||||
|
artistCache map[string]*cacheEntry
|
||||||
|
isrcCache map[string]string // trackID -> ISRC cache
|
||||||
|
cacheMu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
var (
|
||||||
|
deezerClient *DeezerClient
|
||||||
|
deezerClientOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetDeezerClient returns singleton Deezer client
|
||||||
|
func GetDeezerClient() *DeezerClient {
|
||||||
|
deezerClientOnce.Do(func() {
|
||||||
|
deezerClient = &DeezerClient{
|
||||||
|
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
|
||||||
|
searchCache: make(map[string]*cacheEntry),
|
||||||
|
albumCache: make(map[string]*cacheEntry),
|
||||||
|
artistCache: make(map[string]*cacheEntry),
|
||||||
|
isrcCache: make(map[string]string),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return deezerClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deezer API response types
|
||||||
|
type deezerTrack struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Duration int `json:"duration"` // in seconds
|
||||||
|
TrackPosition int `json:"track_position"`
|
||||||
|
DiskNumber int `json:"disk_number"`
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
ReleaseDate string `json:"release_date"` // Sometimes at track level
|
||||||
|
Artist deezerArtist `json:"artist"`
|
||||||
|
Album deezerAlbumSimple `json:"album"`
|
||||||
|
Contributors []deezerArtist `json:"contributors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deezerArtist struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Picture string `json:"picture"`
|
||||||
|
PictureMedium string `json:"picture_medium"`
|
||||||
|
PictureBig string `json:"picture_big"`
|
||||||
|
PictureXL string `json:"picture_xl"`
|
||||||
|
NbFan int `json:"nb_fan"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deezerAlbumSimple struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Cover string `json:"cover"`
|
||||||
|
CoverMedium string `json:"cover_medium"`
|
||||||
|
CoverBig string `json:"cover_big"`
|
||||||
|
CoverXL string `json:"cover_xl"`
|
||||||
|
ReleaseDate string `json:"release_date"` // Sometimes at album level
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... (skip other structs as they are fine/unchanged) ...
|
||||||
|
|
||||||
|
// ... (in convertTrack) ...
|
||||||
|
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
||||||
|
artistName := track.Artist.Name
|
||||||
|
if len(track.Contributors) > 0 {
|
||||||
|
names := make([]string, len(track.Contributors))
|
||||||
|
for i, a := range track.Contributors {
|
||||||
|
names[i] = a.Name
|
||||||
|
}
|
||||||
|
artistName = strings.Join(names, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
albumImage := track.Album.CoverXL
|
||||||
|
if albumImage == "" {
|
||||||
|
albumImage = track.Album.CoverBig
|
||||||
|
}
|
||||||
|
if albumImage == "" {
|
||||||
|
albumImage = track.Album.CoverMedium
|
||||||
|
}
|
||||||
|
if albumImage == "" {
|
||||||
|
albumImage = track.Album.Cover
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find release date
|
||||||
|
releaseDate := track.ReleaseDate
|
||||||
|
if releaseDate == "" {
|
||||||
|
releaseDate = track.Album.ReleaseDate
|
||||||
|
}
|
||||||
|
|
||||||
|
return TrackMetadata{
|
||||||
|
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
||||||
|
Artists: artistName,
|
||||||
|
Name: track.Title,
|
||||||
|
AlbumName: track.Album.Title,
|
||||||
|
AlbumArtist: track.Artist.Name,
|
||||||
|
DurationMS: track.Duration * 1000,
|
||||||
|
Images: albumImage,
|
||||||
|
ReleaseDate: releaseDate, // Added this
|
||||||
|
TrackNumber: track.TrackPosition,
|
||||||
|
DiscNumber: track.DiskNumber,
|
||||||
|
ExternalURL: track.Link,
|
||||||
|
ISRC: track.ISRC,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type deezerAlbumFull struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Cover string `json:"cover"`
|
||||||
|
CoverMedium string `json:"cover_medium"`
|
||||||
|
CoverBig string `json:"cover_big"`
|
||||||
|
CoverXL string `json:"cover_xl"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
NbTracks int `json:"nb_tracks"`
|
||||||
|
RecordType string `json:"record_type"` // album, single, ep, compile
|
||||||
|
Artist deezerArtist `json:"artist"`
|
||||||
|
Contributors []deezerArtist `json:"contributors"`
|
||||||
|
Tracks struct {
|
||||||
|
Data []deezerTrack `json:"data"`
|
||||||
|
} `json:"tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deezerArtistFull struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Picture string `json:"picture"`
|
||||||
|
PictureMedium string `json:"picture_medium"`
|
||||||
|
PictureBig string `json:"picture_big"`
|
||||||
|
PictureXL string `json:"picture_xl"`
|
||||||
|
NbFan int `json:"nb_fan"`
|
||||||
|
NbAlbum int `json:"nb_album"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deezerPlaylistFull struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Picture string `json:"picture"`
|
||||||
|
PictureMedium string `json:"picture_medium"`
|
||||||
|
PictureBig string `json:"picture_big"`
|
||||||
|
PictureXL string `json:"picture_xl"`
|
||||||
|
NbTracks int `json:"nb_tracks"`
|
||||||
|
Creator struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"creator"`
|
||||||
|
Tracks struct {
|
||||||
|
Data []deezerTrack `json:"data"`
|
||||||
|
} `json:"tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchAll searches for tracks and artists on Deezer
|
||||||
|
// NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download
|
||||||
|
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
||||||
|
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d\n", query, trackLimit, artistLimit)
|
||||||
|
|
||||||
|
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit)
|
||||||
|
|
||||||
|
c.cacheMu.RLock()
|
||||||
|
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
GoLog("[Deezer] SearchAll: returning cached result\n")
|
||||||
|
return entry.data.(*SearchAllResult), nil
|
||||||
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
result := &SearchAllResult{
|
||||||
|
Tracks: make([]TrackMetadata, 0),
|
||||||
|
Artists: make([]SearchArtistResult, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search tracks - NO ISRC fetch for performance
|
||||||
|
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
|
||||||
|
GoLog("[Deezer] Fetching tracks from: %s\n", trackURL)
|
||||||
|
|
||||||
|
var trackResp struct {
|
||||||
|
Data []deezerTrack `json:"data"`
|
||||||
|
Error *struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, trackURL, &trackResp); err != nil {
|
||||||
|
GoLog("[Deezer] Track search failed: %v\n", err)
|
||||||
|
return nil, fmt.Errorf("deezer track search failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if trackResp.Error != nil {
|
||||||
|
GoLog("[Deezer] API error: type=%s, code=%d, message=%s\n", trackResp.Error.Type, trackResp.Error.Code, trackResp.Error.Message)
|
||||||
|
return nil, fmt.Errorf("deezer API error: %s (code %d)", trackResp.Error.Message, trackResp.Error.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data))
|
||||||
|
|
||||||
|
for _, track := range trackResp.Data {
|
||||||
|
// Convert directly without fetching ISRC - much faster
|
||||||
|
result.Tracks = append(result.Tracks, c.convertTrack(track))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search artists
|
||||||
|
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
|
||||||
|
GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
|
||||||
|
|
||||||
|
var artistResp struct {
|
||||||
|
Data []deezerArtist `json:"data"`
|
||||||
|
Error *struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, artistURL, &artistResp); err == nil {
|
||||||
|
if artistResp.Error != nil {
|
||||||
|
GoLog("[Deezer] Artist API error: type=%s, code=%d, message=%s\n", artistResp.Error.Type, artistResp.Error.Code, artistResp.Error.Message)
|
||||||
|
} else {
|
||||||
|
GoLog("[Deezer] Got %d artists from API\n", len(artistResp.Data))
|
||||||
|
for _, artist := range artistResp.Data {
|
||||||
|
result.Artists = append(result.Artists, SearchArtistResult{
|
||||||
|
ID: fmt.Sprintf("deezer:%d", artist.ID),
|
||||||
|
Name: artist.Name,
|
||||||
|
Images: c.getBestArtistImage(artist),
|
||||||
|
Followers: artist.NbFan,
|
||||||
|
Popularity: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
GoLog("[Deezer] Artist search failed: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists\n", len(result.Tracks), len(result.Artists))
|
||||||
|
|
||||||
|
// Cache result
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
c.searchCache[cacheKey] = &cacheEntry{
|
||||||
|
data: result,
|
||||||
|
expiresAt: time.Now().Add(deezerCacheTTL),
|
||||||
|
}
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTrack fetches a single track by Deezer ID
|
||||||
|
func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) {
|
||||||
|
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
|
||||||
|
|
||||||
|
var track deezerTrack
|
||||||
|
if err := c.getJSON(ctx, trackURL, &track); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TrackResponse{
|
||||||
|
Track: c.convertTrack(track),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAlbum fetches album with tracks
|
||||||
|
// ISRC is fetched in parallel for better performance
|
||||||
|
func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) {
|
||||||
|
c.cacheMu.RLock()
|
||||||
|
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
return entry.data.(*AlbumResponsePayload), nil
|
||||||
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
albumURL := fmt.Sprintf(deezerAlbumURL, albumID)
|
||||||
|
|
||||||
|
var album deezerAlbumFull
|
||||||
|
if err := c.getJSON(ctx, albumURL, &album); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
albumImage := c.getBestAlbumImage(album)
|
||||||
|
artistName := album.Artist.Name
|
||||||
|
if len(album.Contributors) > 0 {
|
||||||
|
names := make([]string, len(album.Contributors))
|
||||||
|
for i, a := range album.Contributors {
|
||||||
|
names[i] = a.Name
|
||||||
|
}
|
||||||
|
artistName = strings.Join(names, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
info := AlbumInfoMetadata{
|
||||||
|
TotalTracks: album.NbTracks,
|
||||||
|
Name: album.Title,
|
||||||
|
ReleaseDate: album.ReleaseDate,
|
||||||
|
Artists: artistName,
|
||||||
|
Images: albumImage,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch ISRCs in parallel
|
||||||
|
isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data)
|
||||||
|
|
||||||
|
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data))
|
||||||
|
// Normalize record_type (Deezer uses "compile" instead of "compilation")
|
||||||
|
albumType := album.RecordType
|
||||||
|
if albumType == "compile" {
|
||||||
|
albumType = "compilation"
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, track := range album.Tracks.Data {
|
||||||
|
trackIDStr := fmt.Sprintf("%d", track.ID)
|
||||||
|
isrc := isrcMap[trackIDStr]
|
||||||
|
|
||||||
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
|
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
||||||
|
Artists: track.Artist.Name,
|
||||||
|
Name: track.Title,
|
||||||
|
AlbumName: album.Title,
|
||||||
|
AlbumArtist: artistName,
|
||||||
|
DurationMS: track.Duration * 1000,
|
||||||
|
Images: albumImage,
|
||||||
|
ReleaseDate: album.ReleaseDate,
|
||||||
|
TrackNumber: track.TrackPosition,
|
||||||
|
TotalTracks: album.NbTracks,
|
||||||
|
DiscNumber: track.DiskNumber,
|
||||||
|
ExternalURL: track.Link,
|
||||||
|
ISRC: isrc,
|
||||||
|
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
|
||||||
|
AlbumType: albumType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &AlbumResponsePayload{
|
||||||
|
AlbumInfo: info,
|
||||||
|
TrackList: tracks,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
c.albumCache[albumID] = &cacheEntry{
|
||||||
|
data: result,
|
||||||
|
expiresAt: time.Now().Add(deezerCacheTTL),
|
||||||
|
}
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetArtist fetches artist with albums
|
||||||
|
func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistResponsePayload, error) {
|
||||||
|
c.cacheMu.RLock()
|
||||||
|
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
return entry.data.(*ArtistResponsePayload), nil
|
||||||
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
// Fetch artist info
|
||||||
|
artistURL := fmt.Sprintf(deezerArtistURL, artistID)
|
||||||
|
var artist deezerArtistFull
|
||||||
|
if err := c.getJSON(ctx, artistURL, &artist); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
artistInfo := ArtistInfoMetadata{
|
||||||
|
ID: fmt.Sprintf("deezer:%d", artist.ID),
|
||||||
|
Name: artist.Name,
|
||||||
|
Images: c.getBestArtistImageFull(artist),
|
||||||
|
Followers: artist.NbFan,
|
||||||
|
Popularity: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch artist albums
|
||||||
|
albumsURL := fmt.Sprintf("%s/albums?limit=100", fmt.Sprintf(deezerArtistURL, artistID))
|
||||||
|
var albumsResp struct {
|
||||||
|
Data []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
NbTracks int `json:"nb_tracks"`
|
||||||
|
Cover string `json:"cover"`
|
||||||
|
CoverMedium string `json:"cover_medium"`
|
||||||
|
CoverBig string `json:"cover_big"`
|
||||||
|
CoverXL string `json:"cover_xl"`
|
||||||
|
RecordType string `json:"record_type"` // album, single, ep, compile
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
albums := make([]ArtistAlbumMetadata, 0)
|
||||||
|
if err := c.getJSON(ctx, albumsURL, &albumsResp); err == nil {
|
||||||
|
for _, album := range albumsResp.Data {
|
||||||
|
albumType := album.RecordType
|
||||||
|
if albumType == "compile" {
|
||||||
|
albumType = "compilation"
|
||||||
|
}
|
||||||
|
|
||||||
|
coverURL := album.CoverXL
|
||||||
|
if coverURL == "" {
|
||||||
|
coverURL = album.CoverBig
|
||||||
|
}
|
||||||
|
if coverURL == "" {
|
||||||
|
coverURL = album.CoverMedium
|
||||||
|
}
|
||||||
|
if coverURL == "" {
|
||||||
|
coverURL = album.Cover
|
||||||
|
}
|
||||||
|
|
||||||
|
albums = append(albums, ArtistAlbumMetadata{
|
||||||
|
ID: fmt.Sprintf("deezer:%d", album.ID),
|
||||||
|
Name: album.Title,
|
||||||
|
ReleaseDate: album.ReleaseDate,
|
||||||
|
TotalTracks: album.NbTracks,
|
||||||
|
Images: coverURL,
|
||||||
|
AlbumType: albumType,
|
||||||
|
Artists: artist.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &ArtistResponsePayload{
|
||||||
|
ArtistInfo: artistInfo,
|
||||||
|
Albums: albums,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
c.artistCache[artistID] = &cacheEntry{
|
||||||
|
data: result,
|
||||||
|
expiresAt: time.Now().Add(deezerCacheTTL),
|
||||||
|
}
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlaylist fetches playlist with tracks
|
||||||
|
// ISRC is fetched in parallel for better performance
|
||||||
|
func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
|
||||||
|
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
|
||||||
|
|
||||||
|
var playlist deezerPlaylistFull
|
||||||
|
if err := c.getJSON(ctx, playlistURL, &playlist); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
playlistImage := playlist.PictureXL
|
||||||
|
if playlistImage == "" {
|
||||||
|
playlistImage = playlist.PictureBig
|
||||||
|
}
|
||||||
|
if playlistImage == "" {
|
||||||
|
playlistImage = playlist.PictureMedium
|
||||||
|
}
|
||||||
|
|
||||||
|
var info PlaylistInfoMetadata
|
||||||
|
info.Tracks.Total = playlist.NbTracks
|
||||||
|
info.Owner.DisplayName = playlist.Creator.Name
|
||||||
|
info.Owner.Name = playlist.Title
|
||||||
|
info.Owner.Images = playlistImage
|
||||||
|
|
||||||
|
// Fetch ISRCs in parallel
|
||||||
|
isrcMap := c.fetchISRCsParallel(ctx, playlist.Tracks.Data)
|
||||||
|
|
||||||
|
tracks := make([]AlbumTrackMetadata, 0, len(playlist.Tracks.Data))
|
||||||
|
for _, track := range playlist.Tracks.Data {
|
||||||
|
albumImage := track.Album.CoverXL
|
||||||
|
if albumImage == "" {
|
||||||
|
albumImage = track.Album.CoverBig
|
||||||
|
}
|
||||||
|
if albumImage == "" {
|
||||||
|
albumImage = track.Album.CoverMedium
|
||||||
|
}
|
||||||
|
|
||||||
|
trackIDStr := fmt.Sprintf("%d", track.ID)
|
||||||
|
isrc := isrcMap[trackIDStr]
|
||||||
|
|
||||||
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
|
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
||||||
|
Artists: track.Artist.Name,
|
||||||
|
Name: track.Title,
|
||||||
|
AlbumName: track.Album.Title,
|
||||||
|
AlbumArtist: track.Artist.Name,
|
||||||
|
DurationMS: track.Duration * 1000,
|
||||||
|
Images: albumImage,
|
||||||
|
ReleaseDate: "",
|
||||||
|
TrackNumber: track.TrackPosition,
|
||||||
|
DiscNumber: track.DiskNumber,
|
||||||
|
ExternalURL: track.Link,
|
||||||
|
ISRC: isrc,
|
||||||
|
AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &PlaylistResponsePayload{
|
||||||
|
PlaylistInfo: info,
|
||||||
|
TrackList: tracks,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchByISRC searches for a track by ISRC using direct endpoint
|
||||||
|
func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMetadata, error) {
|
||||||
|
// Use direct ISRC endpoint (API 2.0)
|
||||||
|
// https://api.deezer.com/2.0/track/isrc:{ISRC}
|
||||||
|
directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc)
|
||||||
|
|
||||||
|
var track deezerTrack
|
||||||
|
if err := c.getJSON(ctx, directURL, &track); err != nil {
|
||||||
|
// Fallback to search if direct endpoint fails
|
||||||
|
searchURL := fmt.Sprintf("%s/track?q=isrc:%s&limit=1", deezerSearchURL, isrc)
|
||||||
|
var resp struct {
|
||||||
|
Data []deezerTrack `json:"data"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, searchURL, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(resp.Data) == 0 {
|
||||||
|
return nil, fmt.Errorf("no track found for ISRC: %s", isrc)
|
||||||
|
}
|
||||||
|
result := c.convertTrack(resp.Data[0])
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we got a valid response (ID > 0)
|
||||||
|
if track.ID == 0 {
|
||||||
|
return nil, fmt.Errorf("no track found for ISRC: %s", isrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := c.convertTrack(track)
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*deezerTrack, error) {
|
||||||
|
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
|
||||||
|
var track deezerTrack
|
||||||
|
if err := c.getJSON(ctx, trackURL, &track); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &track, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel with caching
|
||||||
|
func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string {
|
||||||
|
result := make(map[string]string)
|
||||||
|
var resultMu sync.Mutex
|
||||||
|
|
||||||
|
// First, check cache for existing ISRCs
|
||||||
|
var tracksToFetch []deezerTrack
|
||||||
|
c.cacheMu.RLock()
|
||||||
|
for _, track := range tracks {
|
||||||
|
trackIDStr := fmt.Sprintf("%d", track.ID)
|
||||||
|
if isrc, ok := c.isrcCache[trackIDStr]; ok {
|
||||||
|
result[trackIDStr] = isrc
|
||||||
|
} else {
|
||||||
|
tracksToFetch = append(tracksToFetch, track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
if len(tracksToFetch) == 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use semaphore to limit concurrent requests
|
||||||
|
sem := make(chan struct{}, deezerMaxParallelISRC)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for _, track := range tracksToFetch {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(t deezerTrack) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
// Acquire semaphore
|
||||||
|
select {
|
||||||
|
case sem <- struct{}{}:
|
||||||
|
defer func() { <-sem }()
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
trackIDStr := fmt.Sprintf("%d", t.ID)
|
||||||
|
fullTrack, err := c.fetchFullTrack(ctx, trackIDStr)
|
||||||
|
if err != nil || fullTrack == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in result and cache
|
||||||
|
resultMu.Lock()
|
||||||
|
result[trackIDStr] = fullTrack.ISRC
|
||||||
|
resultMu.Unlock()
|
||||||
|
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
c.isrcCache[trackIDStr] = fullTrack.ISRC
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
}(track)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTrackISRC fetches ISRC for a single track (with caching)
|
||||||
|
// Use this when you need ISRC for download
|
||||||
|
func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) {
|
||||||
|
// Check cache first
|
||||||
|
c.cacheMu.RLock()
|
||||||
|
if isrc, ok := c.isrcCache[trackID]; ok {
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
return isrc, nil
|
||||||
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
// Fetch from API
|
||||||
|
fullTrack, err := c.fetchFullTrack(ctx, trackID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
c.isrcCache[trackID] = fullTrack.ISRC
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
|
return fullTrack.ISRC, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) getBestArtistImage(artist deezerArtist) string {
|
||||||
|
if artist.PictureXL != "" {
|
||||||
|
return artist.PictureXL
|
||||||
|
}
|
||||||
|
if artist.PictureBig != "" {
|
||||||
|
return artist.PictureBig
|
||||||
|
}
|
||||||
|
if artist.PictureMedium != "" {
|
||||||
|
return artist.PictureMedium
|
||||||
|
}
|
||||||
|
return artist.Picture
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) getBestArtistImageFull(artist deezerArtistFull) string {
|
||||||
|
if artist.PictureXL != "" {
|
||||||
|
return artist.PictureXL
|
||||||
|
}
|
||||||
|
if artist.PictureBig != "" {
|
||||||
|
return artist.PictureBig
|
||||||
|
}
|
||||||
|
if artist.PictureMedium != "" {
|
||||||
|
return artist.PictureMedium
|
||||||
|
}
|
||||||
|
return artist.Picture
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
|
||||||
|
if album.CoverXL != "" {
|
||||||
|
return album.CoverXL
|
||||||
|
}
|
||||||
|
if album.CoverBig != "" {
|
||||||
|
return album.CoverBig
|
||||||
|
}
|
||||||
|
if album.CoverMedium != "" {
|
||||||
|
return album.CoverMedium
|
||||||
|
}
|
||||||
|
return album.Cover
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("deezer API returned status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Unmarshal(body, dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDeezerURL is internal function, returns type and ID
|
||||||
|
func parseDeezerURL(input string) (string, string, error) {
|
||||||
|
trimmed := strings.TrimSpace(input)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", "", fmt.Errorf("empty URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(trimmed)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.Host != "www.deezer.com" && parsed.Host != "deezer.com" && parsed.Host != "deezer.page.link" {
|
||||||
|
return "", "", fmt.Errorf("not a Deezer URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||||
|
|
||||||
|
// Skip language prefix if present (e.g., /en/, /fr/)
|
||||||
|
if len(parts) > 0 && len(parts[0]) == 2 {
|
||||||
|
parts = parts[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return "", "", fmt.Errorf("invalid Deezer URL format")
|
||||||
|
}
|
||||||
|
|
||||||
|
resourceType := parts[0]
|
||||||
|
resourceID := parts[1]
|
||||||
|
|
||||||
|
switch resourceType {
|
||||||
|
case "track", "album", "artist", "playlist":
|
||||||
|
return resourceType, resourceID, nil
|
||||||
|
default:
|
||||||
|
return "", "", fmt.Errorf("unsupported Deezer resource type: %s", resourceType)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,49 +1,144 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ISRCIndex holds a cached map of ISRC -> file path for fast duplicate checking
|
||||||
|
type ISRCIndex struct {
|
||||||
|
index map[string]string // ISRC (uppercase) -> file path
|
||||||
|
outputDir string
|
||||||
|
buildTime time.Time
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global ISRC index cache (per output directory)
|
||||||
|
var (
|
||||||
|
isrcIndexCache = make(map[string]*ISRCIndex)
|
||||||
|
isrcIndexCacheMu sync.RWMutex
|
||||||
|
isrcIndexTTL = 5 * time.Minute // Cache TTL - rebuild after 5 minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetISRCIndex returns or builds an ISRC index for the given directory
|
||||||
|
func GetISRCIndex(outputDir string) *ISRCIndex {
|
||||||
|
isrcIndexCacheMu.RLock()
|
||||||
|
idx, exists := isrcIndexCache[outputDir]
|
||||||
|
isrcIndexCacheMu.RUnlock()
|
||||||
|
|
||||||
|
// Return cached index if still valid
|
||||||
|
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build new index
|
||||||
|
return buildISRCIndex(outputDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
|
||||||
|
// Same implementation as PC version for consistency
|
||||||
|
func buildISRCIndex(outputDir string) *ISRCIndex {
|
||||||
|
idx := &ISRCIndex{
|
||||||
|
index: make(map[string]string),
|
||||||
|
outputDir: outputDir,
|
||||||
|
buildTime: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if outputDir == "" {
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
fileCount := 0
|
||||||
|
|
||||||
|
// Walk directory - only check .flac files
|
||||||
|
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
|
if ext != ".flac" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read ISRC from file
|
||||||
|
metadata, err := ReadMetadata(path)
|
||||||
|
if err != nil || metadata.ISRC == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in index (uppercase for case-insensitive matching)
|
||||||
|
idx.index[strings.ToUpper(metadata.ISRC)] = path
|
||||||
|
fileCount++
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
|
||||||
|
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
|
||||||
|
|
||||||
|
// Cache the index
|
||||||
|
isrcIndexCacheMu.Lock()
|
||||||
|
isrcIndexCache[outputDir] = idx
|
||||||
|
isrcIndexCacheMu.Unlock()
|
||||||
|
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookup checks if an ISRC exists in the index (internal, returns bool)
|
||||||
|
func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
|
||||||
|
if isrc == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
idx.mu.RLock()
|
||||||
|
defer idx.mu.RUnlock()
|
||||||
|
|
||||||
|
path, exists := idx.index[strings.ToUpper(isrc)]
|
||||||
|
return path, exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup checks if an ISRC exists in the index (gomobile compatible)
|
||||||
|
// Returns filepath if found, empty string if not found
|
||||||
|
func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
|
||||||
|
path, _ := idx.lookup(isrc)
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add adds a new ISRC to the index (call after successful download)
|
||||||
|
func (idx *ISRCIndex) Add(isrc, filePath string) {
|
||||||
|
if isrc == "" || filePath == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idx.mu.Lock()
|
||||||
|
defer idx.mu.Unlock()
|
||||||
|
|
||||||
|
idx.index[strings.ToUpper(isrc)] = filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidateCache clears the ISRC index cache for a directory
|
||||||
|
func InvalidateISRCCache(outputDir string) {
|
||||||
|
isrcIndexCacheMu.Lock()
|
||||||
|
delete(isrcIndexCache, outputDir)
|
||||||
|
isrcIndexCacheMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// checkISRCExistsInternal checks if a file with the given ISRC exists (internal use)
|
// checkISRCExistsInternal checks if a file with the given ISRC exists (internal use)
|
||||||
|
// Uses ISRC index for fast lookup
|
||||||
func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
||||||
if isrc == "" || outputDir == "" {
|
if isrc == "" || outputDir == "" {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Walk through directory looking for FLAC files
|
// Use index for fast lookup
|
||||||
var foundFile string
|
idx := GetISRCIndex(outputDir)
|
||||||
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
|
return idx.lookup(isrc)
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only check FLAC files
|
|
||||||
if info.IsDir() || !strings.HasSuffix(strings.ToLower(path), ".flac") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read metadata from file
|
|
||||||
metadata, err := ReadMetadata(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if ISRC matches
|
|
||||||
if metadata.ISRC == isrc {
|
|
||||||
foundFile = path
|
|
||||||
return filepath.SkipAll // Stop walking
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if foundFile != "" {
|
|
||||||
return foundFile, true
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckISRCExists is the exported version for gomobile (returns string, error)
|
// CheckISRCExists is the exported version for gomobile (returns string, error)
|
||||||
@@ -61,3 +156,98 @@ func CheckFileExists(filePath string) bool {
|
|||||||
}
|
}
|
||||||
return !info.IsDir() && info.Size() > 0
|
return !info.IsDir() && info.Size() > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FileExistenceResult represents the result of checking if a file exists
|
||||||
|
type FileExistenceResult struct {
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
Exists bool `json:"exists"`
|
||||||
|
FilePath string `json:"file_path,omitempty"`
|
||||||
|
TrackName string `json:"track_name,omitempty"`
|
||||||
|
ArtistName string `json:"artist_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckFilesExistParallel checks if multiple files exist in parallel
|
||||||
|
// It builds an ISRC index from the output directory once, then checks all tracks against it
|
||||||
|
// Same implementation as PC version for consistency
|
||||||
|
func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error) {
|
||||||
|
// Parse input JSON
|
||||||
|
var tracks []struct {
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
TrackName string `json:"track_name"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(tracksJSON), &tracks); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse tracks JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]FileExistenceResult, len(tracks))
|
||||||
|
|
||||||
|
// Build ISRC index from output directory (scan once)
|
||||||
|
isrcIdx := GetISRCIndex(outputDir)
|
||||||
|
|
||||||
|
// Check each track against the index (parallel)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, track := range tracks {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(resultIdx int, t struct {
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
TrackName string `json:"track_name"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
}) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
result := FileExistenceResult{
|
||||||
|
ISRC: t.ISRC,
|
||||||
|
TrackName: t.TrackName,
|
||||||
|
ArtistName: t.ArtistName,
|
||||||
|
Exists: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.ISRC != "" {
|
||||||
|
if filePath, exists := isrcIdx.lookup(t.ISRC); exists {
|
||||||
|
result.Exists = true
|
||||||
|
result.FilePath = filePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results[resultIdx] = result
|
||||||
|
}(i, track)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Return results as JSON
|
||||||
|
resultJSON, err := json.Marshal(results)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to marshal results: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(resultJSON), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreBuildISRCIndex pre-builds the ISRC index for a directory
|
||||||
|
// Call this when app starts or when entering album/playlist screen
|
||||||
|
func PreBuildISRCIndex(outputDir string) error {
|
||||||
|
if outputDir == "" {
|
||||||
|
return fmt.Errorf("output directory is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
buildISRCIndex(outputDir)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddToISRCIndex adds a new file to the ISRC index after successful download
|
||||||
|
// This avoids rebuilding the entire index
|
||||||
|
func AddToISRCIndex(outputDir, isrc, filePath string) {
|
||||||
|
if outputDir == "" || isrc == "" || filePath == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isrcIndexCacheMu.RLock()
|
||||||
|
idx, exists := isrcIndexCache[outputDir]
|
||||||
|
isrcIndexCacheMu.RUnlock()
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
idx.Add(isrc, filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
// Package gobackend provides extension manifest parsing and validation
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExtensionType represents the type of extension
|
||||||
|
type ExtensionType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ExtensionTypeMetadataProvider ExtensionType = "metadata_provider"
|
||||||
|
ExtensionTypeDownloadProvider ExtensionType = "download_provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SettingType represents the type of a setting field
|
||||||
|
type SettingType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SettingTypeString SettingType = "string"
|
||||||
|
SettingTypeNumber SettingType = "number"
|
||||||
|
SettingTypeBool SettingType = "boolean"
|
||||||
|
SettingTypeSelect SettingType = "select"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExtensionPermissions defines what resources an extension can access
|
||||||
|
type ExtensionPermissions struct {
|
||||||
|
Network []string `json:"network"` // List of allowed domains
|
||||||
|
Storage bool `json:"storage"` // Whether extension can use storage API
|
||||||
|
File bool `json:"file"` // Whether extension can use file API
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtensionSetting defines a configurable setting for an extension
|
||||||
|
type ExtensionSetting struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Type SettingType `json:"type"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Required bool `json:"required,omitempty"`
|
||||||
|
Secret bool `json:"secret,omitempty"`
|
||||||
|
Default interface{} `json:"default,omitempty"`
|
||||||
|
Options []string `json:"options,omitempty"` // For select type
|
||||||
|
}
|
||||||
|
|
||||||
|
// QualityOption represents a quality option for download providers
|
||||||
|
type QualityOption struct {
|
||||||
|
ID string `json:"id"` // Unique identifier (e.g., "mp3_320", "opus_128")
|
||||||
|
Label string `json:"label"` // Display name (e.g., "MP3 320kbps")
|
||||||
|
Description string `json:"description"` // Optional description (e.g., "Best quality MP3")
|
||||||
|
Settings []QualitySpecificSetting `json:"settings,omitempty"` // Quality-specific settings
|
||||||
|
}
|
||||||
|
|
||||||
|
// QualitySpecificSetting represents a setting that's specific to a quality option
|
||||||
|
type QualitySpecificSetting struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Type SettingType `json:"type"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Required bool `json:"required,omitempty"`
|
||||||
|
Secret bool `json:"secret,omitempty"`
|
||||||
|
Default interface{} `json:"default,omitempty"`
|
||||||
|
Options []string `json:"options,omitempty"` // For select type
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchBehaviorConfig defines custom search behavior for an extension
|
||||||
|
type SearchBehaviorConfig struct {
|
||||||
|
Enabled bool `json:"enabled"` // Whether extension provides custom search
|
||||||
|
Placeholder string `json:"placeholder,omitempty"` // Placeholder text for search box
|
||||||
|
Primary bool `json:"primary,omitempty"` // If true, show as primary search tab
|
||||||
|
Icon string `json:"icon,omitempty"` // Icon for search tab
|
||||||
|
ThumbnailRatio string `json:"thumbnailRatio,omitempty"` // Thumbnail aspect ratio: "square" (1:1), "wide" (16:9), "portrait" (2:3)
|
||||||
|
ThumbnailWidth int `json:"thumbnailWidth,omitempty"` // Custom thumbnail width in pixels
|
||||||
|
ThumbnailHeight int `json:"thumbnailHeight,omitempty"` // Custom thumbnail height in pixels
|
||||||
|
}
|
||||||
|
|
||||||
|
// URLHandlerConfig defines custom URL handling for an extension
|
||||||
|
type URLHandlerConfig struct {
|
||||||
|
Enabled bool `json:"enabled"` // Whether extension handles URLs
|
||||||
|
Patterns []string `json:"patterns,omitempty"` // URL patterns to match (e.g., "music.youtube.com", "soundcloud.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrackMatchingConfig defines custom track matching behavior
|
||||||
|
type TrackMatchingConfig struct {
|
||||||
|
CustomMatching bool `json:"customMatching"` // Whether extension handles matching
|
||||||
|
Strategy string `json:"strategy,omitempty"` // "isrc", "name", "duration", "custom"
|
||||||
|
DurationTolerance int `json:"durationTolerance,omitempty"` // Tolerance in seconds for duration matching
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostProcessingHook defines a post-processing hook
|
||||||
|
type PostProcessingHook struct {
|
||||||
|
ID string `json:"id"` // Unique identifier
|
||||||
|
Name string `json:"name"` // Display name
|
||||||
|
Description string `json:"description,omitempty"` // Description
|
||||||
|
DefaultEnabled bool `json:"defaultEnabled,omitempty"` // Whether enabled by default
|
||||||
|
SupportedFormats []string `json:"supportedFormats,omitempty"` // Supported file formats (e.g., ["flac", "mp3"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostProcessingConfig defines post-processing capabilities
|
||||||
|
type PostProcessingConfig struct {
|
||||||
|
Enabled bool `json:"enabled"` // Whether extension provides post-processing
|
||||||
|
Hooks []PostProcessingHook `json:"hooks,omitempty"` // Available hooks
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtensionManifest represents the manifest.json of an extension
|
||||||
|
type ExtensionManifest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Homepage string `json:"homepage,omitempty"`
|
||||||
|
Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png")
|
||||||
|
Types []ExtensionType `json:"type"`
|
||||||
|
Permissions ExtensionPermissions `json:"permissions"`
|
||||||
|
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||||
|
QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers
|
||||||
|
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||||
|
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify
|
||||||
|
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon)
|
||||||
|
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior
|
||||||
|
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling
|
||||||
|
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching
|
||||||
|
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManifestValidationError represents a validation error in the manifest
|
||||||
|
type ManifestValidationError struct {
|
||||||
|
Field string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ManifestValidationError) Error() string {
|
||||||
|
return fmt.Sprintf("manifest validation error: %s - %s", e.Field, e.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseManifest parses and validates a manifest from JSON bytes
|
||||||
|
func ParseManifest(data []byte) (*ExtensionManifest, error) {
|
||||||
|
var manifest ExtensionManifest
|
||||||
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse manifest JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := manifest.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &manifest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks if the manifest has all required fields and valid values
|
||||||
|
func (m *ExtensionManifest) Validate() error {
|
||||||
|
// Check required fields
|
||||||
|
if strings.TrimSpace(m.Name) == "" {
|
||||||
|
return &ManifestValidationError{Field: "name", Message: "name is required"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(m.Version) == "" {
|
||||||
|
return &ManifestValidationError{Field: "version", Message: "version is required"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(m.Author) == "" {
|
||||||
|
return &ManifestValidationError{Field: "author", Message: "author is required"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(m.Description) == "" {
|
||||||
|
return &ManifestValidationError{Field: "description", Message: "description is required"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(m.Types) == 0 {
|
||||||
|
return &ManifestValidationError{Field: "type", Message: "at least one type is required"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate extension types
|
||||||
|
for _, t := range m.Types {
|
||||||
|
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider {
|
||||||
|
return &ManifestValidationError{
|
||||||
|
Field: "type",
|
||||||
|
Message: fmt.Sprintf("invalid extension type: %s (must be 'metadata_provider' or 'download_provider')", t),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate settings if present
|
||||||
|
for i, setting := range m.Settings {
|
||||||
|
if strings.TrimSpace(setting.Key) == "" {
|
||||||
|
return &ManifestValidationError{
|
||||||
|
Field: fmt.Sprintf("settings[%d].key", i),
|
||||||
|
Message: "setting key is required",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if setting.Type == "" {
|
||||||
|
return &ManifestValidationError{
|
||||||
|
Field: fmt.Sprintf("settings[%d].type", i),
|
||||||
|
Message: "setting type is required",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate setting type
|
||||||
|
validTypes := map[SettingType]bool{
|
||||||
|
SettingTypeString: true,
|
||||||
|
SettingTypeNumber: true,
|
||||||
|
SettingTypeBool: true,
|
||||||
|
SettingTypeSelect: true,
|
||||||
|
}
|
||||||
|
if !validTypes[setting.Type] {
|
||||||
|
return &ManifestValidationError{
|
||||||
|
Field: fmt.Sprintf("settings[%d].type", i),
|
||||||
|
Message: fmt.Sprintf("invalid setting type: %s", setting.Type),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select type requires options
|
||||||
|
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
|
||||||
|
return &ManifestValidationError{
|
||||||
|
Field: fmt.Sprintf("settings[%d].options", i),
|
||||||
|
Message: "select type requires options",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasType checks if the extension has a specific type
|
||||||
|
func (m *ExtensionManifest) HasType(t ExtensionType) bool {
|
||||||
|
for _, et := range m.Types {
|
||||||
|
if et == t {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsMetadataProvider returns true if extension provides metadata
|
||||||
|
func (m *ExtensionManifest) IsMetadataProvider() bool {
|
||||||
|
return m.HasType(ExtensionTypeMetadataProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDownloadProvider returns true if extension provides downloads
|
||||||
|
func (m *ExtensionManifest) IsDownloadProvider() bool {
|
||||||
|
return m.HasType(ExtensionTypeDownloadProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDomainAllowed checks if a domain is in the allowed network permissions
|
||||||
|
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
||||||
|
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||||
|
for _, allowed := range m.Permissions.Network {
|
||||||
|
allowed = strings.ToLower(strings.TrimSpace(allowed))
|
||||||
|
if allowed == domain {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Support wildcard subdomains (e.g., *.example.com)
|
||||||
|
if strings.HasPrefix(allowed, "*.") {
|
||||||
|
suffix := allowed[1:] // Remove the *
|
||||||
|
if strings.HasSuffix(domain, suffix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasCustomSearch returns true if extension provides custom search
|
||||||
|
func (m *ExtensionManifest) HasCustomSearch() bool {
|
||||||
|
return m.SearchBehavior != nil && m.SearchBehavior.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasCustomMatching returns true if extension provides custom track matching
|
||||||
|
func (m *ExtensionManifest) HasCustomMatching() bool {
|
||||||
|
return m.TrackMatching != nil && m.TrackMatching.CustomMatching
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasPostProcessing returns true if extension provides post-processing
|
||||||
|
func (m *ExtensionManifest) HasPostProcessing() bool {
|
||||||
|
return m.PostProcessing != nil && m.PostProcessing.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasURLHandler returns true if extension handles custom URLs
|
||||||
|
func (m *ExtensionManifest) HasURLHandler() bool {
|
||||||
|
return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchesURL checks if a URL matches any of the extension's URL patterns
|
||||||
|
func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
|
||||||
|
if !m.HasURLHandler() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse URL to get host
|
||||||
|
urlStr = strings.ToLower(strings.TrimSpace(urlStr))
|
||||||
|
for _, pattern := range m.URLHandler.Patterns {
|
||||||
|
pattern = strings.ToLower(strings.TrimSpace(pattern))
|
||||||
|
// Check if URL contains the pattern (host match)
|
||||||
|
if strings.Contains(urlStr, pattern) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPostProcessingHooks returns all post-processing hooks
|
||||||
|
func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
|
||||||
|
if m.PostProcessing == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m.PostProcessing.Hooks
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToJSON serializes the manifest to JSON
|
||||||
|
func (m *ExtensionManifest) ToJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(m)
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
// Package gobackend provides extension runtime with sandboxed execution
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default timeout for JS execution (30 seconds)
|
||||||
|
const DefaultJSTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// Global auth state for extensions (stores pending auth codes)
|
||||||
|
var (
|
||||||
|
extensionAuthState = make(map[string]*ExtensionAuthState)
|
||||||
|
extensionAuthStateMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExtensionAuthState holds auth state for an extension
|
||||||
|
type ExtensionAuthState struct {
|
||||||
|
PendingAuthURL string
|
||||||
|
AuthCode string
|
||||||
|
AccessToken string
|
||||||
|
RefreshToken string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
IsAuthenticated bool
|
||||||
|
// PKCE support
|
||||||
|
PKCEVerifier string
|
||||||
|
PKCEChallenge string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingAuthRequest holds a pending OAuth request that needs Flutter to open URL
|
||||||
|
type PendingAuthRequest struct {
|
||||||
|
ExtensionID string
|
||||||
|
AuthURL string
|
||||||
|
CallbackURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global pending auth requests (Flutter polls this)
|
||||||
|
var (
|
||||||
|
pendingAuthRequests = make(map[string]*PendingAuthRequest)
|
||||||
|
pendingAuthRequestsMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetPendingAuthRequest returns pending auth request for an extension (called from Flutter)
|
||||||
|
func GetPendingAuthRequest(extensionID string) *PendingAuthRequest {
|
||||||
|
pendingAuthRequestsMu.RLock()
|
||||||
|
defer pendingAuthRequestsMu.RUnlock()
|
||||||
|
return pendingAuthRequests[extensionID]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearPendingAuthRequest clears pending auth request (called from Flutter after opening URL)
|
||||||
|
func ClearPendingAuthRequest(extensionID string) {
|
||||||
|
pendingAuthRequestsMu.Lock()
|
||||||
|
defer pendingAuthRequestsMu.Unlock()
|
||||||
|
delete(pendingAuthRequests, extensionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetExtensionAuthCode sets auth code for an extension (called from Flutter after OAuth callback)
|
||||||
|
func SetExtensionAuthCode(extensionID string, authCode string) {
|
||||||
|
extensionAuthStateMu.Lock()
|
||||||
|
defer extensionAuthStateMu.Unlock()
|
||||||
|
|
||||||
|
state, exists := extensionAuthState[extensionID]
|
||||||
|
if !exists {
|
||||||
|
state = &ExtensionAuthState{}
|
||||||
|
extensionAuthState[extensionID] = state
|
||||||
|
}
|
||||||
|
state.AuthCode = authCode
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetExtensionTokens sets access/refresh tokens for an extension
|
||||||
|
func SetExtensionTokens(extensionID string, accessToken, refreshToken string, expiresAt time.Time) {
|
||||||
|
extensionAuthStateMu.Lock()
|
||||||
|
defer extensionAuthStateMu.Unlock()
|
||||||
|
|
||||||
|
state, exists := extensionAuthState[extensionID]
|
||||||
|
if !exists {
|
||||||
|
state = &ExtensionAuthState{}
|
||||||
|
extensionAuthState[extensionID] = state
|
||||||
|
}
|
||||||
|
state.AccessToken = accessToken
|
||||||
|
state.RefreshToken = refreshToken
|
||||||
|
state.ExpiresAt = expiresAt
|
||||||
|
state.IsAuthenticated = accessToken != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtensionRuntime provides sandboxed APIs for extensions
|
||||||
|
type ExtensionRuntime struct {
|
||||||
|
extensionID string
|
||||||
|
manifest *ExtensionManifest
|
||||||
|
settings map[string]interface{}
|
||||||
|
httpClient *http.Client
|
||||||
|
cookieJar http.CookieJar
|
||||||
|
dataDir string
|
||||||
|
vm *goja.Runtime
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewExtensionRuntime creates a new runtime for an extension
|
||||||
|
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||||
|
// Create a cookie jar for this extension
|
||||||
|
jar, _ := newSimpleCookieJar()
|
||||||
|
|
||||||
|
runtime := &ExtensionRuntime{
|
||||||
|
extensionID: ext.ID,
|
||||||
|
manifest: ext.Manifest,
|
||||||
|
settings: make(map[string]interface{}),
|
||||||
|
cookieJar: jar,
|
||||||
|
dataDir: ext.DataDir,
|
||||||
|
vm: ext.VM,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create HTTP client with redirect validation to prevent SSRF via open redirect
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
Jar: jar,
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
// Validate redirect target domain against allowed domains
|
||||||
|
domain := req.URL.Hostname()
|
||||||
|
if !ext.Manifest.IsDomainAllowed(domain) {
|
||||||
|
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
|
||||||
|
return &RedirectBlockedError{Domain: domain}
|
||||||
|
}
|
||||||
|
// Also block redirects to private/local networks (SSRF protection)
|
||||||
|
if isPrivateIP(domain) {
|
||||||
|
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
|
||||||
|
return &RedirectBlockedError{Domain: domain, IsPrivate: true}
|
||||||
|
}
|
||||||
|
// Default redirect limit (10)
|
||||||
|
if len(via) >= 10 {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
runtime.httpClient = client
|
||||||
|
|
||||||
|
return runtime
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedirectBlockedError is returned when a redirect is blocked due to domain validation
|
||||||
|
type RedirectBlockedError struct {
|
||||||
|
Domain string
|
||||||
|
IsPrivate bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *RedirectBlockedError) Error() string {
|
||||||
|
if e.IsPrivate {
|
||||||
|
return "redirect blocked: private/local network access denied"
|
||||||
|
}
|
||||||
|
return "redirect blocked: domain '" + e.Domain + "' not in allowed list"
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPrivateIP checks if a hostname resolves to a private/local IP address
|
||||||
|
func isPrivateIP(host string) bool {
|
||||||
|
// Block common private network patterns
|
||||||
|
// This is a simple check - for production, consider DNS resolution
|
||||||
|
privatePatterns := []string{
|
||||||
|
"localhost",
|
||||||
|
"127.",
|
||||||
|
"10.",
|
||||||
|
"172.16.", "172.17.", "172.18.", "172.19.",
|
||||||
|
"172.20.", "172.21.", "172.22.", "172.23.",
|
||||||
|
"172.24.", "172.25.", "172.26.", "172.27.",
|
||||||
|
"172.28.", "172.29.", "172.30.", "172.31.",
|
||||||
|
"192.168.",
|
||||||
|
"169.254.", // Link-local
|
||||||
|
"::1", // IPv6 localhost
|
||||||
|
"fc00:", // IPv6 private
|
||||||
|
"fe80:", // IPv6 link-local
|
||||||
|
}
|
||||||
|
|
||||||
|
hostLower := host
|
||||||
|
for _, pattern := range privatePatterns {
|
||||||
|
if hostLower == pattern || len(hostLower) > len(pattern) && hostLower[:len(pattern)] == pattern {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also block .local domains
|
||||||
|
if len(host) > 6 && host[len(host)-6:] == ".local" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// simpleCookieJar is a simple in-memory cookie jar
|
||||||
|
type simpleCookieJar struct {
|
||||||
|
cookies map[string][]*http.Cookie
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSimpleCookieJar() (*simpleCookieJar, error) {
|
||||||
|
return &simpleCookieJar{
|
||||||
|
cookies: make(map[string][]*http.Cookie),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *simpleCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) {
|
||||||
|
j.mu.Lock()
|
||||||
|
defer j.mu.Unlock()
|
||||||
|
key := u.Host
|
||||||
|
j.cookies[key] = append(j.cookies[key], cookies...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
|
||||||
|
j.mu.RLock()
|
||||||
|
defer j.mu.RUnlock()
|
||||||
|
return j.cookies[u.Host]
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSettings updates the runtime settings
|
||||||
|
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
|
||||||
|
r.settings = settings
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterAPIs registers all sandboxed APIs to the Goja VM
|
||||||
|
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||||
|
r.vm = vm
|
||||||
|
|
||||||
|
// HTTP client (sandboxed to allowed domains)
|
||||||
|
httpObj := vm.NewObject()
|
||||||
|
httpObj.Set("get", r.httpGet)
|
||||||
|
httpObj.Set("post", r.httpPost)
|
||||||
|
httpObj.Set("put", r.httpPut)
|
||||||
|
httpObj.Set("delete", r.httpDelete)
|
||||||
|
httpObj.Set("patch", r.httpPatch)
|
||||||
|
httpObj.Set("request", r.httpRequest) // Generic HTTP request (GET, POST, PUT, DELETE, etc.)
|
||||||
|
httpObj.Set("clearCookies", r.httpClearCookies)
|
||||||
|
vm.Set("http", httpObj)
|
||||||
|
|
||||||
|
// Storage API
|
||||||
|
storageObj := vm.NewObject()
|
||||||
|
storageObj.Set("get", r.storageGet)
|
||||||
|
storageObj.Set("set", r.storageSet)
|
||||||
|
storageObj.Set("remove", r.storageRemove)
|
||||||
|
vm.Set("storage", storageObj)
|
||||||
|
|
||||||
|
// Secure Credentials API (encrypted storage for sensitive data)
|
||||||
|
credentialsObj := vm.NewObject()
|
||||||
|
credentialsObj.Set("store", r.credentialsStore)
|
||||||
|
credentialsObj.Set("get", r.credentialsGet)
|
||||||
|
credentialsObj.Set("remove", r.credentialsRemove)
|
||||||
|
credentialsObj.Set("has", r.credentialsHas)
|
||||||
|
vm.Set("credentials", credentialsObj)
|
||||||
|
|
||||||
|
// Auth API (for OAuth and other auth flows)
|
||||||
|
authObj := vm.NewObject()
|
||||||
|
authObj.Set("openAuthUrl", r.authOpenUrl)
|
||||||
|
authObj.Set("getAuthCode", r.authGetCode)
|
||||||
|
authObj.Set("setAuthCode", r.authSetCode)
|
||||||
|
authObj.Set("clearAuth", r.authClear)
|
||||||
|
authObj.Set("isAuthenticated", r.authIsAuthenticated)
|
||||||
|
authObj.Set("getTokens", r.authGetTokens)
|
||||||
|
// PKCE support
|
||||||
|
authObj.Set("generatePKCE", r.authGeneratePKCE)
|
||||||
|
authObj.Set("getPKCE", r.authGetPKCE)
|
||||||
|
authObj.Set("startOAuthWithPKCE", r.authStartOAuthWithPKCE)
|
||||||
|
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
|
||||||
|
vm.Set("auth", authObj)
|
||||||
|
|
||||||
|
// File operations (sandboxed)
|
||||||
|
fileObj := vm.NewObject()
|
||||||
|
fileObj.Set("download", r.fileDownload)
|
||||||
|
fileObj.Set("exists", r.fileExists)
|
||||||
|
fileObj.Set("delete", r.fileDelete)
|
||||||
|
fileObj.Set("read", r.fileRead)
|
||||||
|
fileObj.Set("write", r.fileWrite)
|
||||||
|
fileObj.Set("copy", r.fileCopy)
|
||||||
|
fileObj.Set("move", r.fileMove)
|
||||||
|
fileObj.Set("getSize", r.fileGetSize)
|
||||||
|
vm.Set("file", fileObj)
|
||||||
|
|
||||||
|
// FFmpeg API (for post-processing)
|
||||||
|
ffmpegObj := vm.NewObject()
|
||||||
|
ffmpegObj.Set("execute", r.ffmpegExecute)
|
||||||
|
ffmpegObj.Set("getInfo", r.ffmpegGetInfo)
|
||||||
|
ffmpegObj.Set("convert", r.ffmpegConvert)
|
||||||
|
vm.Set("ffmpeg", ffmpegObj)
|
||||||
|
|
||||||
|
// Track matching API
|
||||||
|
matchingObj := vm.NewObject()
|
||||||
|
matchingObj.Set("compareStrings", r.matchingCompareStrings)
|
||||||
|
matchingObj.Set("compareDuration", r.matchingCompareDuration)
|
||||||
|
matchingObj.Set("normalizeString", r.matchingNormalizeString)
|
||||||
|
vm.Set("matching", matchingObj)
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
utilsObj := vm.NewObject()
|
||||||
|
utilsObj.Set("base64Encode", r.base64Encode)
|
||||||
|
utilsObj.Set("base64Decode", r.base64Decode)
|
||||||
|
utilsObj.Set("md5", r.md5Hash)
|
||||||
|
utilsObj.Set("sha256", r.sha256Hash)
|
||||||
|
utilsObj.Set("hmacSHA256", r.hmacSHA256)
|
||||||
|
utilsObj.Set("hmacSHA256Base64", r.hmacSHA256Base64)
|
||||||
|
utilsObj.Set("hmacSHA1", r.hmacSHA1)
|
||||||
|
utilsObj.Set("parseJSON", r.parseJSON)
|
||||||
|
utilsObj.Set("stringifyJSON", r.stringifyJSON)
|
||||||
|
// Crypto utilities for developers
|
||||||
|
utilsObj.Set("encrypt", r.cryptoEncrypt)
|
||||||
|
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
||||||
|
utilsObj.Set("generateKey", r.cryptoGenerateKey)
|
||||||
|
vm.Set("utils", utilsObj)
|
||||||
|
|
||||||
|
// Log object (already set in extension_manager.go, but we can enhance it)
|
||||||
|
logObj := vm.NewObject()
|
||||||
|
logObj.Set("debug", r.logDebug)
|
||||||
|
logObj.Set("info", r.logInfo)
|
||||||
|
logObj.Set("warn", r.logWarn)
|
||||||
|
logObj.Set("error", r.logError)
|
||||||
|
vm.Set("log", logObj)
|
||||||
|
|
||||||
|
// Go backend functions
|
||||||
|
gobackendObj := vm.NewObject()
|
||||||
|
gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper)
|
||||||
|
vm.Set("gobackend", gobackendObj)
|
||||||
|
|
||||||
|
// ==================== Browser-like Polyfills ====================
|
||||||
|
// These make porting browser/Node.js libraries easier
|
||||||
|
|
||||||
|
// Global fetch() - Promise-style HTTP API (browser-compatible)
|
||||||
|
vm.Set("fetch", r.fetchPolyfill)
|
||||||
|
|
||||||
|
// Global atob/btoa - Base64 encoding (browser-compatible)
|
||||||
|
vm.Set("atob", r.atobPolyfill)
|
||||||
|
vm.Set("btoa", r.btoaPolyfill)
|
||||||
|
|
||||||
|
// TextEncoder/TextDecoder constructors
|
||||||
|
r.registerTextEncoderDecoder(vm)
|
||||||
|
|
||||||
|
// URL class for URL parsing
|
||||||
|
r.registerURLClass(vm)
|
||||||
|
|
||||||
|
// JSON global (browser-compatible)
|
||||||
|
r.registerJSONGlobal(vm)
|
||||||
|
}
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
// Package gobackend provides Auth API and PKCE support for extension runtime
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ==================== Auth API (OAuth Support) ====================
|
||||||
|
|
||||||
|
// authOpenUrl requests Flutter to open an OAuth URL
|
||||||
|
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "auth URL is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
authURL := call.Arguments[0].String()
|
||||||
|
callbackURL := ""
|
||||||
|
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) {
|
||||||
|
callbackURL = call.Arguments[1].String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store pending auth request for Flutter to pick up
|
||||||
|
pendingAuthRequestsMu.Lock()
|
||||||
|
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||||
|
ExtensionID: r.extensionID,
|
||||||
|
AuthURL: authURL,
|
||||||
|
CallbackURL: callbackURL,
|
||||||
|
}
|
||||||
|
pendingAuthRequestsMu.Unlock()
|
||||||
|
|
||||||
|
// Update auth state
|
||||||
|
extensionAuthStateMu.Lock()
|
||||||
|
state, exists := extensionAuthState[r.extensionID]
|
||||||
|
if !exists {
|
||||||
|
state = &ExtensionAuthState{}
|
||||||
|
extensionAuthState[r.extensionID] = state
|
||||||
|
}
|
||||||
|
state.PendingAuthURL = authURL
|
||||||
|
state.AuthCode = "" // Clear any previous auth code
|
||||||
|
extensionAuthStateMu.Unlock()
|
||||||
|
|
||||||
|
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, authURL)
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"message": "Auth URL will be opened by the app",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// authGetCode gets the auth code (set by Flutter after OAuth callback)
|
||||||
|
func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
||||||
|
extensionAuthStateMu.RLock()
|
||||||
|
defer extensionAuthStateMu.RUnlock()
|
||||||
|
|
||||||
|
state, exists := extensionAuthState[r.extensionID]
|
||||||
|
if !exists || state.AuthCode == "" {
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(state.AuthCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// authSetCode sets auth code and tokens (can be called by extension after token exchange)
|
||||||
|
func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can accept either just auth code or an object with tokens
|
||||||
|
arg := call.Arguments[0].Export()
|
||||||
|
|
||||||
|
extensionAuthStateMu.Lock()
|
||||||
|
defer extensionAuthStateMu.Unlock()
|
||||||
|
|
||||||
|
state, exists := extensionAuthState[r.extensionID]
|
||||||
|
if !exists {
|
||||||
|
state = &ExtensionAuthState{}
|
||||||
|
extensionAuthState[r.extensionID] = state
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := arg.(type) {
|
||||||
|
case string:
|
||||||
|
state.AuthCode = v
|
||||||
|
case map[string]interface{}:
|
||||||
|
if code, ok := v["code"].(string); ok {
|
||||||
|
state.AuthCode = code
|
||||||
|
}
|
||||||
|
if accessToken, ok := v["access_token"].(string); ok {
|
||||||
|
state.AccessToken = accessToken
|
||||||
|
state.IsAuthenticated = true
|
||||||
|
}
|
||||||
|
if refreshToken, ok := v["refresh_token"].(string); ok {
|
||||||
|
state.RefreshToken = refreshToken
|
||||||
|
}
|
||||||
|
if expiresIn, ok := v["expires_in"].(float64); ok {
|
||||||
|
state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// authClear clears all auth state for the extension
|
||||||
|
func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
||||||
|
extensionAuthStateMu.Lock()
|
||||||
|
delete(extensionAuthState, r.extensionID)
|
||||||
|
extensionAuthStateMu.Unlock()
|
||||||
|
|
||||||
|
pendingAuthRequestsMu.Lock()
|
||||||
|
delete(pendingAuthRequests, r.extensionID)
|
||||||
|
pendingAuthRequestsMu.Unlock()
|
||||||
|
|
||||||
|
GoLog("[Extension:%s] Auth state cleared\n", r.extensionID)
|
||||||
|
return r.vm.ToValue(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// authIsAuthenticated checks if extension has valid auth
|
||||||
|
func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
|
||||||
|
extensionAuthStateMu.RLock()
|
||||||
|
defer extensionAuthStateMu.RUnlock()
|
||||||
|
|
||||||
|
state, exists := extensionAuthState[r.extensionID]
|
||||||
|
if !exists {
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token is expired
|
||||||
|
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(state.IsAuthenticated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// authGetTokens returns current tokens (for extension to use in API calls)
|
||||||
|
func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
||||||
|
extensionAuthStateMu.RLock()
|
||||||
|
defer extensionAuthStateMu.RUnlock()
|
||||||
|
|
||||||
|
state, exists := extensionAuthState[r.extensionID]
|
||||||
|
if !exists {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{})
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"access_token": state.AccessToken,
|
||||||
|
"refresh_token": state.RefreshToken,
|
||||||
|
"is_authenticated": state.IsAuthenticated,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !state.ExpiresAt.IsZero() {
|
||||||
|
result["expires_at"] = state.ExpiresAt.Unix()
|
||||||
|
result["is_expired"] = time.Now().After(state.ExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
length = 43
|
||||||
|
}
|
||||||
|
if length > 128 {
|
||||||
|
length = 128
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random bytes
|
||||||
|
bytes := make([]byte, length)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use base64url encoding without padding (RFC 7636 compliant)
|
||||||
|
verifier := base64.RawURLEncoding.EncodeToString(bytes)
|
||||||
|
|
||||||
|
// Trim to exact length
|
||||||
|
if len(verifier) > length {
|
||||||
|
verifier = verifier[:length]
|
||||||
|
}
|
||||||
|
|
||||||
|
return verifier, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generatePKCEChallenge generates a code challenge from verifier using S256 method
|
||||||
|
func generatePKCEChallenge(verifier string) string {
|
||||||
|
hash := sha256.Sum256([]byte(verifier))
|
||||||
|
// Base64url encode without padding (RFC 7636)
|
||||||
|
return base64.RawURLEncoding.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// authGeneratePKCE generates a PKCE code verifier and challenge pair
|
||||||
|
// Returns: { verifier: string, challenge: string, method: "S256" }
|
||||||
|
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||||
|
// Default length is 64 characters
|
||||||
|
length := 64
|
||||||
|
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||||
|
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
|
||||||
|
length = int(l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
verifier, err := generatePKCEVerifier(length)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Extension:%s] PKCE generation error: %v\n", r.extensionID, err)
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
challenge := generatePKCEChallenge(verifier)
|
||||||
|
|
||||||
|
// Store in auth state for later use
|
||||||
|
extensionAuthStateMu.Lock()
|
||||||
|
state, exists := extensionAuthState[r.extensionID]
|
||||||
|
if !exists {
|
||||||
|
state = &ExtensionAuthState{}
|
||||||
|
extensionAuthState[r.extensionID] = state
|
||||||
|
}
|
||||||
|
state.PKCEVerifier = verifier
|
||||||
|
state.PKCEChallenge = challenge
|
||||||
|
extensionAuthStateMu.Unlock()
|
||||||
|
|
||||||
|
GoLog("[Extension:%s] PKCE generated (verifier length: %d)\n", r.extensionID, len(verifier))
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"verifier": verifier,
|
||||||
|
"challenge": challenge,
|
||||||
|
"method": "S256",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// authGetPKCE returns the current PKCE verifier and challenge (if generated)
|
||||||
|
func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
||||||
|
extensionAuthStateMu.RLock()
|
||||||
|
defer extensionAuthStateMu.RUnlock()
|
||||||
|
|
||||||
|
state, exists := extensionAuthState[r.extensionID]
|
||||||
|
if !exists || state.PKCEVerifier == "" {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{})
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"verifier": state.PKCEVerifier,
|
||||||
|
"challenge": state.PKCEChallenge,
|
||||||
|
"method": "S256",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// authStartOAuthWithPKCE is a high-level helper that generates PKCE and opens OAuth URL
|
||||||
|
// config: { authUrl, clientId, redirectUri, scope, extraParams }
|
||||||
|
// Returns: { success, authUrl, pkce: { verifier, challenge } }
|
||||||
|
func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "config object is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
configObj := call.Arguments[0].Export()
|
||||||
|
config, ok := configObj.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "config must be an object",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required fields
|
||||||
|
authURL, _ := config["authUrl"].(string)
|
||||||
|
clientID, _ := config["clientId"].(string)
|
||||||
|
redirectURI, _ := config["redirectUri"].(string)
|
||||||
|
|
||||||
|
if authURL == "" || clientID == "" || redirectURI == "" {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "authUrl, clientId, and redirectUri are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional fields
|
||||||
|
scope, _ := config["scope"].(string)
|
||||||
|
extraParams, _ := config["extraParams"].(map[string]interface{})
|
||||||
|
|
||||||
|
// Generate PKCE
|
||||||
|
verifier, err := generatePKCEVerifier(64)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to generate PKCE: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
challenge := generatePKCEChallenge(verifier)
|
||||||
|
|
||||||
|
// Store PKCE in auth state
|
||||||
|
extensionAuthStateMu.Lock()
|
||||||
|
state, exists := extensionAuthState[r.extensionID]
|
||||||
|
if !exists {
|
||||||
|
state = &ExtensionAuthState{}
|
||||||
|
extensionAuthState[r.extensionID] = state
|
||||||
|
}
|
||||||
|
state.PKCEVerifier = verifier
|
||||||
|
state.PKCEChallenge = challenge
|
||||||
|
state.AuthCode = "" // Clear any previous auth code
|
||||||
|
extensionAuthStateMu.Unlock()
|
||||||
|
|
||||||
|
// Build OAuth URL with PKCE parameters
|
||||||
|
parsedURL, err := url.Parse(authURL)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("invalid authUrl: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
query := parsedURL.Query()
|
||||||
|
query.Set("client_id", clientID)
|
||||||
|
query.Set("redirect_uri", redirectURI)
|
||||||
|
query.Set("response_type", "code")
|
||||||
|
query.Set("code_challenge", challenge)
|
||||||
|
query.Set("code_challenge_method", "S256")
|
||||||
|
|
||||||
|
if scope != "" {
|
||||||
|
query.Set("scope", scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add extra params
|
||||||
|
for k, v := range extraParams {
|
||||||
|
query.Set(k, fmt.Sprintf("%v", v))
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedURL.RawQuery = query.Encode()
|
||||||
|
fullAuthURL := parsedURL.String()
|
||||||
|
|
||||||
|
// Store pending auth request for Flutter
|
||||||
|
pendingAuthRequestsMu.Lock()
|
||||||
|
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||||
|
ExtensionID: r.extensionID,
|
||||||
|
AuthURL: fullAuthURL,
|
||||||
|
CallbackURL: redirectURI,
|
||||||
|
}
|
||||||
|
pendingAuthRequestsMu.Unlock()
|
||||||
|
|
||||||
|
GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, fullAuthURL)
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"authUrl": fullAuthURL,
|
||||||
|
"pkce": map[string]interface{}{
|
||||||
|
"verifier": verifier,
|
||||||
|
"challenge": challenge,
|
||||||
|
"method": "S256",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "config object is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
configObj := call.Arguments[0].Export()
|
||||||
|
config, ok := configObj.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "config must be an object",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required fields
|
||||||
|
tokenURL, _ := config["tokenUrl"].(string)
|
||||||
|
clientID, _ := config["clientId"].(string)
|
||||||
|
redirectURI, _ := config["redirectUri"].(string)
|
||||||
|
code, _ := config["code"].(string)
|
||||||
|
|
||||||
|
if tokenURL == "" || clientID == "" || code == "" {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "tokenUrl, clientId, and code are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get stored PKCE verifier
|
||||||
|
extensionAuthStateMu.RLock()
|
||||||
|
state, exists := extensionAuthState[r.extensionID]
|
||||||
|
var verifier string
|
||||||
|
if exists {
|
||||||
|
verifier = state.PKCEVerifier
|
||||||
|
}
|
||||||
|
extensionAuthStateMu.RUnlock()
|
||||||
|
|
||||||
|
if verifier == "" {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "no PKCE verifier found - call generatePKCE or startOAuthWithPKCE first",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate domain
|
||||||
|
if err := r.validateDomain(tokenURL); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build token request body
|
||||||
|
formData := url.Values{}
|
||||||
|
formData.Set("grant_type", "authorization_code")
|
||||||
|
formData.Set("client_id", clientID)
|
||||||
|
formData.Set("code", code)
|
||||||
|
formData.Set("code_verifier", verifier)
|
||||||
|
if redirectURI != "" {
|
||||||
|
formData.Set("redirect_uri", redirectURI)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add extra params
|
||||||
|
if extraParams, ok := config["extraParams"].(map[string]interface{}); ok {
|
||||||
|
for k, v := range extraParams {
|
||||||
|
formData.Set(k, fmt.Sprintf("%v", v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make token request
|
||||||
|
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||||
|
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse response
|
||||||
|
var tokenResp map[string]interface{}
|
||||||
|
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to parse token response: %v", err),
|
||||||
|
"body": string(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for error in response
|
||||||
|
if errMsg, ok := tokenResp["error"].(string); ok {
|
||||||
|
errDesc, _ := tokenResp["error_description"].(string)
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": errMsg,
|
||||||
|
"error_description": errDesc,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract tokens
|
||||||
|
accessToken, _ := tokenResp["access_token"].(string)
|
||||||
|
refreshToken, _ := tokenResp["refresh_token"].(string)
|
||||||
|
expiresIn, _ := tokenResp["expires_in"].(float64)
|
||||||
|
|
||||||
|
if accessToken == "" {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "no access_token in response",
|
||||||
|
"body": string(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store tokens in auth state
|
||||||
|
extensionAuthStateMu.Lock()
|
||||||
|
state, exists = extensionAuthState[r.extensionID]
|
||||||
|
if !exists {
|
||||||
|
state = &ExtensionAuthState{}
|
||||||
|
extensionAuthState[r.extensionID] = state
|
||||||
|
}
|
||||||
|
state.AccessToken = accessToken
|
||||||
|
state.RefreshToken = refreshToken
|
||||||
|
state.IsAuthenticated = true
|
||||||
|
if expiresIn > 0 {
|
||||||
|
state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||||
|
}
|
||||||
|
// Clear PKCE after successful exchange
|
||||||
|
state.PKCEVerifier = ""
|
||||||
|
state.PKCEChallenge = ""
|
||||||
|
extensionAuthStateMu.Unlock()
|
||||||
|
|
||||||
|
GoLog("[Extension:%s] PKCE token exchange successful\n", r.extensionID)
|
||||||
|
|
||||||
|
// Return full token response
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"access_token": accessToken,
|
||||||
|
"refresh_token": refreshToken,
|
||||||
|
"token_type": tokenResp["token_type"],
|
||||||
|
}
|
||||||
|
if expiresIn > 0 {
|
||||||
|
result["expires_in"] = expiresIn
|
||||||
|
}
|
||||||
|
// Include any additional fields from response
|
||||||
|
if scope, ok := tokenResp["scope"].(string); ok {
|
||||||
|
result["scope"] = scope
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(result)
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
// Package gobackend provides FFmpeg API for extension runtime
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ==================== FFmpeg API (Post-Processing) ====================
|
||||||
|
|
||||||
|
// FFmpegCommand holds a pending FFmpeg command for Flutter to execute
|
||||||
|
type FFmpegCommand struct {
|
||||||
|
ExtensionID string
|
||||||
|
Command string
|
||||||
|
InputPath string
|
||||||
|
OutputPath string
|
||||||
|
Completed bool
|
||||||
|
Success bool
|
||||||
|
Error string
|
||||||
|
Output string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global FFmpeg command queue
|
||||||
|
var (
|
||||||
|
ffmpegCommands = make(map[string]*FFmpegCommand)
|
||||||
|
ffmpegCommandsMu sync.RWMutex
|
||||||
|
ffmpegCommandID int64
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetPendingFFmpegCommand returns a pending FFmpeg command (called from Flutter)
|
||||||
|
func GetPendingFFmpegCommand(commandID string) *FFmpegCommand {
|
||||||
|
ffmpegCommandsMu.RLock()
|
||||||
|
defer ffmpegCommandsMu.RUnlock()
|
||||||
|
return ffmpegCommands[commandID]
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFFmpegCommandResult sets the result of an FFmpeg command (called from Flutter)
|
||||||
|
func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg string) {
|
||||||
|
ffmpegCommandsMu.Lock()
|
||||||
|
defer ffmpegCommandsMu.Unlock()
|
||||||
|
if cmd, exists := ffmpegCommands[commandID]; exists {
|
||||||
|
cmd.Completed = true
|
||||||
|
cmd.Success = success
|
||||||
|
cmd.Output = output
|
||||||
|
cmd.Error = errorMsg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearFFmpegCommand removes a completed FFmpeg command
|
||||||
|
func ClearFFmpegCommand(commandID string) {
|
||||||
|
ffmpegCommandsMu.Lock()
|
||||||
|
defer ffmpegCommandsMu.Unlock()
|
||||||
|
delete(ffmpegCommands, commandID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ffmpegExecute queues an FFmpeg command for execution by Flutter
|
||||||
|
func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "command is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
command := call.Arguments[0].String()
|
||||||
|
|
||||||
|
// Generate unique command ID
|
||||||
|
ffmpegCommandsMu.Lock()
|
||||||
|
ffmpegCommandID++
|
||||||
|
cmdID := fmt.Sprintf("%s_%d", r.extensionID, ffmpegCommandID)
|
||||||
|
ffmpegCommands[cmdID] = &FFmpegCommand{
|
||||||
|
ExtensionID: r.extensionID,
|
||||||
|
Command: command,
|
||||||
|
Completed: false,
|
||||||
|
}
|
||||||
|
ffmpegCommandsMu.Unlock()
|
||||||
|
|
||||||
|
GoLog("[Extension:%s] FFmpeg command queued: %s\n", r.extensionID, cmdID)
|
||||||
|
|
||||||
|
// Wait for completion (with timeout)
|
||||||
|
timeout := 5 * time.Minute
|
||||||
|
start := time.Now()
|
||||||
|
for {
|
||||||
|
ffmpegCommandsMu.RLock()
|
||||||
|
cmd := ffmpegCommands[cmdID]
|
||||||
|
completed := cmd != nil && cmd.Completed
|
||||||
|
ffmpegCommandsMu.RUnlock()
|
||||||
|
|
||||||
|
if completed {
|
||||||
|
ffmpegCommandsMu.RLock()
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"success": cmd.Success,
|
||||||
|
"output": cmd.Output,
|
||||||
|
}
|
||||||
|
if cmd.Error != "" {
|
||||||
|
result["error"] = cmd.Error
|
||||||
|
}
|
||||||
|
ffmpegCommandsMu.RUnlock()
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
ClearFFmpegCommand(cmdID)
|
||||||
|
return r.vm.ToValue(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Since(start) > timeout {
|
||||||
|
ClearFFmpegCommand(cmdID)
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "FFmpeg command timed out",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ffmpegGetInfo gets audio file information using FFprobe
|
||||||
|
func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "file path is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := call.Arguments[0].String()
|
||||||
|
|
||||||
|
// Use Go's built-in audio quality function
|
||||||
|
quality, err := GetAudioQuality(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"bit_depth": quality.BitDepth,
|
||||||
|
"sample_rate": quality.SampleRate,
|
||||||
|
"total_samples": quality.TotalSamples,
|
||||||
|
"duration": float64(quality.TotalSamples) / float64(quality.SampleRate),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ffmpegConvert is a helper for common conversion operations
|
||||||
|
func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "input and output paths are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
inputPath := call.Arguments[0].String()
|
||||||
|
outputPath := call.Arguments[1].String()
|
||||||
|
|
||||||
|
// Get options if provided
|
||||||
|
options := map[string]interface{}{}
|
||||||
|
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||||
|
if opts, ok := call.Arguments[2].Export().(map[string]interface{}); ok {
|
||||||
|
options = opts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build FFmpeg command
|
||||||
|
var cmdParts []string
|
||||||
|
cmdParts = append(cmdParts, "-i", fmt.Sprintf("%q", inputPath))
|
||||||
|
|
||||||
|
// Audio codec
|
||||||
|
if codec, ok := options["codec"].(string); ok {
|
||||||
|
cmdParts = append(cmdParts, "-c:a", codec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bitrate
|
||||||
|
if bitrate, ok := options["bitrate"].(string); ok {
|
||||||
|
cmdParts = append(cmdParts, "-b:a", bitrate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample rate
|
||||||
|
if sampleRate, ok := options["sample_rate"].(float64); ok {
|
||||||
|
cmdParts = append(cmdParts, "-ar", fmt.Sprintf("%d", int(sampleRate)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channels
|
||||||
|
if channels, ok := options["channels"].(float64); ok {
|
||||||
|
cmdParts = append(cmdParts, "-ac", fmt.Sprintf("%d", int(channels)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overwrite output
|
||||||
|
cmdParts = append(cmdParts, "-y", fmt.Sprintf("%q", outputPath))
|
||||||
|
|
||||||
|
command := strings.Join(cmdParts, " ")
|
||||||
|
|
||||||
|
// Execute via ffmpegExecute
|
||||||
|
execCall := goja.FunctionCall{
|
||||||
|
Arguments: []goja.Value{r.vm.ToValue(command)},
|
||||||
|
}
|
||||||
|
return r.ffmpegExecute(execCall)
|
||||||
|
}
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
// Package gobackend provides File API for extension runtime
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ==================== File API (Sandboxed) ====================
|
||||||
|
|
||||||
|
// List of allowed directories for file operations (set by Go backend for download operations)
|
||||||
|
var (
|
||||||
|
allowedDownloadDirs []string
|
||||||
|
allowedDownloadDirsMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetAllowedDownloadDirs sets the list of directories where extensions can write files
|
||||||
|
// This should be called by the Go backend when setting up download paths
|
||||||
|
func SetAllowedDownloadDirs(dirs []string) {
|
||||||
|
allowedDownloadDirsMu.Lock()
|
||||||
|
defer allowedDownloadDirsMu.Unlock()
|
||||||
|
allowedDownloadDirs = dirs
|
||||||
|
GoLog("[Extension] Allowed download directories set: %v\n", dirs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddAllowedDownloadDir adds a directory to the allowed list
|
||||||
|
func AddAllowedDownloadDir(dir string) {
|
||||||
|
allowedDownloadDirsMu.Lock()
|
||||||
|
defer allowedDownloadDirsMu.Unlock()
|
||||||
|
absDir, err := filepath.Abs(dir)
|
||||||
|
if err == nil {
|
||||||
|
allowedDownloadDirs = append(allowedDownloadDirs, absDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPathInAllowedDirs checks if an absolute path is within any allowed directory
|
||||||
|
func isPathInAllowedDirs(absPath string) bool {
|
||||||
|
allowedDownloadDirsMu.RLock()
|
||||||
|
defer allowedDownloadDirsMu.RUnlock()
|
||||||
|
|
||||||
|
for _, allowedDir := range allowedDownloadDirs {
|
||||||
|
if strings.HasPrefix(absPath, allowedDir) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// validatePath checks if the path is within the extension's sandbox
|
||||||
|
// Security: Absolute paths are BLOCKED unless they're in allowed download directories
|
||||||
|
// Extensions should use relative paths for their own data storage
|
||||||
|
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
||||||
|
// Check if extension has file permission
|
||||||
|
if !r.manifest.Permissions.File {
|
||||||
|
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean and resolve the path
|
||||||
|
cleanPath := filepath.Clean(path)
|
||||||
|
|
||||||
|
// SECURITY: Block absolute paths by default
|
||||||
|
// Only allow if path is in explicitly allowed download directories
|
||||||
|
if filepath.IsAbs(cleanPath) {
|
||||||
|
absPath, err := filepath.Abs(cleanPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if path is in allowed download directories
|
||||||
|
if isPathInAllowedDirs(absPath) {
|
||||||
|
return absPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block all other absolute paths
|
||||||
|
return "", fmt.Errorf("file access denied: absolute paths are not allowed. Use relative paths within extension sandbox")
|
||||||
|
}
|
||||||
|
|
||||||
|
// For relative paths, join with data directory (extension's sandbox)
|
||||||
|
fullPath := filepath.Join(r.dataDir, cleanPath)
|
||||||
|
|
||||||
|
// Resolve to absolute path
|
||||||
|
absPath, err := filepath.Abs(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure path is within data directory (prevent path traversal)
|
||||||
|
absDataDir, _ := filepath.Abs(r.dataDir)
|
||||||
|
if !strings.HasPrefix(absPath, absDataDir) {
|
||||||
|
return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return absPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileDownload downloads a file from URL to the specified path
|
||||||
|
// Supports progress callback via options.onProgress
|
||||||
|
func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "URL and output path are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
urlStr := call.Arguments[0].String()
|
||||||
|
outputPath := call.Arguments[1].String()
|
||||||
|
|
||||||
|
// Validate domain
|
||||||
|
if err := r.validateDomain(urlStr); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate output path (allows absolute paths for download queue)
|
||||||
|
fullPath, err := r.validatePath(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get options if provided
|
||||||
|
var onProgress goja.Callable
|
||||||
|
var headers map[string]string
|
||||||
|
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||||
|
optionsObj := call.Arguments[2].Export()
|
||||||
|
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||||
|
// Extract headers
|
||||||
|
if h, ok := opts["headers"].(map[string]interface{}); ok {
|
||||||
|
headers = make(map[string]string)
|
||||||
|
for k, v := range h {
|
||||||
|
headers[k] = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Extract onProgress callback
|
||||||
|
if progressVal, ok := opts["onProgress"]; ok {
|
||||||
|
if callable, ok := goja.AssertFunction(r.vm.ToValue(progressVal)); ok {
|
||||||
|
onProgress = callable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create directory if needed
|
||||||
|
dir := filepath.Dir(fullPath)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to create directory: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create HTTP request
|
||||||
|
req, err := http.NewRequest("GET", urlStr, nil)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set headers
|
||||||
|
for k, v := range headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
if req.Header.Get("User-Agent") == "" {
|
||||||
|
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download file
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("HTTP error: %d", resp.StatusCode),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create output file
|
||||||
|
out, err := os.Create(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to create file: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
// Get content length for progress
|
||||||
|
contentLength := resp.ContentLength
|
||||||
|
|
||||||
|
// Copy content with progress reporting
|
||||||
|
var written int64
|
||||||
|
buf := make([]byte, 32*1024) // 32KB buffer
|
||||||
|
for {
|
||||||
|
nr, er := resp.Body.Read(buf)
|
||||||
|
if nr > 0 {
|
||||||
|
nw, ew := out.Write(buf[0:nr])
|
||||||
|
if nw < 0 || nr < nw {
|
||||||
|
nw = 0
|
||||||
|
if ew == nil {
|
||||||
|
ew = fmt.Errorf("invalid write result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
written += int64(nw)
|
||||||
|
if ew != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to write file: %v", ew),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if nr != nw {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "short write",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report progress
|
||||||
|
if onProgress != nil && contentLength > 0 {
|
||||||
|
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if er != nil {
|
||||||
|
if er != io.EOF {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to read response: %v", er),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Extension:%s] Downloaded %d bytes to %s\n", r.extensionID, written, fullPath)
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"path": fullPath,
|
||||||
|
"size": written,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileExists checks if a file exists in the sandbox
|
||||||
|
func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := call.Arguments[0].String()
|
||||||
|
fullPath, err := r.validatePath(path)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = os.Stat(fullPath)
|
||||||
|
return r.vm.ToValue(err == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileDelete deletes a file in the sandbox
|
||||||
|
func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "path is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
path := call.Arguments[0].String()
|
||||||
|
fullPath, err := r.validatePath(path)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(fullPath); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileRead reads a file from the sandbox
|
||||||
|
func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "path is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
path := call.Arguments[0].String()
|
||||||
|
fullPath, err := r.validatePath(path)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"data": string(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileWrite writes data to a file in the sandbox
|
||||||
|
func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "path and data are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
path := call.Arguments[0].String()
|
||||||
|
data := call.Arguments[1].String()
|
||||||
|
|
||||||
|
fullPath, err := r.validatePath(path)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create directory if needed
|
||||||
|
dir := filepath.Dir(fullPath)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to create directory: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(fullPath, []byte(data), 0644); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"path": fullPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileCopy copies a file within the sandbox
|
||||||
|
func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "source and destination paths are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
srcPath := call.Arguments[0].String()
|
||||||
|
dstPath := call.Arguments[1].String()
|
||||||
|
|
||||||
|
fullSrc, err := r.validatePath(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fullDst, err := r.validatePath(dstPath)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read source file
|
||||||
|
data, err := os.ReadFile(fullSrc)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to read source: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create destination directory if needed
|
||||||
|
dir := filepath.Dir(fullDst)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to create directory: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to destination
|
||||||
|
if err := os.WriteFile(fullDst, data, 0644); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to write destination: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"path": fullDst,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileMove moves/renames a file within the sandbox
|
||||||
|
func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "source and destination paths are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
srcPath := call.Arguments[0].String()
|
||||||
|
dstPath := call.Arguments[1].String()
|
||||||
|
|
||||||
|
fullSrc, err := r.validatePath(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fullDst, err := r.validatePath(dstPath)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create destination directory if needed
|
||||||
|
dir := filepath.Dir(fullDst)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to create directory: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(fullSrc, fullDst); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to move file: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"path": fullDst,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileGetSize returns the size of a file in bytes
|
||||||
|
func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "path is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
path := call.Arguments[0].String()
|
||||||
|
fullPath, err := r.validatePath(path)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"size": info.Size(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
// Package gobackend provides HTTP API for extension runtime
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ==================== HTTP API (Sandboxed) ====================
|
||||||
|
|
||||||
|
// HTTPResponse represents the response from an HTTP request
|
||||||
|
type HTTPResponse struct {
|
||||||
|
StatusCode int `json:"statusCode"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Headers map[string]string `json:"headers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateDomain checks if the domain is allowed by the extension's permissions
|
||||||
|
func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
||||||
|
parsed, err := url.Parse(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := parsed.Hostname()
|
||||||
|
|
||||||
|
// Block private/local network access (SSRF protection)
|
||||||
|
if isPrivateIP(domain) {
|
||||||
|
return fmt.Errorf("network access denied: private/local network '%s' not allowed", domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !r.manifest.IsDomainAllowed(domain) {
|
||||||
|
return fmt.Errorf("network access denied: domain '%s' not in allowed list", domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpGet performs a GET request (sandboxed)
|
||||||
|
func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": "URL is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
urlStr := call.Arguments[0].String()
|
||||||
|
|
||||||
|
// Validate domain
|
||||||
|
if err := r.validateDomain(urlStr); err != nil {
|
||||||
|
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get headers if provided
|
||||||
|
headers := make(map[string]string)
|
||||||
|
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||||
|
headersObj := call.Arguments[1].Export()
|
||||||
|
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||||
|
for k, v := range h {
|
||||||
|
headers[k] = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create request
|
||||||
|
req, err := http.NewRequest("GET", urlStr, nil)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set headers - user headers first
|
||||||
|
for k, v := range headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
// Only set default User-Agent if not provided by extension
|
||||||
|
if req.Header.Get("User-Agent") == "" {
|
||||||
|
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute request
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Read body
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract response headers - return all values as arrays for multi-value headers (cookies, etc.)
|
||||||
|
respHeaders := make(map[string]interface{})
|
||||||
|
for k, v := range resp.Header {
|
||||||
|
if len(v) == 1 {
|
||||||
|
respHeaders[k] = v[0]
|
||||||
|
} else {
|
||||||
|
respHeaders[k] = v // Return as array if multiple values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"statusCode": resp.StatusCode,
|
||||||
|
"status": resp.StatusCode, // Alias for convenience
|
||||||
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
|
"body": string(body),
|
||||||
|
"headers": respHeaders,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpPost performs a POST request (sandboxed)
|
||||||
|
func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": "URL is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
urlStr := call.Arguments[0].String()
|
||||||
|
|
||||||
|
// Validate domain
|
||||||
|
if err := r.validateDomain(urlStr); err != nil {
|
||||||
|
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get body if provided - support both string and object
|
||||||
|
var bodyStr string
|
||||||
|
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||||
|
bodyArg := call.Arguments[1].Export()
|
||||||
|
switch v := bodyArg.(type) {
|
||||||
|
case string:
|
||||||
|
bodyStr = v
|
||||||
|
case map[string]interface{}, []interface{}:
|
||||||
|
// Auto-stringify objects and arrays to JSON
|
||||||
|
jsonBytes, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": fmt.Sprintf("failed to stringify body: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
bodyStr = string(jsonBytes)
|
||||||
|
default:
|
||||||
|
// Fallback to string conversion
|
||||||
|
bodyStr = call.Arguments[1].String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get headers if provided
|
||||||
|
headers := make(map[string]string)
|
||||||
|
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||||
|
headersObj := call.Arguments[2].Export()
|
||||||
|
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||||
|
for k, v := range h {
|
||||||
|
headers[k] = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create request
|
||||||
|
req, err := http.NewRequest("POST", urlStr, strings.NewReader(bodyStr))
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set headers - user headers first
|
||||||
|
for k, v := range headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
// Only set defaults if not provided by extension
|
||||||
|
if req.Header.Get("User-Agent") == "" {
|
||||||
|
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||||
|
}
|
||||||
|
if req.Header.Get("Content-Type") == "" {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute request
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Read body
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract response headers - return all values as arrays for multi-value headers
|
||||||
|
respHeaders := make(map[string]interface{})
|
||||||
|
for k, v := range resp.Header {
|
||||||
|
if len(v) == 1 {
|
||||||
|
respHeaders[k] = v[0]
|
||||||
|
} else {
|
||||||
|
respHeaders[k] = v // Return as array if multiple values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"statusCode": resp.StatusCode,
|
||||||
|
"status": resp.StatusCode, // Alias for convenience
|
||||||
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
|
"body": string(body),
|
||||||
|
"headers": respHeaders,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpRequest performs a generic HTTP request (GET, POST, PUT, DELETE, etc.)
|
||||||
|
// Usage: http.request(url, options) where options = { method, body, headers }
|
||||||
|
func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": "URL is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
urlStr := call.Arguments[0].String()
|
||||||
|
|
||||||
|
// Validate domain
|
||||||
|
if err := r.validateDomain(urlStr); err != nil {
|
||||||
|
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default options
|
||||||
|
method := "GET"
|
||||||
|
var bodyStr string
|
||||||
|
headers := make(map[string]string)
|
||||||
|
|
||||||
|
// Parse options if provided
|
||||||
|
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||||
|
optionsObj := call.Arguments[1].Export()
|
||||||
|
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||||
|
// Get method
|
||||||
|
if m, ok := opts["method"].(string); ok {
|
||||||
|
method = strings.ToUpper(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get body - support both string and object
|
||||||
|
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
|
||||||
|
switch v := bodyArg.(type) {
|
||||||
|
case string:
|
||||||
|
bodyStr = v
|
||||||
|
case map[string]interface{}, []interface{}:
|
||||||
|
// Auto-stringify objects and arrays to JSON
|
||||||
|
jsonBytes, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": fmt.Sprintf("failed to stringify body: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
bodyStr = string(jsonBytes)
|
||||||
|
default:
|
||||||
|
bodyStr = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get headers
|
||||||
|
if h, ok := opts["headers"].(map[string]interface{}); ok {
|
||||||
|
for k, v := range h {
|
||||||
|
headers[k] = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create request
|
||||||
|
var reqBody io.Reader
|
||||||
|
if bodyStr != "" {
|
||||||
|
reqBody = strings.NewReader(bodyStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(method, urlStr, reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set headers - user headers first
|
||||||
|
for k, v := range headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
// Only set defaults if not provided by extension
|
||||||
|
if req.Header.Get("User-Agent") == "" {
|
||||||
|
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||||
|
}
|
||||||
|
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute request
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Read body
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract response headers - return all values as arrays for multi-value headers
|
||||||
|
respHeaders := make(map[string]interface{})
|
||||||
|
for k, v := range resp.Header {
|
||||||
|
if len(v) == 1 {
|
||||||
|
respHeaders[k] = v[0]
|
||||||
|
} else {
|
||||||
|
respHeaders[k] = v // Return as array if multiple values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return response with helper properties
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"statusCode": resp.StatusCode,
|
||||||
|
"status": resp.StatusCode, // Alias for convenience
|
||||||
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
|
"body": string(body),
|
||||||
|
"headers": respHeaders,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpPut performs a PUT request (shortcut for http.request with method: "PUT")
|
||||||
|
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
|
||||||
|
return r.httpMethodShortcut("PUT", call)
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpDelete performs a DELETE request (shortcut for http.request with method: "DELETE")
|
||||||
|
func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
||||||
|
return r.httpMethodShortcut("DELETE", call)
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpPatch performs a PATCH request (shortcut for http.request with method: "PATCH")
|
||||||
|
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
|
||||||
|
return r.httpMethodShortcut("PATCH", call)
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpMethodShortcut is a helper for PUT/DELETE/PATCH shortcuts
|
||||||
|
// Signature: http.put(url, body, headers) / http.delete(url, headers) / http.patch(url, body, headers)
|
||||||
|
func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": "URL is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
urlStr := call.Arguments[0].String()
|
||||||
|
|
||||||
|
// Validate domain
|
||||||
|
if err := r.validateDomain(urlStr); err != nil {
|
||||||
|
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var bodyStr string
|
||||||
|
headers := make(map[string]string)
|
||||||
|
|
||||||
|
// For DELETE, second arg is headers; for PUT/PATCH, second arg is body
|
||||||
|
if method == "DELETE" {
|
||||||
|
// http.delete(url, headers)
|
||||||
|
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||||
|
headersObj := call.Arguments[1].Export()
|
||||||
|
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||||
|
for k, v := range h {
|
||||||
|
headers[k] = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// http.put(url, body, headers) / http.patch(url, body, headers)
|
||||||
|
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||||
|
bodyArg := call.Arguments[1].Export()
|
||||||
|
switch v := bodyArg.(type) {
|
||||||
|
case string:
|
||||||
|
bodyStr = v
|
||||||
|
case map[string]interface{}, []interface{}:
|
||||||
|
jsonBytes, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": fmt.Sprintf("failed to stringify body: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
bodyStr = string(jsonBytes)
|
||||||
|
default:
|
||||||
|
bodyStr = call.Arguments[1].String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||||
|
headersObj := call.Arguments[2].Export()
|
||||||
|
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||||
|
for k, v := range h {
|
||||||
|
headers[k] = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create request
|
||||||
|
var reqBody io.Reader
|
||||||
|
if bodyStr != "" {
|
||||||
|
reqBody = strings.NewReader(bodyStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(method, urlStr, reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set headers - user headers first
|
||||||
|
for k, v := range headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
if req.Header.Get("User-Agent") == "" {
|
||||||
|
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||||
|
}
|
||||||
|
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute request
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Read body
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract response headers
|
||||||
|
respHeaders := make(map[string]interface{})
|
||||||
|
for k, v := range resp.Header {
|
||||||
|
if len(v) == 1 {
|
||||||
|
respHeaders[k] = v[0]
|
||||||
|
} else {
|
||||||
|
respHeaders[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"statusCode": resp.StatusCode,
|
||||||
|
"status": resp.StatusCode,
|
||||||
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
|
"body": string(body),
|
||||||
|
"headers": respHeaders,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpClearCookies clears all cookies for this extension
|
||||||
|
func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
|
||||||
|
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
|
||||||
|
jar.mu.Lock()
|
||||||
|
jar.cookies = make(map[string][]*http.Cookie)
|
||||||
|
jar.mu.Unlock()
|
||||||
|
GoLog("[Extension:%s] Cookies cleared\n", r.extensionID)
|
||||||
|
return r.vm.ToValue(true)
|
||||||
|
}
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// Package gobackend provides Track Matching API for extension runtime
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ==================== Track Matching API ====================
|
||||||
|
|
||||||
|
// matchingCompareStrings compares two strings with fuzzy matching
|
||||||
|
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
str1 := strings.ToLower(strings.TrimSpace(call.Arguments[0].String()))
|
||||||
|
str2 := strings.ToLower(strings.TrimSpace(call.Arguments[1].String()))
|
||||||
|
|
||||||
|
if str1 == str2 {
|
||||||
|
return r.vm.ToValue(1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate Levenshtein distance-based similarity
|
||||||
|
similarity := calculateStringSimilarity(str1, str2)
|
||||||
|
return r.vm.ToValue(similarity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchingCompareDuration compares two durations with tolerance
|
||||||
|
func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
dur1 := int(call.Arguments[0].ToInteger())
|
||||||
|
dur2 := int(call.Arguments[1].ToInteger())
|
||||||
|
|
||||||
|
// Default tolerance: 3 seconds
|
||||||
|
tolerance := 3000 // milliseconds
|
||||||
|
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) {
|
||||||
|
tolerance = int(call.Arguments[2].ToInteger())
|
||||||
|
}
|
||||||
|
|
||||||
|
diff := dur1 - dur2
|
||||||
|
if diff < 0 {
|
||||||
|
diff = -diff
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(diff <= tolerance)
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchingNormalizeString normalizes a string for comparison
|
||||||
|
func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue("")
|
||||||
|
}
|
||||||
|
|
||||||
|
str := call.Arguments[0].String()
|
||||||
|
normalized := normalizeStringForMatching(str)
|
||||||
|
return r.vm.ToValue(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateStringSimilarity calculates similarity between two strings (0-1)
|
||||||
|
func calculateStringSimilarity(s1, s2 string) float64 {
|
||||||
|
if len(s1) == 0 && len(s2) == 0 {
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
if len(s1) == 0 || len(s2) == 0 {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Levenshtein distance
|
||||||
|
distance := levenshteinDistance(s1, s2)
|
||||||
|
maxLen := len(s1)
|
||||||
|
if len(s2) > maxLen {
|
||||||
|
maxLen = len(s2)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1.0 - float64(distance)/float64(maxLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// levenshteinDistance calculates the Levenshtein distance between two strings
|
||||||
|
func levenshteinDistance(s1, s2 string) int {
|
||||||
|
if len(s1) == 0 {
|
||||||
|
return len(s2)
|
||||||
|
}
|
||||||
|
if len(s2) == 0 {
|
||||||
|
return len(s1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create matrix
|
||||||
|
matrix := make([][]int, len(s1)+1)
|
||||||
|
for i := range matrix {
|
||||||
|
matrix[i] = make([]int, len(s2)+1)
|
||||||
|
matrix[i][0] = i
|
||||||
|
}
|
||||||
|
for j := range matrix[0] {
|
||||||
|
matrix[0][j] = j
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill matrix
|
||||||
|
for i := 1; i <= len(s1); i++ {
|
||||||
|
for j := 1; j <= len(s2); j++ {
|
||||||
|
cost := 1
|
||||||
|
if s1[i-1] == s2[j-1] {
|
||||||
|
cost = 0
|
||||||
|
}
|
||||||
|
matrix[i][j] = min(
|
||||||
|
matrix[i-1][j]+1, // deletion
|
||||||
|
matrix[i][j-1]+1, // insertion
|
||||||
|
matrix[i-1][j-1]+cost, // substitution
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matrix[len(s1)][len(s2)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeStringForMatching normalizes a string for comparison
|
||||||
|
func normalizeStringForMatching(s string) string {
|
||||||
|
// Convert to lowercase
|
||||||
|
s = strings.ToLower(s)
|
||||||
|
|
||||||
|
// Remove common suffixes/prefixes
|
||||||
|
suffixes := []string{
|
||||||
|
" (remastered)", " (remaster)", " - remastered", " - remaster",
|
||||||
|
" (deluxe)", " (deluxe edition)", " - deluxe", " - deluxe edition",
|
||||||
|
" (explicit)", " (clean)", " [explicit]", " [clean]",
|
||||||
|
" (album version)", " (single version)", " (radio edit)",
|
||||||
|
" (feat.", " (ft.", " feat.", " ft.",
|
||||||
|
}
|
||||||
|
for _, suffix := range suffixes {
|
||||||
|
if idx := strings.Index(s, suffix); idx != -1 {
|
||||||
|
s = s[:idx]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove special characters
|
||||||
|
var result strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == ' ' {
|
||||||
|
result.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapse multiple spaces
|
||||||
|
s = strings.Join(strings.Fields(result.String()), " ")
|
||||||
|
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
// Package gobackend provides Browser-like Polyfills for extension runtime
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ==================== Browser-like Polyfills ====================
|
||||||
|
// These polyfills make porting browser/Node.js libraries easier
|
||||||
|
// without compromising sandbox security
|
||||||
|
|
||||||
|
// fetchPolyfill implements browser-compatible fetch() API
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
urlStr := call.Arguments[0].String()
|
||||||
|
|
||||||
|
// Validate domain
|
||||||
|
if err := r.validateDomain(urlStr); err != nil {
|
||||||
|
GoLog("[Extension:%s] fetch blocked: %v\n", r.extensionID, err)
|
||||||
|
return r.createFetchError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse options
|
||||||
|
method := "GET"
|
||||||
|
var bodyStr string
|
||||||
|
headers := make(map[string]string)
|
||||||
|
|
||||||
|
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||||
|
optionsObj := call.Arguments[1].Export()
|
||||||
|
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||||
|
// Method
|
||||||
|
if m, ok := opts["method"].(string); ok {
|
||||||
|
method = strings.ToUpper(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body - support string, object (auto-stringify), or nil
|
||||||
|
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
|
||||||
|
switch v := bodyArg.(type) {
|
||||||
|
case string:
|
||||||
|
bodyStr = v
|
||||||
|
case map[string]interface{}, []interface{}:
|
||||||
|
jsonBytes, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return r.createFetchError(fmt.Sprintf("failed to stringify body: %v", err))
|
||||||
|
}
|
||||||
|
bodyStr = string(jsonBytes)
|
||||||
|
default:
|
||||||
|
bodyStr = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headers
|
||||||
|
if h, ok := opts["headers"]; ok && h != nil {
|
||||||
|
switch hv := h.(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
for k, v := range hv {
|
||||||
|
headers[k] = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create HTTP request
|
||||||
|
var reqBody io.Reader
|
||||||
|
if bodyStr != "" {
|
||||||
|
reqBody = strings.NewReader(bodyStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(method, urlStr, reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return r.createFetchError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set headers - user headers first
|
||||||
|
for k, v := range headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
// Set defaults if not provided
|
||||||
|
if req.Header.Get("User-Agent") == "" {
|
||||||
|
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||||
|
}
|
||||||
|
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute request
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return r.createFetchError(err.Error())
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Read body
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return r.createFetchError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract response headers
|
||||||
|
respHeaders := make(map[string]interface{})
|
||||||
|
for k, v := range resp.Header {
|
||||||
|
if len(v) == 1 {
|
||||||
|
respHeaders[k] = v[0]
|
||||||
|
} else {
|
||||||
|
respHeaders[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Response object (browser-compatible)
|
||||||
|
responseObj := r.vm.NewObject()
|
||||||
|
responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300)
|
||||||
|
responseObj.Set("status", resp.StatusCode)
|
||||||
|
responseObj.Set("statusText", http.StatusText(resp.StatusCode))
|
||||||
|
responseObj.Set("headers", respHeaders)
|
||||||
|
responseObj.Set("url", urlStr)
|
||||||
|
|
||||||
|
// Store body for methods
|
||||||
|
bodyString := string(body)
|
||||||
|
|
||||||
|
// text() method - returns body as string
|
||||||
|
responseObj.Set("text", func(call goja.FunctionCall) goja.Value {
|
||||||
|
return r.vm.ToValue(bodyString)
|
||||||
|
})
|
||||||
|
|
||||||
|
// json() method - parses body as JSON
|
||||||
|
responseObj.Set("json", func(call goja.FunctionCall) goja.Value {
|
||||||
|
var result interface{}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
GoLog("[Extension:%s] fetch json() parse error: %v\n", r.extensionID, err)
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
return r.vm.ToValue(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
// arrayBuffer() method - returns body as array (simplified)
|
||||||
|
responseObj.Set("arrayBuffer", func(call goja.FunctionCall) goja.Value {
|
||||||
|
// Return as array of bytes
|
||||||
|
byteArray := make([]interface{}, len(body))
|
||||||
|
for i, b := range body {
|
||||||
|
byteArray[i] = int(b)
|
||||||
|
}
|
||||||
|
return r.vm.ToValue(byteArray)
|
||||||
|
})
|
||||||
|
|
||||||
|
return responseObj
|
||||||
|
}
|
||||||
|
|
||||||
|
// createFetchError creates a fetch error response
|
||||||
|
func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
|
||||||
|
errorObj := r.vm.NewObject()
|
||||||
|
errorObj.Set("ok", false)
|
||||||
|
errorObj.Set("status", 0)
|
||||||
|
errorObj.Set("statusText", "Network Error")
|
||||||
|
errorObj.Set("error", message)
|
||||||
|
errorObj.Set("text", func(call goja.FunctionCall) goja.Value {
|
||||||
|
return r.vm.ToValue("")
|
||||||
|
})
|
||||||
|
errorObj.Set("json", func(call goja.FunctionCall) goja.Value {
|
||||||
|
return goja.Undefined()
|
||||||
|
})
|
||||||
|
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("")
|
||||||
|
}
|
||||||
|
input := call.Arguments[0].String()
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(input)
|
||||||
|
if err != nil {
|
||||||
|
// Try URL-safe base64
|
||||||
|
decoded, err = base64.URLEncoding.DecodeString(input)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Extension:%s] atob decode error: %v\n", r.extensionID, err)
|
||||||
|
return r.vm.ToValue("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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("")
|
||||||
|
}
|
||||||
|
input := call.Arguments[0].String()
|
||||||
|
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerTextEncoderDecoder registers TextEncoder and TextDecoder classes
|
||||||
|
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||||
|
// TextEncoder constructor
|
||||||
|
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
|
||||||
|
encoder := call.This
|
||||||
|
encoder.Set("encoding", "utf-8")
|
||||||
|
|
||||||
|
// encode() method - string to Uint8Array
|
||||||
|
encoder.Set("encode", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return vm.ToValue([]byte{})
|
||||||
|
}
|
||||||
|
input := call.Arguments[0].String()
|
||||||
|
bytes := []byte(input)
|
||||||
|
|
||||||
|
// Return as array (Uint8Array-like)
|
||||||
|
result := make([]interface{}, len(bytes))
|
||||||
|
for i, b := range bytes {
|
||||||
|
result[i] = int(b)
|
||||||
|
}
|
||||||
|
return vm.ToValue(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
// encodeInto() method
|
||||||
|
encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value {
|
||||||
|
// Simplified implementation
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return vm.ToValue(map[string]interface{}{"read": 0, "written": 0})
|
||||||
|
}
|
||||||
|
input := call.Arguments[0].String()
|
||||||
|
return vm.ToValue(map[string]interface{}{
|
||||||
|
"read": len(input),
|
||||||
|
"written": len([]byte(input)),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// TextDecoder constructor
|
||||||
|
vm.Set("TextDecoder", func(call goja.ConstructorCall) *goja.Object {
|
||||||
|
decoder := call.This
|
||||||
|
|
||||||
|
// Get encoding from arguments (default: utf-8)
|
||||||
|
encoding := "utf-8"
|
||||||
|
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||||
|
encoding = call.Arguments[0].String()
|
||||||
|
}
|
||||||
|
decoder.Set("encoding", encoding)
|
||||||
|
decoder.Set("fatal", false)
|
||||||
|
decoder.Set("ignoreBOM", false)
|
||||||
|
|
||||||
|
// decode() method - Uint8Array to string
|
||||||
|
decoder.Set("decode", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return vm.ToValue("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle different input types
|
||||||
|
input := call.Arguments[0].Export()
|
||||||
|
var bytes []byte
|
||||||
|
|
||||||
|
switch v := input.(type) {
|
||||||
|
case []byte:
|
||||||
|
bytes = v
|
||||||
|
case []interface{}:
|
||||||
|
bytes = make([]byte, len(v))
|
||||||
|
for i, val := range v {
|
||||||
|
switch n := val.(type) {
|
||||||
|
case int64:
|
||||||
|
bytes[i] = byte(n)
|
||||||
|
case float64:
|
||||||
|
bytes[i] = byte(n)
|
||||||
|
case int:
|
||||||
|
bytes[i] = byte(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
// Already a string, just return it
|
||||||
|
return vm.ToValue(v)
|
||||||
|
default:
|
||||||
|
return vm.ToValue("")
|
||||||
|
}
|
||||||
|
|
||||||
|
return vm.ToValue(string(bytes))
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerURLClass registers the URL class for URL parsing
|
||||||
|
func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||||
|
vm.Set("URL", func(call goja.ConstructorCall) *goja.Object {
|
||||||
|
urlObj := call.This
|
||||||
|
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
urlObj.Set("href", "")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
urlStr := call.Arguments[0].String()
|
||||||
|
|
||||||
|
// Handle relative URLs with base
|
||||||
|
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) {
|
||||||
|
baseStr := call.Arguments[1].String()
|
||||||
|
baseURL, err := url.Parse(baseStr)
|
||||||
|
if err == nil {
|
||||||
|
relURL, err := url.Parse(urlStr)
|
||||||
|
if err == nil {
|
||||||
|
urlStr = baseURL.ResolveReference(relURL).String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
urlObj.Set("href", urlStr)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set URL properties
|
||||||
|
urlObj.Set("href", parsed.String())
|
||||||
|
urlObj.Set("protocol", parsed.Scheme+":")
|
||||||
|
urlObj.Set("host", parsed.Host)
|
||||||
|
urlObj.Set("hostname", parsed.Hostname())
|
||||||
|
urlObj.Set("port", parsed.Port())
|
||||||
|
urlObj.Set("pathname", parsed.Path)
|
||||||
|
urlObj.Set("search", "")
|
||||||
|
if parsed.RawQuery != "" {
|
||||||
|
urlObj.Set("search", "?"+parsed.RawQuery)
|
||||||
|
}
|
||||||
|
urlObj.Set("hash", "")
|
||||||
|
if parsed.Fragment != "" {
|
||||||
|
urlObj.Set("hash", "#"+parsed.Fragment)
|
||||||
|
}
|
||||||
|
urlObj.Set("origin", parsed.Scheme+"://"+parsed.Host)
|
||||||
|
urlObj.Set("username", parsed.User.Username())
|
||||||
|
password, _ := parsed.User.Password()
|
||||||
|
urlObj.Set("password", password)
|
||||||
|
|
||||||
|
// searchParams object
|
||||||
|
searchParams := vm.NewObject()
|
||||||
|
queryValues := parsed.Query()
|
||||||
|
|
||||||
|
searchParams.Set("get", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return goja.Null()
|
||||||
|
}
|
||||||
|
key := call.Arguments[0].String()
|
||||||
|
if val := queryValues.Get(key); val != "" {
|
||||||
|
return vm.ToValue(val)
|
||||||
|
}
|
||||||
|
return goja.Null()
|
||||||
|
})
|
||||||
|
|
||||||
|
searchParams.Set("getAll", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return vm.ToValue([]string{})
|
||||||
|
}
|
||||||
|
key := call.Arguments[0].String()
|
||||||
|
return vm.ToValue(queryValues[key])
|
||||||
|
})
|
||||||
|
|
||||||
|
searchParams.Set("has", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return vm.ToValue(false)
|
||||||
|
}
|
||||||
|
key := call.Arguments[0].String()
|
||||||
|
return vm.ToValue(queryValues.Has(key))
|
||||||
|
})
|
||||||
|
|
||||||
|
searchParams.Set("toString", func(call goja.FunctionCall) goja.Value {
|
||||||
|
return vm.ToValue(queryValues.Encode())
|
||||||
|
})
|
||||||
|
|
||||||
|
urlObj.Set("searchParams", searchParams)
|
||||||
|
|
||||||
|
// toString method
|
||||||
|
urlObj.Set("toString", func(call goja.FunctionCall) goja.Value {
|
||||||
|
return vm.ToValue(parsed.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
// toJSON method
|
||||||
|
urlObj.Set("toJSON", func(call goja.FunctionCall) goja.Value {
|
||||||
|
return vm.ToValue(parsed.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// URLSearchParams constructor
|
||||||
|
vm.Set("URLSearchParams", func(call goja.ConstructorCall) *goja.Object {
|
||||||
|
paramsObj := call.This
|
||||||
|
values := url.Values{}
|
||||||
|
|
||||||
|
// Parse initial value if provided
|
||||||
|
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||||
|
init := call.Arguments[0].Export()
|
||||||
|
switch v := init.(type) {
|
||||||
|
case string:
|
||||||
|
// Parse query string
|
||||||
|
parsed, _ := url.ParseQuery(strings.TrimPrefix(v, "?"))
|
||||||
|
values = parsed
|
||||||
|
case map[string]interface{}:
|
||||||
|
for k, val := range v {
|
||||||
|
values.Set(k, fmt.Sprintf("%v", val))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
paramsObj.Set("append", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) >= 2 {
|
||||||
|
values.Add(call.Arguments[0].String(), call.Arguments[1].String())
|
||||||
|
}
|
||||||
|
return goja.Undefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
paramsObj.Set("delete", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) >= 1 {
|
||||||
|
values.Del(call.Arguments[0].String())
|
||||||
|
}
|
||||||
|
return goja.Undefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
paramsObj.Set("get", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return goja.Null()
|
||||||
|
}
|
||||||
|
if val := values.Get(call.Arguments[0].String()); val != "" {
|
||||||
|
return vm.ToValue(val)
|
||||||
|
}
|
||||||
|
return goja.Null()
|
||||||
|
})
|
||||||
|
|
||||||
|
paramsObj.Set("getAll", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return vm.ToValue([]string{})
|
||||||
|
}
|
||||||
|
return vm.ToValue(values[call.Arguments[0].String()])
|
||||||
|
})
|
||||||
|
|
||||||
|
paramsObj.Set("has", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return vm.ToValue(false)
|
||||||
|
}
|
||||||
|
return vm.ToValue(values.Has(call.Arguments[0].String()))
|
||||||
|
})
|
||||||
|
|
||||||
|
paramsObj.Set("set", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) >= 2 {
|
||||||
|
values.Set(call.Arguments[0].String(), call.Arguments[1].String())
|
||||||
|
}
|
||||||
|
return goja.Undefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
paramsObj.Set("toString", func(call goja.FunctionCall) goja.Value {
|
||||||
|
return vm.ToValue(values.Encode())
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerJSONGlobal ensures JSON global is properly set up
|
||||||
|
func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
|
||||||
|
// JSON is already built-in to Goja, but we can enhance it
|
||||||
|
// This ensures JSON.parse and JSON.stringify work as expected
|
||||||
|
|
||||||
|
// The built-in JSON object should already work, but let's verify
|
||||||
|
// and add any missing functionality if needed
|
||||||
|
jsonScript := `
|
||||||
|
if (typeof JSON === 'undefined') {
|
||||||
|
var JSON = {
|
||||||
|
parse: function(text) {
|
||||||
|
return utils.parseJSON(text);
|
||||||
|
},
|
||||||
|
stringify: function(value, replacer, space) {
|
||||||
|
return utils.stringifyJSON(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
`
|
||||||
|
_, _ = vm.RunString(jsonScript)
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
// Package gobackend provides Storage and Credentials API for extension runtime
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ==================== Storage API ====================
|
||||||
|
|
||||||
|
// getStoragePath returns the path to the extension's storage file
|
||||||
|
func (r *ExtensionRuntime) getStoragePath() string {
|
||||||
|
return filepath.Join(r.dataDir, "storage.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadStorage loads the storage data from disk
|
||||||
|
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
|
||||||
|
storagePath := r.getStoragePath()
|
||||||
|
data, err := os.ReadFile(storagePath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return make(map[string]interface{}), nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var storage map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &storage); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return storage, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveStorage saves the storage data to disk
|
||||||
|
func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
|
||||||
|
storagePath := r.getStoragePath()
|
||||||
|
data, err := json.MarshalIndent(storage, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(storagePath, data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// storageGet retrieves a value from storage
|
||||||
|
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
key := call.Arguments[0].String()
|
||||||
|
|
||||||
|
storage, err := r.loadStorage()
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
value, exists := storage[key]
|
||||||
|
if !exists {
|
||||||
|
// Return default value if provided
|
||||||
|
if len(call.Arguments) > 1 {
|
||||||
|
return call.Arguments[1]
|
||||||
|
}
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// storageSet stores a value in storage
|
||||||
|
func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := call.Arguments[0].String()
|
||||||
|
value := call.Arguments[1].Export()
|
||||||
|
|
||||||
|
storage, err := r.loadStorage()
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
storage[key] = value
|
||||||
|
|
||||||
|
if err := r.saveStorage(storage); err != nil {
|
||||||
|
GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err)
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// storageRemove removes a value from storage
|
||||||
|
func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := call.Arguments[0].String()
|
||||||
|
|
||||||
|
storage, err := r.loadStorage()
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(storage, key)
|
||||||
|
|
||||||
|
if err := r.saveStorage(storage); err != nil {
|
||||||
|
GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err)
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Credentials API (Encrypted Storage) ====================
|
||||||
|
|
||||||
|
// getCredentialsPath returns the path to the extension's encrypted credentials file
|
||||||
|
func (r *ExtensionRuntime) getCredentialsPath() string {
|
||||||
|
return filepath.Join(r.dataDir, ".credentials.enc")
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSaltPath returns the path to the extension's encryption salt file
|
||||||
|
func (r *ExtensionRuntime) getSaltPath() string {
|
||||||
|
return filepath.Join(r.dataDir, ".cred_salt")
|
||||||
|
}
|
||||||
|
|
||||||
|
// getOrCreateSalt gets existing salt or creates a new random one
|
||||||
|
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
||||||
|
saltPath := r.getSaltPath()
|
||||||
|
|
||||||
|
// Try to read existing salt
|
||||||
|
salt, err := os.ReadFile(saltPath)
|
||||||
|
if err == nil && len(salt) == 32 {
|
||||||
|
return salt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new random salt (32 bytes)
|
||||||
|
salt = make([]byte, 32)
|
||||||
|
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate salt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save salt to file
|
||||||
|
if err := os.WriteFile(saltPath, salt, 0600); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to save salt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return salt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getEncryptionKey derives an encryption key from extension ID + random salt
|
||||||
|
func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
|
||||||
|
// Get or create per-installation random salt
|
||||||
|
salt, err := r.getOrCreateSalt()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine extension ID + random salt for key derivation
|
||||||
|
// This makes each installation unique, preventing mass decryption attacks
|
||||||
|
combined := append([]byte(r.extensionID), salt...)
|
||||||
|
hash := sha256.Sum256(combined)
|
||||||
|
return hash[:], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadCredentials loads and decrypts credentials from disk
|
||||||
|
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
||||||
|
credPath := r.getCredentialsPath()
|
||||||
|
data, err := os.ReadFile(credPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return make(map[string]interface{}), nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt the data
|
||||||
|
key, err := r.getEncryptionKey()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get encryption key: %w", err)
|
||||||
|
}
|
||||||
|
decrypted, err := decryptAES(data, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decrypt credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var creds map[string]interface{}
|
||||||
|
if err := json.Unmarshal(decrypted, &creds); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return creds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveCredentials encrypts and saves credentials to disk
|
||||||
|
func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
||||||
|
data, err := json.Marshal(creds)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt the data
|
||||||
|
key, err := r.getEncryptionKey()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get encryption key: %w", err)
|
||||||
|
}
|
||||||
|
encrypted, err := encryptAES(data, key)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
credPath := r.getCredentialsPath()
|
||||||
|
return os.WriteFile(credPath, encrypted, 0600) // Restrictive permissions
|
||||||
|
}
|
||||||
|
|
||||||
|
// credentialsStore stores an encrypted credential
|
||||||
|
func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "key and value are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
key := call.Arguments[0].String()
|
||||||
|
value := call.Arguments[1].Export()
|
||||||
|
|
||||||
|
creds, err := r.loadCredentials()
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
creds[key] = value
|
||||||
|
|
||||||
|
if err := r.saveCredentials(creds); err != nil {
|
||||||
|
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// credentialsGet retrieves a decrypted credential
|
||||||
|
func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
key := call.Arguments[0].String()
|
||||||
|
|
||||||
|
creds, err := r.loadCredentials()
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
value, exists := creds[key]
|
||||||
|
if !exists {
|
||||||
|
// Return default value if provided
|
||||||
|
if len(call.Arguments) > 1 {
|
||||||
|
return call.Arguments[1]
|
||||||
|
}
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// credentialsRemove removes a credential
|
||||||
|
func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := call.Arguments[0].String()
|
||||||
|
|
||||||
|
creds, err := r.loadCredentials()
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(creds, key)
|
||||||
|
|
||||||
|
if err := r.saveCredentials(creds); err != nil {
|
||||||
|
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// credentialsHas checks if a credential exists
|
||||||
|
func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := call.Arguments[0].String()
|
||||||
|
|
||||||
|
creds, err := r.loadCredentials()
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, exists := creds[key]
|
||||||
|
return r.vm.ToValue(exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Crypto Utilities ====================
|
||||||
|
|
||||||
|
// encryptAES encrypts data using AES-GCM
|
||||||
|
func encryptAES(plaintext []byte, key []byte) ([]byte, error) {
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
|
||||||
|
return ciphertext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decryptAES decrypts data using AES-GCM
|
||||||
|
func decryptAES(ciphertext []byte, key []byte) ([]byte, error) {
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nonceSize := gcm.NonceSize()
|
||||||
|
if len(ciphertext) < nonceSize {
|
||||||
|
return nil, fmt.Errorf("ciphertext too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
||||||
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
// Package gobackend provides Utility functions for extension runtime
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ==================== Utility Functions ====================
|
||||||
|
|
||||||
|
// base64Encode encodes a string to base64
|
||||||
|
func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue("")
|
||||||
|
}
|
||||||
|
input := call.Arguments[0].String()
|
||||||
|
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// base64Decode decodes a base64 string
|
||||||
|
func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue("")
|
||||||
|
}
|
||||||
|
input := call.Arguments[0].String()
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(input)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue("")
|
||||||
|
}
|
||||||
|
return r.vm.ToValue(string(decoded))
|
||||||
|
}
|
||||||
|
|
||||||
|
// md5Hash computes MD5 hash of a string
|
||||||
|
func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue("")
|
||||||
|
}
|
||||||
|
input := call.Arguments[0].String()
|
||||||
|
hash := md5.Sum([]byte(input))
|
||||||
|
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// sha256Hash computes SHA256 hash of a string
|
||||||
|
func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue("")
|
||||||
|
}
|
||||||
|
input := call.Arguments[0].String()
|
||||||
|
hash := sha256.Sum256([]byte(input))
|
||||||
|
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// hmacSHA256 computes HMAC-SHA256 of a message with a key
|
||||||
|
func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue("")
|
||||||
|
}
|
||||||
|
message := call.Arguments[0].String()
|
||||||
|
key := call.Arguments[1].String()
|
||||||
|
|
||||||
|
mac := hmac.New(sha256.New, []byte(key))
|
||||||
|
mac.Write([]byte(message))
|
||||||
|
return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// hmacSHA256Base64 computes HMAC-SHA256 and returns base64 encoded result
|
||||||
|
func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue("")
|
||||||
|
}
|
||||||
|
message := call.Arguments[0].String()
|
||||||
|
key := call.Arguments[1].String()
|
||||||
|
|
||||||
|
mac := hmac.New(sha256.New, []byte(key))
|
||||||
|
mac.Write([]byte(message))
|
||||||
|
return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// hmacSHA1 computes HMAC-SHA1 of a message with a key (for TOTP)
|
||||||
|
// Arguments: message (string or array of bytes), key (string or array of bytes)
|
||||||
|
// Returns: array of bytes (for TOTP dynamic truncation)
|
||||||
|
func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue([]byte{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get key - can be string or array of bytes
|
||||||
|
var keyBytes []byte
|
||||||
|
keyArg := call.Arguments[0].Export()
|
||||||
|
switch k := keyArg.(type) {
|
||||||
|
case string:
|
||||||
|
keyBytes = []byte(k)
|
||||||
|
case []interface{}:
|
||||||
|
keyBytes = make([]byte, len(k))
|
||||||
|
for i, v := range k {
|
||||||
|
if num, ok := v.(int64); ok {
|
||||||
|
keyBytes[i] = byte(num)
|
||||||
|
} else if num, ok := v.(float64); ok {
|
||||||
|
keyBytes[i] = byte(int(num))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return r.vm.ToValue([]byte{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get message - can be string or array of bytes
|
||||||
|
var msgBytes []byte
|
||||||
|
msgArg := call.Arguments[1].Export()
|
||||||
|
switch m := msgArg.(type) {
|
||||||
|
case string:
|
||||||
|
msgBytes = []byte(m)
|
||||||
|
case []interface{}:
|
||||||
|
msgBytes = make([]byte, len(m))
|
||||||
|
for i, v := range m {
|
||||||
|
if num, ok := v.(int64); ok {
|
||||||
|
msgBytes[i] = byte(num)
|
||||||
|
} else if num, ok := v.(float64); ok {
|
||||||
|
msgBytes[i] = byte(int(num))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return r.vm.ToValue([]byte{})
|
||||||
|
}
|
||||||
|
|
||||||
|
mac := hmac.New(sha1.New, keyBytes)
|
||||||
|
mac.Write(msgBytes)
|
||||||
|
result := mac.Sum(nil)
|
||||||
|
|
||||||
|
// Convert to array of numbers for JavaScript
|
||||||
|
jsArray := make([]interface{}, len(result))
|
||||||
|
for i, b := range result {
|
||||||
|
jsArray[i] = int(b)
|
||||||
|
}
|
||||||
|
return r.vm.ToValue(jsArray)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseJSON parses a JSON string
|
||||||
|
func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
input := call.Arguments[0].String()
|
||||||
|
|
||||||
|
var result interface{}
|
||||||
|
if err := json.Unmarshal([]byte(input), &result); err != nil {
|
||||||
|
GoLog("[Extension:%s] JSON parse error: %v\n", r.extensionID, err)
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stringifyJSON converts a value to JSON string
|
||||||
|
func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue("")
|
||||||
|
}
|
||||||
|
input := call.Arguments[0].Export()
|
||||||
|
|
||||||
|
data, err := json.Marshal(input)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Extension:%s] JSON stringify error: %v\n", r.extensionID, err)
|
||||||
|
return r.vm.ToValue("")
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Crypto Utilities for Extensions ====================
|
||||||
|
|
||||||
|
// cryptoEncrypt encrypts a string using AES-GCM (for extension use)
|
||||||
|
func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "plaintext and key are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext := call.Arguments[0].String()
|
||||||
|
keyStr := call.Arguments[1].String()
|
||||||
|
|
||||||
|
// Derive 32-byte key from provided key string
|
||||||
|
keyHash := sha256.Sum256([]byte(keyStr))
|
||||||
|
|
||||||
|
encrypted, err := encryptAES([]byte(plaintext), keyHash[:])
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"data": base64.StdEncoding.EncodeToString(encrypted),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// cryptoDecrypt decrypts a string using AES-GCM (for extension use)
|
||||||
|
func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "ciphertext and key are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ciphertextB64 := call.Arguments[0].String()
|
||||||
|
keyStr := call.Arguments[1].String()
|
||||||
|
|
||||||
|
ciphertext, err := base64.StdEncoding.DecodeString(ciphertextB64)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "invalid base64 ciphertext",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive 32-byte key from provided key string
|
||||||
|
keyHash := sha256.Sum256([]byte(keyStr))
|
||||||
|
|
||||||
|
decrypted, err := decryptAES(ciphertext, keyHash[:])
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"data": string(decrypted),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// cryptoGenerateKey generates a random encryption key
|
||||||
|
func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
|
||||||
|
length := 32 // Default 256-bit key
|
||||||
|
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||||
|
if l, ok := call.Arguments[0].Export().(float64); ok {
|
||||||
|
length = int(l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
key := make([]byte, length)
|
||||||
|
if _, err := rand.Read(key); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"key": base64.StdEncoding.EncodeToString(key),
|
||||||
|
"hex": hex.EncodeToString(key),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Logging Functions ====================
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
||||||
|
msg := r.formatLogArgs(call.Arguments)
|
||||||
|
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) logInfo(call goja.FunctionCall) goja.Value {
|
||||||
|
msg := r.formatLogArgs(call.Arguments)
|
||||||
|
GoLog("[Extension:%s:INFO] %s\n", r.extensionID, msg)
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) logWarn(call goja.FunctionCall) goja.Value {
|
||||||
|
msg := r.formatLogArgs(call.Arguments)
|
||||||
|
GoLog("[Extension:%s:WARN] %s\n", r.extensionID, msg)
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) logError(call goja.FunctionCall) goja.Value {
|
||||||
|
msg := r.formatLogArgs(call.Arguments)
|
||||||
|
GoLog("[Extension:%s:ERROR] %s\n", r.extensionID, msg)
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
|
||||||
|
parts := make([]string, len(args))
|
||||||
|
for i, arg := range args {
|
||||||
|
parts[i] = fmt.Sprintf("%v", arg.Export())
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Go Backend Wrappers ====================
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue("")
|
||||||
|
}
|
||||||
|
input := call.Arguments[0].String()
|
||||||
|
return r.vm.ToValue(sanitizeFilename(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterGoBackendAPIs adds more Go backend functions to the VM
|
||||||
|
func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||||
|
gobackendObj := vm.Get("gobackend")
|
||||||
|
if gobackendObj == nil || goja.IsUndefined(gobackendObj) {
|
||||||
|
gobackendObj = vm.NewObject()
|
||||||
|
vm.Set("gobackend", gobackendObj)
|
||||||
|
}
|
||||||
|
|
||||||
|
obj := gobackendObj.(*goja.Object)
|
||||||
|
|
||||||
|
// Expose sanitizeFilename
|
||||||
|
obj.Set("sanitizeFilename", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return vm.ToValue("")
|
||||||
|
}
|
||||||
|
return vm.ToValue(sanitizeFilename(call.Arguments[0].String()))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Expose getAudioQuality
|
||||||
|
obj.Set("getAudioQuality", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return vm.ToValue(map[string]interface{}{
|
||||||
|
"error": "file path is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := call.Arguments[0].String()
|
||||||
|
quality, err := GetAudioQuality(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return vm.ToValue(map[string]interface{}{
|
||||||
|
"bitDepth": quality.BitDepth,
|
||||||
|
"sampleRate": quality.SampleRate,
|
||||||
|
"totalSamples": quality.TotalSamples,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Expose buildFilename
|
||||||
|
obj.Set("buildFilename", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return vm.ToValue("")
|
||||||
|
}
|
||||||
|
|
||||||
|
template := call.Arguments[0].String()
|
||||||
|
metadataObj := call.Arguments[1].Export()
|
||||||
|
|
||||||
|
metadata, ok := metadataObj.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return vm.ToValue("")
|
||||||
|
}
|
||||||
|
|
||||||
|
return vm.ToValue(buildFilenameFromTemplate(template, metadata))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
// Package gobackend provides extension settings storage
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExtensionSettingsStore manages settings for all extensions
|
||||||
|
type ExtensionSettingsStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
dataDir string
|
||||||
|
settings map[string]map[string]interface{} // extensionID -> settings
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global settings store
|
||||||
|
var (
|
||||||
|
globalSettingsStore *ExtensionSettingsStore
|
||||||
|
globalSettingsStoreOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetExtensionSettingsStore returns the global settings store
|
||||||
|
func GetExtensionSettingsStore() *ExtensionSettingsStore {
|
||||||
|
globalSettingsStoreOnce.Do(func() {
|
||||||
|
globalSettingsStore = &ExtensionSettingsStore{
|
||||||
|
settings: make(map[string]map[string]interface{}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalSettingsStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDataDir sets the data directory for settings storage
|
||||||
|
func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
s.dataDir = dataDir
|
||||||
|
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create settings directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all existing settings
|
||||||
|
return s.loadAllSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSettingsPath returns the path to an extension's settings file
|
||||||
|
func (s *ExtensionSettingsStore) getSettingsPath(extensionID string) string {
|
||||||
|
return filepath.Join(s.dataDir, extensionID, "settings.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadAllSettings loads settings for all extensions from disk
|
||||||
|
func (s *ExtensionSettingsStore) loadAllSettings() error {
|
||||||
|
entries, err := os.ReadDir(s.dataDir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
extensionID := entry.Name()
|
||||||
|
settings, err := s.loadSettings(extensionID)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[ExtensionSettings] Failed to load settings for %s: %v\n", extensionID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.settings[extensionID] = settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadSettings loads settings for a specific extension
|
||||||
|
func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]interface{}, error) {
|
||||||
|
settingsPath := s.getSettingsPath(extensionID)
|
||||||
|
data, err := os.ReadFile(settingsPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return make(map[string]interface{}), nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &settings); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveSettings saves settings for a specific extension
|
||||||
|
func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error {
|
||||||
|
settingsPath := s.getSettingsPath(extensionID)
|
||||||
|
|
||||||
|
// Create directory if needed
|
||||||
|
dir := filepath.Dir(settingsPath)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(settings, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(settingsPath, data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a setting value for an extension
|
||||||
|
// Returns error if extension or key not found (gomobile compatible)
|
||||||
|
func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
extSettings, exists := s.settings[extensionID]
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("extension '%s' settings not found", extensionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
value, exists := extSettings[key]
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("setting '%s' not found for extension '%s'", key, extensionID)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAll retrieves all settings for an extension
|
||||||
|
func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface{} {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
extSettings, exists := s.settings[extensionID]
|
||||||
|
if !exists {
|
||||||
|
return make(map[string]interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a copy
|
||||||
|
result := make(map[string]interface{})
|
||||||
|
for k, v := range extSettings {
|
||||||
|
result[k] = v
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set stores a setting value for an extension
|
||||||
|
func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{}) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := s.settings[extensionID]; !exists {
|
||||||
|
s.settings[extensionID] = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
s.settings[extensionID][key] = value
|
||||||
|
|
||||||
|
// Persist to disk
|
||||||
|
return s.saveSettings(extensionID, s.settings[extensionID])
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAll stores all settings for an extension
|
||||||
|
func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]interface{}) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
s.settings[extensionID] = settings
|
||||||
|
|
||||||
|
// Persist to disk
|
||||||
|
return s.saveSettings(extensionID, settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove removes a setting for an extension
|
||||||
|
func (s *ExtensionSettingsStore) Remove(extensionID, key string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
extSettings, exists := s.settings[extensionID]
|
||||||
|
if !exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(extSettings, key)
|
||||||
|
|
||||||
|
// Persist to disk
|
||||||
|
return s.saveSettings(extensionID, extSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveAll removes all settings for an extension
|
||||||
|
func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
delete(s.settings, extensionID)
|
||||||
|
|
||||||
|
// Remove settings file
|
||||||
|
settingsPath := s.getSettingsPath(extensionID)
|
||||||
|
if err := os.Remove(settingsPath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllExtensionSettings returns settings for all extensions as JSON
|
||||||
|
func (s *ExtensionSettingsStore) GetAllExtensionSettingsJSON() (string, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
data, err := json.Marshal(s.settings)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Extension categories
|
||||||
|
const (
|
||||||
|
CategoryMetadata = "metadata"
|
||||||
|
CategoryDownload = "download"
|
||||||
|
CategoryUtility = "utility"
|
||||||
|
CategoryLyrics = "lyrics"
|
||||||
|
CategoryIntegration = "integration"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StoreExtension represents an extension in the store
|
||||||
|
type StoreExtension struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
DownloadURL string `json:"download_url,omitempty"`
|
||||||
|
IconURL string `json:"icon_url,omitempty"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
Downloads int `json:"downloads"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
MinAppVersion string `json:"min_app_version,omitempty"`
|
||||||
|
// Alternative camelCase fields (for flexibility)
|
||||||
|
DisplayNameAlt string `json:"displayName,omitempty"`
|
||||||
|
DownloadURLAlt string `json:"downloadUrl,omitempty"`
|
||||||
|
IconURLAlt string `json:"iconUrl,omitempty"`
|
||||||
|
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDisplayName returns display name, falling back to name (private to avoid gomobile conflict)
|
||||||
|
func (e *StoreExtension) getDisplayName() string {
|
||||||
|
if e.DisplayName != "" {
|
||||||
|
return e.DisplayName
|
||||||
|
}
|
||||||
|
if e.DisplayNameAlt != "" {
|
||||||
|
return e.DisplayNameAlt
|
||||||
|
}
|
||||||
|
return e.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDownloadURL returns download URL from either field (private to avoid gomobile conflict)
|
||||||
|
func (e *StoreExtension) getDownloadURL() string {
|
||||||
|
if e.DownloadURL != "" {
|
||||||
|
return e.DownloadURL
|
||||||
|
}
|
||||||
|
return e.DownloadURLAlt
|
||||||
|
}
|
||||||
|
|
||||||
|
// getIconURL returns icon URL from either field (private to avoid gomobile conflict)
|
||||||
|
func (e *StoreExtension) getIconURL() string {
|
||||||
|
if e.IconURL != "" {
|
||||||
|
return e.IconURL
|
||||||
|
}
|
||||||
|
return e.IconURLAlt
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMinAppVersion returns min app version from either field (private to avoid gomobile conflict)
|
||||||
|
func (e *StoreExtension) getMinAppVersion() string {
|
||||||
|
if e.MinAppVersion != "" {
|
||||||
|
return e.MinAppVersion
|
||||||
|
}
|
||||||
|
return e.MinAppVersionAlt
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoreRegistry represents the extension registry
|
||||||
|
type StoreRegistry struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
Extensions []StoreExtension `json:"extensions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoreExtensionResponse is the normalized response sent to Flutter
|
||||||
|
type StoreExtensionResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
DownloadURL string `json:"download_url"`
|
||||||
|
IconURL string `json:"icon_url,omitempty"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
Downloads int `json:"downloads"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
MinAppVersion string `json:"min_app_version,omitempty"`
|
||||||
|
IsInstalled bool `json:"is_installed"`
|
||||||
|
InstalledVersion string `json:"installed_version,omitempty"`
|
||||||
|
HasUpdate bool `json:"has_update"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToResponse converts StoreExtension to normalized response
|
||||||
|
func (e *StoreExtension) ToResponse() StoreExtensionResponse {
|
||||||
|
return StoreExtensionResponse{
|
||||||
|
ID: e.ID,
|
||||||
|
Name: e.Name,
|
||||||
|
DisplayName: e.getDisplayName(),
|
||||||
|
Version: e.Version,
|
||||||
|
Author: e.Author,
|
||||||
|
Description: e.Description,
|
||||||
|
DownloadURL: e.getDownloadURL(),
|
||||||
|
IconURL: e.getIconURL(),
|
||||||
|
Category: e.Category,
|
||||||
|
Tags: e.Tags,
|
||||||
|
Downloads: e.Downloads,
|
||||||
|
UpdatedAt: e.UpdatedAt,
|
||||||
|
MinAppVersion: e.getMinAppVersion(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtensionStore manages the extension store
|
||||||
|
type ExtensionStore struct {
|
||||||
|
registryURL string
|
||||||
|
cacheDir string
|
||||||
|
cache *StoreRegistry
|
||||||
|
cacheMu sync.RWMutex
|
||||||
|
cacheTime time.Time
|
||||||
|
cacheTTL time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
extensionStore *ExtensionStore
|
||||||
|
extensionStoreMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultRegistryURL = "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Extension/main/registry.json"
|
||||||
|
cacheTTL = 30 * time.Minute
|
||||||
|
cacheFileName = "store_cache.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InitExtensionStore initializes the extension store
|
||||||
|
func InitExtensionStore(cacheDir string) *ExtensionStore {
|
||||||
|
extensionStoreMu.Lock()
|
||||||
|
defer extensionStoreMu.Unlock()
|
||||||
|
|
||||||
|
if extensionStore == nil {
|
||||||
|
extensionStore = &ExtensionStore{
|
||||||
|
registryURL: defaultRegistryURL,
|
||||||
|
cacheDir: cacheDir,
|
||||||
|
cacheTTL: cacheTTL,
|
||||||
|
}
|
||||||
|
// Try to load from disk cache
|
||||||
|
extensionStore.loadDiskCache()
|
||||||
|
}
|
||||||
|
return extensionStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetExtensionStore returns the singleton store instance
|
||||||
|
func GetExtensionStore() *ExtensionStore {
|
||||||
|
extensionStoreMu.Lock()
|
||||||
|
defer extensionStoreMu.Unlock()
|
||||||
|
return extensionStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadDiskCache loads cached registry from disk
|
||||||
|
func (s *ExtensionStore) loadDiskCache() {
|
||||||
|
if s.cacheDir == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cachePath := filepath.Join(s.cacheDir, cacheFileName)
|
||||||
|
data, err := os.ReadFile(cachePath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var cacheData struct {
|
||||||
|
Registry StoreRegistry `json:"registry"`
|
||||||
|
CacheTime int64 `json:"cache_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &cacheData); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.cache = &cacheData.Registry
|
||||||
|
s.cacheTime = time.Unix(cacheData.CacheTime, 0)
|
||||||
|
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveDiskCache saves registry to disk cache
|
||||||
|
func (s *ExtensionStore) saveDiskCache() {
|
||||||
|
if s.cacheDir == "" || s.cache == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheData := struct {
|
||||||
|
Registry StoreRegistry `json:"registry"`
|
||||||
|
CacheTime int64 `json:"cache_time"`
|
||||||
|
}{
|
||||||
|
Registry: *s.cache,
|
||||||
|
CacheTime: s.cacheTime.Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(cacheData)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cachePath := filepath.Join(s.cacheDir, cacheFileName)
|
||||||
|
os.WriteFile(cachePath, data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchRegistry fetches the extension registry from GitHub
|
||||||
|
func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) {
|
||||||
|
s.cacheMu.Lock()
|
||||||
|
defer s.cacheMu.Unlock()
|
||||||
|
|
||||||
|
// Return cached if valid and not forcing refresh
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
resp, err := client.Get(s.registryURL)
|
||||||
|
if err != nil {
|
||||||
|
// Return cached data if available on network error
|
||||||
|
if s.cache != nil {
|
||||||
|
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
|
||||||
|
return s.cache, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to fetch registry: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
if s.cache != nil {
|
||||||
|
LogWarn("ExtensionStore", "HTTP %d, using cached registry", resp.StatusCode)
|
||||||
|
return s.cache, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("registry returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read registry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var registry StoreRegistry
|
||||||
|
if err := json.Unmarshal(body, ®istry); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse registry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.cache = ®istry
|
||||||
|
s.cacheTime = time.Now()
|
||||||
|
s.saveDiskCache()
|
||||||
|
|
||||||
|
LogInfo("ExtensionStore", "Fetched %d extensions from registry", len(registry.Extensions))
|
||||||
|
return ®istry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetExtensionsWithStatus returns extensions with installation status
|
||||||
|
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
|
||||||
|
registry, err := s.FetchRegistry(false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := GetExtensionManager()
|
||||||
|
installed := make(map[string]string) // id -> version
|
||||||
|
|
||||||
|
if manager != nil {
|
||||||
|
for _, ext := range manager.GetAllExtensions() {
|
||||||
|
installed[ext.ID] = ext.Manifest.Version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]StoreExtensionResponse, len(registry.Extensions))
|
||||||
|
for i, ext := range registry.Extensions {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadExtension downloads an extension package to the specified path
|
||||||
|
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
|
||||||
|
registry, err := s.FetchRegistry(false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var ext *StoreExtension
|
||||||
|
for _, e := range registry.Extensions {
|
||||||
|
if e.ID == extensionID {
|
||||||
|
ext = &e
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ext == nil {
|
||||||
|
return fmt.Errorf("extension %s not found in store", extensionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 5 * time.Minute}
|
||||||
|
resp, err := client.Get(ext.getDownloadURL())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to download: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create destination file
|
||||||
|
out, err := os.Create(destPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create file: %w", err)
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(out, resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(destPath)
|
||||||
|
return fmt.Errorf("failed to write file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
LogInfo("ExtensionStore", "Downloaded %s to %s", ext.getDisplayName(), destPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCategories returns all available categories
|
||||||
|
func (s *ExtensionStore) GetCategories() []string {
|
||||||
|
return []string{
|
||||||
|
CategoryMetadata,
|
||||||
|
CategoryDownload,
|
||||||
|
CategoryUtility,
|
||||||
|
CategoryLyrics,
|
||||||
|
CategoryIntegration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchExtensions searches extensions by query
|
||||||
|
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) {
|
||||||
|
extensions, err := s.GetExtensionsWithStatus()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if query == "" && category == "" {
|
||||||
|
return extensions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []StoreExtensionResponse
|
||||||
|
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) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearCache clears the in-memory and disk cache
|
||||||
|
func (s *ExtensionStore) ClearCache() {
|
||||||
|
s.cacheMu.Lock()
|
||||||
|
defer s.cacheMu.Unlock()
|
||||||
|
|
||||||
|
s.cache = nil
|
||||||
|
s.cacheTime = time.Time{}
|
||||||
|
|
||||||
|
if s.cacheDir != "" {
|
||||||
|
cachePath := filepath.Join(s.cacheDir, cacheFileName)
|
||||||
|
os.Remove(cachePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
LogInfo("ExtensionStore", "Cache cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: case-insensitive contains
|
||||||
|
func containsIgnoreCase(s, substr string) bool {
|
||||||
|
return containsStr(toLower(s), substr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toLower(s string) string {
|
||||||
|
result := make([]byte, len(s))
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
if c >= 'A' && c <= 'Z' {
|
||||||
|
c += 'a' - 'A'
|
||||||
|
}
|
||||||
|
result[i] = c
|
||||||
|
}
|
||||||
|
return string(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsStr(s, substr string) bool {
|
||||||
|
return len(substr) == 0 || (len(s) >= len(substr) && findSubstring(s, substr) >= 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findSubstring(s, substr string) int {
|
||||||
|
for i := 0; i <= len(s)-len(substr); i++ {
|
||||||
|
if s[i:i+len(substr)] == substr {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseManifest_Valid(t *testing.T) {
|
||||||
|
validManifest := `{
|
||||||
|
"name": "test-provider",
|
||||||
|
"displayName": "Test Provider",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Test Author",
|
||||||
|
"description": "A test extension",
|
||||||
|
"type": ["metadata_provider"],
|
||||||
|
"permissions": {
|
||||||
|
"network": ["api.test.com"],
|
||||||
|
"storage": true
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
manifest, err := ParseManifest([]byte(validManifest))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Expected valid manifest to parse, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if manifest.Name != "test-provider" {
|
||||||
|
t.Errorf("Expected name 'test-provider', got '%s'", manifest.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if manifest.Version != "1.0.0" {
|
||||||
|
t.Errorf("Expected version '1.0.0', got '%s'", manifest.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !manifest.IsMetadataProvider() {
|
||||||
|
t.Error("Expected IsMetadataProvider() to return true")
|
||||||
|
}
|
||||||
|
|
||||||
|
if manifest.IsDownloadProvider() {
|
||||||
|
t.Error("Expected IsDownloadProvider() to return false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseManifest_MissingName(t *testing.T) {
|
||||||
|
invalidManifest := `{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Test Author",
|
||||||
|
"description": "A test extension",
|
||||||
|
"type": ["metadata_provider"]
|
||||||
|
}`
|
||||||
|
|
||||||
|
_, err := ParseManifest([]byte(invalidManifest))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for missing name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseManifest_MissingType(t *testing.T) {
|
||||||
|
invalidManifest := `{
|
||||||
|
"name": "test-provider",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Test Author",
|
||||||
|
"description": "A test extension"
|
||||||
|
}`
|
||||||
|
|
||||||
|
_, err := ParseManifest([]byte(invalidManifest))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for missing type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsDomainAllowed(t *testing.T) {
|
||||||
|
manifest := &ExtensionManifest{
|
||||||
|
Permissions: ExtensionPermissions{
|
||||||
|
Network: []string{"api.test.com", "*.example.com"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
domain string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{"api.test.com", true},
|
||||||
|
{"api.example.com", true},
|
||||||
|
{"sub.example.com", true},
|
||||||
|
{"notallowed.com", false},
|
||||||
|
{"test.com", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
result := manifest.IsDomainAllowed(tt.domain)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("IsDomainAllowed(%s) = %v, expected %v", tt.domain, result, tt.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
||||||
|
// Create a mock extension with limited network permissions
|
||||||
|
ext := &LoadedExtension{
|
||||||
|
ID: "test-ext",
|
||||||
|
Manifest: &ExtensionManifest{
|
||||||
|
Name: "test-ext",
|
||||||
|
Permissions: ExtensionPermissions{
|
||||||
|
Network: []string{"api.allowed.com", "*.wildcard.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := NewExtensionRuntime(ext)
|
||||||
|
|
||||||
|
// Test allowed domains
|
||||||
|
if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil {
|
||||||
|
t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runtime.validateDomain("https://sub.wildcard.com/path"); err != nil {
|
||||||
|
t.Errorf("Expected sub.wildcard.com to be allowed (wildcard), got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test blocked domains
|
||||||
|
if err := runtime.validateDomain("https://blocked.com/path"); err == nil {
|
||||||
|
t.Error("Expected blocked.com to be denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runtime.validateDomain("https://notallowed.com/path"); err == nil {
|
||||||
|
t.Error("Expected notallowed.com to be denied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
ext := &LoadedExtension{
|
||||||
|
ID: "test-ext",
|
||||||
|
Manifest: &ExtensionManifest{
|
||||||
|
Name: "test-ext",
|
||||||
|
Permissions: ExtensionPermissions{
|
||||||
|
File: true, // Enable file permission for test
|
||||||
|
},
|
||||||
|
},
|
||||||
|
DataDir: tempDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := NewExtensionRuntime(ext)
|
||||||
|
|
||||||
|
// Test valid path within sandbox
|
||||||
|
validPath, err := runtime.validatePath("test.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected relative path to be valid, got error: %v", err)
|
||||||
|
}
|
||||||
|
if validPath == "" {
|
||||||
|
t.Error("Expected non-empty path")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test path traversal attack
|
||||||
|
_, err = runtime.validatePath("../../../etc/passwd")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected path traversal to be blocked")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test nested path within sandbox (should be allowed)
|
||||||
|
nestedPath, err := runtime.validatePath("subdir/file.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected nested path to be valid, got error: %v", err)
|
||||||
|
}
|
||||||
|
if nestedPath == "" {
|
||||||
|
t.Error("Expected non-empty nested path")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test absolute path should be blocked (security fix)
|
||||||
|
// Use platform-appropriate absolute path
|
||||||
|
var absPath string
|
||||||
|
if filepath.IsAbs("C:\\Windows\\System32") {
|
||||||
|
absPath = "C:\\Windows\\System32\\test.txt" // Windows
|
||||||
|
} else {
|
||||||
|
absPath = "/etc/passwd" // Unix
|
||||||
|
}
|
||||||
|
_, err = runtime.validatePath(absPath)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected absolute path to be blocked")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that extension without file permission is blocked
|
||||||
|
extNoFile := &LoadedExtension{
|
||||||
|
ID: "test-ext-no-file",
|
||||||
|
Manifest: &ExtensionManifest{
|
||||||
|
Name: "test-ext-no-file",
|
||||||
|
Permissions: ExtensionPermissions{
|
||||||
|
File: false, // No file permission
|
||||||
|
},
|
||||||
|
},
|
||||||
|
DataDir: tempDir,
|
||||||
|
}
|
||||||
|
runtimeNoFile := NewExtensionRuntime(extNoFile)
|
||||||
|
_, err = runtimeNoFile.validatePath("test.txt")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected file access to be denied without file permission")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
||||||
|
ext := &LoadedExtension{
|
||||||
|
ID: "test-ext",
|
||||||
|
Manifest: &ExtensionManifest{
|
||||||
|
Name: "test-ext",
|
||||||
|
},
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := NewExtensionRuntime(ext)
|
||||||
|
vm := goja.New()
|
||||||
|
runtime.RegisterAPIs(vm)
|
||||||
|
|
||||||
|
// Test base64 encode/decode
|
||||||
|
result, err := vm.RunString(`utils.base64Encode("hello")`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("base64Encode failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.String() != "aGVsbG8=" {
|
||||||
|
t.Errorf("Expected 'aGVsbG8=', got '%s'", result.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err = vm.RunString(`utils.base64Decode("aGVsbG8=")`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("base64Decode failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.String() != "hello" {
|
||||||
|
t.Errorf("Expected 'hello', got '%s'", result.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test MD5
|
||||||
|
result, err = vm.RunString(`utils.md5("hello")`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("md5 failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.String() != "5d41402abc4b2a76b9719d911017c592" {
|
||||||
|
t.Errorf("Expected '5d41402abc4b2a76b9719d911017c592', got '%s'", result.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test JSON parse/stringify
|
||||||
|
result, err = vm.RunString(`utils.stringifyJSON({name: "test", value: 123})`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stringifyJSON failed: %v", err)
|
||||||
|
}
|
||||||
|
// JSON output may vary in order, just check it's valid
|
||||||
|
if result.String() == "" {
|
||||||
|
t.Error("Expected non-empty JSON string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
||||||
|
// Create extension with limited network permissions
|
||||||
|
ext := &LoadedExtension{
|
||||||
|
ID: "test-ext",
|
||||||
|
Manifest: &ExtensionManifest{
|
||||||
|
Name: "test-ext",
|
||||||
|
Permissions: ExtensionPermissions{
|
||||||
|
Network: []string{"api.example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := NewExtensionRuntime(ext)
|
||||||
|
|
||||||
|
// Test that private IPs are blocked (SSRF protection)
|
||||||
|
privateIPs := []string{
|
||||||
|
"http://localhost/admin",
|
||||||
|
"http://127.0.0.1/admin",
|
||||||
|
"http://192.168.1.1/admin",
|
||||||
|
"http://10.0.0.1/admin",
|
||||||
|
"http://172.16.0.1/admin",
|
||||||
|
"http://169.254.169.254/latest/meta-data/", // AWS metadata
|
||||||
|
"http://router.local/admin",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, url := range privateIPs {
|
||||||
|
err := runtime.validateDomain(url)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Expected private IP/host '%s' to be blocked", url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that allowed public domain still works
|
||||||
|
if err := runtime.validateDomain("https://api.example.com/path"); err != nil {
|
||||||
|
t.Errorf("Expected api.example.com to be allowed, got error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsPrivateIP(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
host string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
// Private IPs should be blocked
|
||||||
|
{"localhost", true},
|
||||||
|
{"127.0.0.1", true},
|
||||||
|
{"127.0.0.2", true},
|
||||||
|
{"10.0.0.1", true},
|
||||||
|
{"10.255.255.255", true},
|
||||||
|
{"172.16.0.1", true},
|
||||||
|
{"172.31.255.255", true},
|
||||||
|
{"192.168.0.1", true},
|
||||||
|
{"192.168.255.255", true},
|
||||||
|
{"169.254.169.254", true}, // AWS metadata
|
||||||
|
{"router.local", true},
|
||||||
|
{"mydevice.local", true},
|
||||||
|
|
||||||
|
// Public IPs should be allowed
|
||||||
|
{"8.8.8.8", false},
|
||||||
|
{"1.1.1.1", false},
|
||||||
|
{"api.example.com", false},
|
||||||
|
{"google.com", false},
|
||||||
|
{"172.15.0.1", false}, // Just outside 172.16-31 range
|
||||||
|
{"172.32.0.1", false}, // Just outside 172.16-31 range
|
||||||
|
{"192.167.0.1", false}, // Not 192.168.x.x
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
result := isPrivateIP(tt.host)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("isPrivateIP(%s) = %v, expected %v", tt.host, result, tt.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// Package gobackend provides timeout execution for extension JS code
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JSExecutionError represents an error during JS execution
|
||||||
|
type JSExecutionError struct {
|
||||||
|
Message string
|
||||||
|
IsTimeout bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *JSExecutionError) Error() string {
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunWithTimeout executes JavaScript code with a timeout
|
||||||
|
// Returns the result value and any error (including timeout)
|
||||||
|
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = DefaultJSTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Channel to receive result
|
||||||
|
type result struct {
|
||||||
|
value goja.Value
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
resultCh := make(chan result, 1)
|
||||||
|
|
||||||
|
// Track if we've interrupted
|
||||||
|
var interrupted bool
|
||||||
|
var interruptMu sync.Mutex
|
||||||
|
|
||||||
|
// Run script in goroutine
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
// Check if this was our interrupt
|
||||||
|
interruptMu.Lock()
|
||||||
|
wasInterrupted := interrupted
|
||||||
|
interruptMu.Unlock()
|
||||||
|
|
||||||
|
if wasInterrupted {
|
||||||
|
resultCh <- result{nil, &JSExecutionError{
|
||||||
|
Message: "execution timeout exceeded",
|
||||||
|
IsTimeout: true,
|
||||||
|
}}
|
||||||
|
} else {
|
||||||
|
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
val, err := vm.RunString(script)
|
||||||
|
resultCh <- result{val, err}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for result or timeout
|
||||||
|
select {
|
||||||
|
case res := <-resultCh:
|
||||||
|
return res.value, res.err
|
||||||
|
case <-ctx.Done():
|
||||||
|
// Timeout - interrupt the VM
|
||||||
|
interruptMu.Lock()
|
||||||
|
interrupted = true
|
||||||
|
interruptMu.Unlock()
|
||||||
|
|
||||||
|
vm.Interrupt("execution timeout")
|
||||||
|
|
||||||
|
// Wait a bit for the goroutine to finish
|
||||||
|
select {
|
||||||
|
case res := <-resultCh:
|
||||||
|
// If we got a result after interrupt, it might be the timeout error
|
||||||
|
if res.err != nil {
|
||||||
|
return nil, res.err
|
||||||
|
}
|
||||||
|
return nil, &JSExecutionError{
|
||||||
|
Message: "execution timeout exceeded",
|
||||||
|
IsTimeout: true,
|
||||||
|
}
|
||||||
|
case <-time.After(1 * time.Second):
|
||||||
|
// Force return timeout error
|
||||||
|
return nil, &JSExecutionError{
|
||||||
|
Message: "execution timeout exceeded (force)",
|
||||||
|
IsTimeout: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunWithTimeoutAndRecover runs JS with timeout and clears interrupt state after
|
||||||
|
// This should be used when you want to continue using the VM after a timeout
|
||||||
|
func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||||
|
result, err := RunWithTimeout(vm, script, timeout)
|
||||||
|
|
||||||
|
// Clear any interrupt state so VM can be reused
|
||||||
|
vm.ClearInterrupt()
|
||||||
|
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTimeoutError checks if an error is a timeout error
|
||||||
|
func IsTimeoutError(err error) bool {
|
||||||
|
if jsErr, ok := err.(*JSExecutionError); ok {
|
||||||
|
return jsErr.IsTimeout
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -5,14 +5,19 @@ go 1.24.0
|
|||||||
toolchain go1.24.5
|
toolchain go1.24.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
||||||
github.com/go-flac/flacpicture v0.3.0
|
github.com/go-flac/flacpicture v0.3.0
|
||||||
github.com/go-flac/flacvorbis v0.2.0
|
github.com/go-flac/flacvorbis v0.2.0
|
||||||
github.com/go-flac/go-flac v1.0.0
|
github.com/go-flac/go-flac v1.0.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||||
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||||
|
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
||||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 // indirect
|
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 // indirect
|
||||||
golang.org/x/mod v0.31.0 // indirect
|
golang.org/x/mod v0.31.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/text v0.3.8 // indirect
|
||||||
golang.org/x/tools v0.40.0 // indirect
|
golang.org/x/tools v0.40.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
|
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||||
|
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||||
|
github.com/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/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
|
github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
|
||||||
github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI=
|
github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI=
|
||||||
github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs=
|
github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs=
|
||||||
github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI=
|
github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI=
|
||||||
github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY=
|
github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY=
|
||||||
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
|
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
|
||||||
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||||
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||||
|
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
|
||||||
|
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
|
||||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 h1:Cr6kbEvA6nqvdHynE4CtVKlqpZB9dS1Jva/6IsHA19g=
|
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 h1:Cr6kbEvA6nqvdHynE4CtVKlqpZB9dS1Jva/6IsHA19g=
|
||||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294/go.mod h1:RdZ+3sb4CVgpCFnzv+I4haEpwqFfsfzlLHs3L7ok+e0=
|
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294/go.mod h1:RdZ+3sb4CVgpCFnzv+I4haEpwqFfsfzlLHs3L7ok+e0=
|
||||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
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/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||||
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
|||||||
@@ -1,48 +1,90 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HTTP utility functions for consistent request handling across all downloaders
|
// HTTP utility functions for consistent request handling across all downloaders
|
||||||
|
|
||||||
// User-Agent pool for Android Chrome browsers
|
// getRandomUserAgent generates a random Windows Chrome User-Agent string
|
||||||
var userAgentTemplates = []string{
|
// Uses same format as PC version (referensi/backend/spotify_metadata.go) for better API compatibility
|
||||||
"Mozilla/5.0 (Linux; Android %d; SM-G%d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
|
|
||||||
"Mozilla/5.0 (Linux; Android %d; Pixel %d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
|
|
||||||
"Mozilla/5.0 (Linux; Android %d; SM-A%d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
|
|
||||||
"Mozilla/5.0 (Linux; Android %d; Redmi Note %d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
|
|
||||||
}
|
|
||||||
|
|
||||||
// getRandomUserAgent generates a random browser-like User-Agent string (Android Chrome format)
|
|
||||||
func getRandomUserAgent() string {
|
func getRandomUserAgent() string {
|
||||||
template := userAgentTemplates[rand.Intn(len(userAgentTemplates))]
|
// Windows 10/11 Chrome format - same as PC version for maximum compatibility
|
||||||
|
// Some APIs may block mobile User-Agents, so we use desktop format
|
||||||
|
winMajor := rand.Intn(2) + 10 // Windows 10 or 11
|
||||||
|
|
||||||
androidVersion := rand.Intn(5) + 10 // Android 10-14
|
chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
|
||||||
deviceModel := rand.Intn(900) + 100 // Random model number
|
chromeBuild := rand.Intn(1500) + 3000 // Build 3000-4500
|
||||||
chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
|
chromePatch := rand.Intn(65) + 60 // Patch 60-125
|
||||||
chromeBuild := rand.Intn(5000) + 5000
|
|
||||||
chromePatch := rand.Intn(200) + 100
|
|
||||||
|
|
||||||
return fmt.Sprintf(template, androidVersion, deviceModel, chromeVersion, chromeBuild, chromePatch)
|
return fmt.Sprintf(
|
||||||
|
"Mozilla/5.0 (Windows NT %d.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
|
||||||
|
winMajor,
|
||||||
|
chromeVersion,
|
||||||
|
chromeBuild,
|
||||||
|
chromePatch,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getRandomMacUserAgent generates a random Mac Chrome User-Agent string
|
||||||
|
// Alternative format matching referensi/backend/spotify_metadata.go exactly
|
||||||
|
// Kept for potential future use
|
||||||
|
// func getRandomMacUserAgent() string {
|
||||||
|
// macMajor := rand.Intn(4) + 11 // macOS 11-14
|
||||||
|
// macMinor := rand.Intn(5) + 4 // Minor 4-8
|
||||||
|
// webkitMajor := rand.Intn(7) + 530
|
||||||
|
// webkitMinor := rand.Intn(7) + 30
|
||||||
|
// chromeMajor := rand.Intn(25) + 80
|
||||||
|
// chromeBuild := rand.Intn(1500) + 3000
|
||||||
|
// chromePatch := rand.Intn(65) + 60
|
||||||
|
// safariMajor := rand.Intn(7) + 530
|
||||||
|
// safariMinor := rand.Intn(6) + 30
|
||||||
|
//
|
||||||
|
// return fmt.Sprintf(
|
||||||
|
// "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
|
||||||
|
// macMajor,
|
||||||
|
// macMinor,
|
||||||
|
// webkitMajor,
|
||||||
|
// webkitMinor,
|
||||||
|
// chromeMajor,
|
||||||
|
// chromeBuild,
|
||||||
|
// chromePatch,
|
||||||
|
// safariMajor,
|
||||||
|
// safariMinor,
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
|
||||||
|
// getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent
|
||||||
|
// Kept for potential future use
|
||||||
|
// func getRandomDesktopUserAgent() string {
|
||||||
|
// if rand.Intn(2) == 0 {
|
||||||
|
// return getRandomUserAgent() // Windows
|
||||||
|
// }
|
||||||
|
// return getRandomMacUserAgent() // Mac
|
||||||
|
// }
|
||||||
|
|
||||||
// Default timeout values
|
// Default timeout values
|
||||||
const (
|
const (
|
||||||
DefaultTimeout = 60 * time.Second // Default HTTP timeout
|
DefaultTimeout = 60 * time.Second // Default HTTP timeout
|
||||||
DownloadTimeout = 120 * time.Second // Timeout for file downloads
|
DownloadTimeout = 120 * time.Second // Timeout for file downloads
|
||||||
SongLinkTimeout = 30 * time.Second // Timeout for SongLink API
|
SongLinkTimeout = 30 * time.Second // Timeout for SongLink API
|
||||||
DefaultMaxRetries = 3 // Default retry count
|
DefaultMaxRetries = 3 // Default retry count
|
||||||
DefaultRetryDelay = 1 * time.Second // Initial retry delay
|
DefaultRetryDelay = 1 * time.Second // Initial retry delay
|
||||||
)
|
)
|
||||||
|
|
||||||
// Shared transport with connection pooling to prevent TCP exhaustion
|
// Shared transport with connection pooling to prevent TCP exhaustion
|
||||||
|
// Optimized for large file downloads (FLAC ~30-50MB)
|
||||||
var sharedTransport = &http.Transport{
|
var sharedTransport = &http.Transport{
|
||||||
DialContext: (&net.Dialer{
|
DialContext: (&net.Dialer{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
@@ -56,6 +98,9 @@ var sharedTransport = &http.Transport{
|
|||||||
ExpectContinueTimeout: 1 * time.Second,
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
DisableKeepAlives: false, // Enable keep-alives for connection reuse
|
DisableKeepAlives: false, // Enable keep-alives for connection reuse
|
||||||
ForceAttemptHTTP2: true,
|
ForceAttemptHTTP2: true,
|
||||||
|
WriteBufferSize: 64 * 1024, // 64KB write buffer
|
||||||
|
ReadBufferSize: 64 * 1024, // 64KB read buffer
|
||||||
|
DisableCompression: true, // FLAC is already compressed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared HTTP client for general requests (reuses connections)
|
// Shared HTTP client for general requests (reuses connections)
|
||||||
@@ -96,9 +141,15 @@ func CloseIdleConnections() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header
|
// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header
|
||||||
|
// Also checks for ISP blocking on errors
|
||||||
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
|
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
return client.Do(req)
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
// Check for ISP blocking
|
||||||
|
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
||||||
|
}
|
||||||
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// RetryConfig holds configuration for retry logic
|
// RetryConfig holds configuration for retry logic
|
||||||
@@ -121,9 +172,11 @@ func DefaultRetryConfig() RetryConfig {
|
|||||||
|
|
||||||
// DoRequestWithRetry executes an HTTP request with retry logic and exponential backoff
|
// DoRequestWithRetry executes an HTTP request with retry logic and exponential backoff
|
||||||
// Handles 429 (Too Many Requests) responses with Retry-After header
|
// Handles 429 (Too Many Requests) responses with Retry-After header
|
||||||
|
// Also detects and logs ISP blocking
|
||||||
func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) {
|
func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
delay := config.InitialDelay
|
delay := config.InitialDelay
|
||||||
|
requestURL := req.URL.String()
|
||||||
|
|
||||||
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
|
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
|
||||||
// Clone request for retry (body needs to be re-readable)
|
// Clone request for retry (body needs to be re-readable)
|
||||||
@@ -133,7 +186,16 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
|||||||
resp, err := client.Do(reqCopy)
|
resp, err := client.Do(reqCopy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
|
|
||||||
|
// Check for ISP blocking on network errors
|
||||||
|
if CheckAndLogISPBlocking(err, requestURL, "HTTP") {
|
||||||
|
// Don't retry if ISP blocking is detected - it won't help
|
||||||
|
return nil, WrapErrorWithISPCheck(err, requestURL, "HTTP")
|
||||||
|
}
|
||||||
|
|
||||||
if attempt < config.MaxRetries {
|
if attempt < config.MaxRetries {
|
||||||
|
GoLog("[HTTP] Request failed (attempt %d/%d): %v, retrying in %v...\n",
|
||||||
|
attempt+1, config.MaxRetries+1, err, delay)
|
||||||
time.Sleep(delay)
|
time.Sleep(delay)
|
||||||
delay = calculateNextDelay(delay, config)
|
delay = calculateNextDelay(delay, config)
|
||||||
}
|
}
|
||||||
@@ -154,17 +216,43 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
|||||||
}
|
}
|
||||||
lastErr = fmt.Errorf("rate limited (429)")
|
lastErr = fmt.Errorf("rate limited (429)")
|
||||||
if attempt < config.MaxRetries {
|
if attempt < config.MaxRetries {
|
||||||
|
GoLog("[HTTP] Rate limited, waiting %v before retry...\n", delay)
|
||||||
time.Sleep(delay)
|
time.Sleep(delay)
|
||||||
delay = calculateNextDelay(delay, config)
|
delay = calculateNextDelay(delay, config)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for ISP blocking via HTTP status codes
|
||||||
|
// Some ISPs return 403 or 451 when blocking content
|
||||||
|
if resp.StatusCode == 403 || resp.StatusCode == 451 {
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, indicator := range ispBlockingIndicators {
|
||||||
|
if strings.Contains(bodyStr, indicator) {
|
||||||
|
LogError("HTTP", "ISP BLOCKING DETECTED via HTTP %d response", resp.StatusCode)
|
||||||
|
LogError("HTTP", "Domain: %s", req.URL.Host)
|
||||||
|
LogError("HTTP", "Response contains: %s", indicator)
|
||||||
|
LogError("HTTP", "Suggestion: Try using a VPN or changing your DNS to 1.1.1.1 or 8.8.8.8")
|
||||||
|
return nil, fmt.Errorf("ISP blocking detected for %s (HTTP %d) - try using VPN or change DNS", req.URL.Host, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Server errors (5xx) - retry
|
// Server errors (5xx) - retry
|
||||||
if resp.StatusCode >= 500 {
|
if resp.StatusCode >= 500 {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
|
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
|
||||||
if attempt < config.MaxRetries {
|
if attempt < config.MaxRetries {
|
||||||
|
GoLog("[HTTP] Server error %d, retrying in %v...\n", resp.StatusCode, delay)
|
||||||
time.Sleep(delay)
|
time.Sleep(delay)
|
||||||
delay = calculateNextDelay(delay, config)
|
delay = calculateNextDelay(delay, config)
|
||||||
}
|
}
|
||||||
@@ -181,10 +269,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
|||||||
// calculateNextDelay calculates the next delay with exponential backoff
|
// calculateNextDelay calculates the next delay with exponential backoff
|
||||||
func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration {
|
func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration {
|
||||||
nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor)
|
nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor)
|
||||||
if nextDelay > config.MaxDelay {
|
return min(nextDelay, config.MaxDelay)
|
||||||
nextDelay = config.MaxDelay
|
|
||||||
}
|
|
||||||
return nextDelay
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRetryAfterDuration parses Retry-After header and returns duration
|
// getRetryAfterDuration parses Retry-After header and returns duration
|
||||||
@@ -258,3 +343,172 @@ func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) st
|
|||||||
}
|
}
|
||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ISPBlockingError represents an error caused by ISP blocking
|
||||||
|
type ISPBlockingError struct {
|
||||||
|
Domain string
|
||||||
|
Reason string
|
||||||
|
OriginalErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ISPBlockingError) Error() string {
|
||||||
|
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsISPBlocking checks if an error is likely caused by ISP blocking
|
||||||
|
// Returns the ISPBlockingError if detected, nil otherwise
|
||||||
|
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract domain from URL
|
||||||
|
domain := extractDomain(requestURL)
|
||||||
|
errStr := strings.ToLower(err.Error())
|
||||||
|
|
||||||
|
// Check for DNS resolution failure (common ISP blocking method)
|
||||||
|
var dnsErr *net.DNSError
|
||||||
|
if errors.As(err, &dnsErr) {
|
||||||
|
if dnsErr.IsNotFound || dnsErr.IsTemporary {
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: "DNS resolution failed - domain may be blocked by ISP",
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for connection refused (ISP firewall blocking)
|
||||||
|
var opErr *net.OpError
|
||||||
|
if errors.As(err, &opErr) {
|
||||||
|
if opErr.Op == "dial" {
|
||||||
|
// Check for specific syscall errors
|
||||||
|
var syscallErr syscall.Errno
|
||||||
|
if errors.As(opErr.Err, &syscallErr) {
|
||||||
|
switch syscallErr {
|
||||||
|
case syscall.ECONNREFUSED:
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: "Connection refused - port may be blocked by ISP/firewall",
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
case syscall.ECONNRESET:
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: "Connection reset - ISP may be intercepting traffic",
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
case syscall.ETIMEDOUT:
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: "Connection timed out - ISP may be blocking access",
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
case syscall.ENETUNREACH:
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: "Network unreachable - ISP may be blocking route",
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
case syscall.EHOSTUNREACH:
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: "Host unreachable - ISP may be blocking destination",
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for TLS handshake failure (ISP MITM or blocking HTTPS)
|
||||||
|
var tlsErr *tls.RecordHeaderError
|
||||||
|
if errors.As(err, &tlsErr) {
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: "TLS handshake failed - ISP may be intercepting HTTPS traffic",
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check error message patterns for common ISP blocking indicators
|
||||||
|
blockingPatterns := []struct {
|
||||||
|
pattern string
|
||||||
|
reason string
|
||||||
|
}{
|
||||||
|
{"connection reset by peer", "Connection reset - ISP may be intercepting traffic"},
|
||||||
|
{"connection refused", "Connection refused - port may be blocked"},
|
||||||
|
{"no such host", "DNS lookup failed - domain may be blocked by ISP"},
|
||||||
|
{"i/o timeout", "Connection timed out - ISP may be blocking access"},
|
||||||
|
{"network is unreachable", "Network unreachable - ISP may be blocking route"},
|
||||||
|
{"tls: ", "TLS error - ISP may be intercepting HTTPS traffic"},
|
||||||
|
{"certificate", "Certificate error - ISP may be using MITM proxy"},
|
||||||
|
{"eof", "Connection closed unexpectedly - ISP may be blocking"},
|
||||||
|
{"context deadline exceeded", "Request timed out - ISP may be throttling"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bp := range blockingPatterns {
|
||||||
|
if strings.Contains(errStr, bp.pattern) {
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: bp.reason,
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckAndLogISPBlocking checks for ISP blocking and logs if detected
|
||||||
|
// Returns true if ISP blocking was detected
|
||||||
|
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
||||||
|
ispErr := IsISPBlocking(err, requestURL)
|
||||||
|
if ispErr != nil {
|
||||||
|
LogError(tag, "ISP BLOCKING DETECTED: %s", ispErr.Error())
|
||||||
|
LogError(tag, "Domain: %s", ispErr.Domain)
|
||||||
|
LogError(tag, "Reason: %s", ispErr.Reason)
|
||||||
|
LogError(tag, "Original error: %v", ispErr.OriginalErr)
|
||||||
|
LogError(tag, "Suggestion: Try using a VPN or changing your DNS to 1.1.1.1 or 8.8.8.8")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractDomain extracts the domain from a URL string
|
||||||
|
func extractDomain(rawURL string) string {
|
||||||
|
if rawURL == "" {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
// Try to extract domain manually
|
||||||
|
rawURL = strings.TrimPrefix(rawURL, "https://")
|
||||||
|
rawURL = strings.TrimPrefix(rawURL, "http://")
|
||||||
|
if idx := strings.Index(rawURL, "/"); idx > 0 {
|
||||||
|
return rawURL[:idx]
|
||||||
|
}
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.Host != "" {
|
||||||
|
return parsed.Host
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// WrapErrorWithISPCheck wraps an error with ISP blocking detection
|
||||||
|
// If ISP blocking is detected, returns a more descriptive error
|
||||||
|
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if CheckAndLogISPBlocking(err, requestURL, tag) {
|
||||||
|
domain := extractDomain(requestURL)
|
||||||
|
return fmt.Errorf("ISP blocking detected for %s - try using VPN or change DNS to 1.1.1.1/8.8.8.8: %w", domain, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogEntry represents a single log entry
|
||||||
|
type LogEntry struct {
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogBuffer stores logs in a circular buffer for retrieval by Flutter
|
||||||
|
type LogBuffer struct {
|
||||||
|
entries []LogEntry
|
||||||
|
maxSize int
|
||||||
|
mu sync.RWMutex
|
||||||
|
loggingEnabled bool // Whether logging is enabled (controlled by Flutter)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
globalLogBuffer *LogBuffer
|
||||||
|
logBufferOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetLogBuffer returns the singleton log buffer instance
|
||||||
|
func GetLogBuffer() *LogBuffer {
|
||||||
|
logBufferOnce.Do(func() {
|
||||||
|
globalLogBuffer = &LogBuffer{
|
||||||
|
entries: make([]LogEntry, 0, 1000),
|
||||||
|
maxSize: 1000,
|
||||||
|
loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalLogBuffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLoggingEnabled enables or disables logging
|
||||||
|
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
|
||||||
|
lb.mu.Lock()
|
||||||
|
defer lb.mu.Unlock()
|
||||||
|
lb.loggingEnabled = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsLoggingEnabled returns whether logging is enabled
|
||||||
|
func (lb *LogBuffer) IsLoggingEnabled() bool {
|
||||||
|
lb.mu.RLock()
|
||||||
|
defer lb.mu.RUnlock()
|
||||||
|
return lb.loggingEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add adds a log entry to the buffer
|
||||||
|
func (lb *LogBuffer) Add(level, tag, message string) {
|
||||||
|
lb.mu.Lock()
|
||||||
|
defer lb.mu.Unlock()
|
||||||
|
|
||||||
|
// Skip if logging is disabled (except for errors which are always logged)
|
||||||
|
if !lb.loggingEnabled && level != "ERROR" && level != "FATAL" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := LogEntry{
|
||||||
|
Timestamp: time.Now().Format("15:04:05.000"),
|
||||||
|
Level: level,
|
||||||
|
Tag: tag,
|
||||||
|
Message: message,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(lb.entries) >= lb.maxSize {
|
||||||
|
// Remove oldest entry
|
||||||
|
lb.entries = lb.entries[1:]
|
||||||
|
}
|
||||||
|
lb.entries = append(lb.entries, entry)
|
||||||
|
|
||||||
|
// Also print to logcat for debugging
|
||||||
|
fmt.Printf("[%s] %s\n", tag, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAll returns all log entries as JSON
|
||||||
|
func (lb *LogBuffer) GetAll() string {
|
||||||
|
lb.mu.RLock()
|
||||||
|
defer lb.mu.RUnlock()
|
||||||
|
|
||||||
|
jsonBytes, _ := json.Marshal(lb.entries)
|
||||||
|
return string(jsonBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSince returns log entries since the given index (internal use)
|
||||||
|
func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) {
|
||||||
|
lb.mu.RLock()
|
||||||
|
defer lb.mu.RUnlock()
|
||||||
|
|
||||||
|
if index < 0 {
|
||||||
|
index = 0
|
||||||
|
}
|
||||||
|
if index >= len(lb.entries) {
|
||||||
|
return []LogEntry{}, len(lb.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := lb.entries[index:]
|
||||||
|
return entries, len(lb.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear clears all log entries
|
||||||
|
func (lb *LogBuffer) Clear() {
|
||||||
|
lb.mu.Lock()
|
||||||
|
defer lb.mu.Unlock()
|
||||||
|
lb.entries = lb.entries[:0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count returns the number of log entries
|
||||||
|
func (lb *LogBuffer) Count() int {
|
||||||
|
lb.mu.RLock()
|
||||||
|
defer lb.mu.RUnlock()
|
||||||
|
return len(lb.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions for logging with different levels
|
||||||
|
func LogDebug(tag, format string, args ...interface{}) {
|
||||||
|
GetLogBuffer().Add("DEBUG", tag, fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogInfo(tag, format string, args ...interface{}) {
|
||||||
|
GetLogBuffer().Add("INFO", tag, fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogWarn(tag, format string, args ...interface{}) {
|
||||||
|
GetLogBuffer().Add("WARN", tag, fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogError(tag, format string, args ...interface{}) {
|
||||||
|
GetLogBuffer().Add("ERROR", tag, fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoLog is a drop-in replacement for fmt.Printf that also logs to buffer
|
||||||
|
// It parses the tag from the format string if it starts with [Tag]
|
||||||
|
func GoLog(format string, args ...interface{}) {
|
||||||
|
message := fmt.Sprintf(format, args...)
|
||||||
|
message = strings.TrimSuffix(message, "\n")
|
||||||
|
|
||||||
|
// Extract tag from message if present (e.g., "[Tidal] message")
|
||||||
|
tag := "Go"
|
||||||
|
level := "INFO"
|
||||||
|
|
||||||
|
if strings.HasPrefix(message, "[") {
|
||||||
|
endBracket := strings.Index(message, "]")
|
||||||
|
if endBracket > 1 {
|
||||||
|
tag = message[1:endBracket]
|
||||||
|
message = strings.TrimSpace(message[endBracket+1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine level from message content
|
||||||
|
msgLower := strings.ToLower(message)
|
||||||
|
if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") || strings.HasPrefix(message, "✗") {
|
||||||
|
level = "ERROR"
|
||||||
|
} else if strings.Contains(msgLower, "warning") || strings.Contains(msgLower, "warn") {
|
||||||
|
level = "WARN"
|
||||||
|
} else if strings.HasPrefix(message, "✓") || strings.Contains(msgLower, "success") || strings.Contains(msgLower, "match found") {
|
||||||
|
level = "INFO"
|
||||||
|
} else if strings.Contains(msgLower, "searching") || strings.Contains(msgLower, "trying") || strings.Contains(msgLower, "found") {
|
||||||
|
level = "DEBUG"
|
||||||
|
}
|
||||||
|
|
||||||
|
GetLogBuffer().Add(level, tag, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exported functions for Flutter
|
||||||
|
|
||||||
|
// GetLogs returns all logs as JSON array
|
||||||
|
func GetLogs() string {
|
||||||
|
return GetLogBuffer().GetAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLogsSince returns logs since the given index
|
||||||
|
// Returns JSON: {"logs": [...], "next_index": N}
|
||||||
|
func GetLogsSince(index int) string {
|
||||||
|
entries, nextIndex := GetLogBuffer().getSince(index)
|
||||||
|
logsJson, _ := json.Marshal(entries)
|
||||||
|
result := fmt.Sprintf(`{"logs":%s,"next_index":%d}`, string(logsJson), nextIndex)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearLogs clears all logs
|
||||||
|
func ClearLogs() {
|
||||||
|
GetLogBuffer().Clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLogCount returns the number of log entries
|
||||||
|
func GetLogCount() int {
|
||||||
|
return GetLogBuffer().Count()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLoggingEnabled enables or disables logging from Flutter
|
||||||
|
func SetLoggingEnabled(enabled bool) {
|
||||||
|
GetLogBuffer().SetLoggingEnabled(enabled)
|
||||||
|
}
|
||||||
@@ -248,15 +248,54 @@ func msToLRCTimestamp(ms int64) string {
|
|||||||
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
|
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertToLRC(lyrics *LyricsResponse) string {
|
// convertToLRC converts lyrics to LRC format string (without metadata headers)
|
||||||
|
// Use convertToLRCWithMetadata for full LRC with headers
|
||||||
|
// Kept for potential future use
|
||||||
|
// func convertToLRC(lyrics *LyricsResponse) string {
|
||||||
|
// if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||||
|
// return ""
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// var builder strings.Builder
|
||||||
|
//
|
||||||
|
// if lyrics.SyncType == "LINE_SYNCED" {
|
||||||
|
// for _, line := range lyrics.Lines {
|
||||||
|
// timestamp := msToLRCTimestamp(line.StartTimeMs)
|
||||||
|
// builder.WriteString(timestamp)
|
||||||
|
// builder.WriteString(line.Words)
|
||||||
|
// builder.WriteString("\n")
|
||||||
|
// }
|
||||||
|
// } else {
|
||||||
|
// for _, line := range lyrics.Lines {
|
||||||
|
// builder.WriteString(line.Words)
|
||||||
|
// builder.WriteString("\n")
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// return builder.String()
|
||||||
|
// }
|
||||||
|
|
||||||
|
// convertToLRCWithMetadata converts lyrics to LRC format with metadata headers
|
||||||
|
// Includes [ti:], [ar:], [by:] headers
|
||||||
|
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
|
||||||
if lyrics == nil || len(lyrics.Lines) == 0 {
|
if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
|
|
||||||
|
// Add metadata headers
|
||||||
|
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
|
||||||
|
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
|
||||||
|
builder.WriteString("[by:SpotiFLAC-Mobile]\n")
|
||||||
|
builder.WriteString("\n")
|
||||||
|
|
||||||
|
// Add lyrics lines
|
||||||
if lyrics.SyncType == "LINE_SYNCED" {
|
if lyrics.SyncType == "LINE_SYNCED" {
|
||||||
for _, line := range lyrics.Lines {
|
for _, line := range lyrics.Lines {
|
||||||
|
if line.Words == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
timestamp := msToLRCTimestamp(line.StartTimeMs)
|
timestamp := msToLRCTimestamp(line.StartTimeMs)
|
||||||
builder.WriteString(timestamp)
|
builder.WriteString(timestamp)
|
||||||
builder.WriteString(line.Words)
|
builder.WriteString(line.Words)
|
||||||
@@ -264,6 +303,9 @@ func convertToLRC(lyrics *LyricsResponse) string {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for _, line := range lyrics.Lines {
|
for _, line := range lyrics.Lines {
|
||||||
|
if line.Words == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
builder.WriteString(line.Words)
|
builder.WriteString(line.Words)
|
||||||
builder.WriteString("\n")
|
builder.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
setComment(cmt, "ALBUM", metadata.Album)
|
setComment(cmt, "ALBUM", metadata.Album)
|
||||||
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
|
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
|
||||||
setComment(cmt, "DATE", metadata.Date)
|
setComment(cmt, "DATE", metadata.Date)
|
||||||
|
|
||||||
if metadata.TrackNumber > 0 {
|
if metadata.TrackNumber > 0 {
|
||||||
if metadata.TotalTracks > 0 {
|
if metadata.TotalTracks > 0 {
|
||||||
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
|
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
|
||||||
@@ -66,15 +66,15 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
|
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.DiscNumber > 0 {
|
if metadata.DiscNumber > 0 {
|
||||||
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.ISRC != "" {
|
if metadata.ISRC != "" {
|
||||||
setComment(cmt, "ISRC", metadata.ISRC)
|
setComment(cmt, "ISRC", metadata.ISRC)
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.Description != "" {
|
if metadata.Description != "" {
|
||||||
setComment(cmt, "DESCRIPTION", metadata.Description)
|
setComment(cmt, "DESCRIPTION", metadata.Description)
|
||||||
}
|
}
|
||||||
@@ -105,7 +105,7 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
picture, err := flacpicture.NewFromImageData(
|
picture, err := flacpicture.NewFromImageData(
|
||||||
flacpicture.PictureTypeFrontCover,
|
flacpicture.PictureTypeFrontCover,
|
||||||
"Front Cover",
|
"Front Cover",
|
||||||
@@ -162,7 +162,7 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
setComment(cmt, "ALBUM", metadata.Album)
|
setComment(cmt, "ALBUM", metadata.Album)
|
||||||
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
|
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
|
||||||
setComment(cmt, "DATE", metadata.Date)
|
setComment(cmt, "DATE", metadata.Date)
|
||||||
|
|
||||||
if metadata.TrackNumber > 0 {
|
if metadata.TrackNumber > 0 {
|
||||||
if metadata.TotalTracks > 0 {
|
if metadata.TotalTracks > 0 {
|
||||||
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
|
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
|
||||||
@@ -170,15 +170,15 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
|
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.DiscNumber > 0 {
|
if metadata.DiscNumber > 0 {
|
||||||
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.ISRC != "" {
|
if metadata.ISRC != "" {
|
||||||
setComment(cmt, "ISRC", metadata.ISRC)
|
setComment(cmt, "ISRC", metadata.ISRC)
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.Description != "" {
|
if metadata.Description != "" {
|
||||||
setComment(cmt, "DESCRIPTION", metadata.Description)
|
setComment(cmt, "DESCRIPTION", metadata.Description)
|
||||||
}
|
}
|
||||||
@@ -204,7 +204,7 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
picture, err := flacpicture.NewFromImageData(
|
picture, err := flacpicture.NewFromImageData(
|
||||||
flacpicture.PictureTypeFrontCover,
|
flacpicture.PictureTypeFrontCover,
|
||||||
"Front Cover",
|
"Front Cover",
|
||||||
@@ -257,11 +257,30 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
|||||||
if trackNum != "" {
|
if trackNum != "" {
|
||||||
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
|
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
|
||||||
}
|
}
|
||||||
|
// Also try lowercase variant (some encoders use lowercase)
|
||||||
|
if metadata.TrackNumber == 0 {
|
||||||
|
trackNum = getComment(cmt, "TRACK")
|
||||||
|
if trackNum != "" {
|
||||||
|
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
discNum := getComment(cmt, "DISCNUMBER")
|
discNum := getComment(cmt, "DISCNUMBER")
|
||||||
if discNum != "" {
|
if discNum != "" {
|
||||||
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
||||||
}
|
}
|
||||||
|
// Also try DISC variant
|
||||||
|
if metadata.DiscNumber == 0 {
|
||||||
|
discNum = getComment(cmt, "DISC")
|
||||||
|
if discNum != "" {
|
||||||
|
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try DATE variants
|
||||||
|
if metadata.Date == "" {
|
||||||
|
metadata.Date = getComment(cmt, "YEAR")
|
||||||
|
}
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -291,9 +310,14 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
|
func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
|
||||||
|
keyUpper := strings.ToUpper(key) + "="
|
||||||
for _, comment := range cmt.Comments {
|
for _, comment := range cmt.Comments {
|
||||||
if len(comment) > len(key)+1 && comment[:len(key)+1] == key+"=" {
|
if len(comment) > len(key) {
|
||||||
return comment[len(key)+1:]
|
// Case-insensitive comparison for Vorbis comments
|
||||||
|
commentUpper := strings.ToUpper(comment[:len(key)+1])
|
||||||
|
if commentUpper == keyUpper {
|
||||||
|
return comment[len(key)+1:]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
@@ -356,13 +380,13 @@ func ExtractLyrics(filePath string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try LYRICS tag first
|
// Try LYRICS tag first
|
||||||
lyrics, err := cmt.Get("LYRICS")
|
lyrics, err := cmt.Get("LYRICS")
|
||||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||||
return lyrics[0], nil
|
return lyrics[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to UNSYNCEDLYRICS
|
// Fallback to UNSYNCEDLYRICS
|
||||||
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
||||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||||
@@ -376,12 +400,14 @@ func ExtractLyrics(filePath string) (string, error) {
|
|||||||
|
|
||||||
// AudioQuality represents audio quality info from a FLAC file
|
// AudioQuality represents audio quality info from a FLAC file
|
||||||
type AudioQuality struct {
|
type AudioQuality struct {
|
||||||
BitDepth int `json:"bit_depth"`
|
BitDepth int `json:"bit_depth"`
|
||||||
SampleRate int `json:"sample_rate"`
|
SampleRate int `json:"sample_rate"`
|
||||||
|
TotalSamples int64 `json:"total_samples"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
|
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
|
||||||
// FLAC StreamInfo is always the first metadata block after the 4-byte "fLaC" marker
|
// FLAC StreamInfo is always the first metadata block after the 4-byte "fLaC" marker
|
||||||
|
// For M4A files, it delegates to GetM4AQuality
|
||||||
func GetAudioQuality(filePath string) (AudioQuality, error) {
|
func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||||
file, err := os.Open(filePath)
|
file, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -389,45 +415,408 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
|||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
// Read FLAC marker (4 bytes: "fLaC")
|
// Read first 4 bytes to detect file type
|
||||||
marker := make([]byte, 4)
|
marker := make([]byte, 4)
|
||||||
if _, err := file.Read(marker); err != nil {
|
if _, err := file.Read(marker); err != nil {
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
|
||||||
}
|
}
|
||||||
if string(marker) != "fLaC" {
|
|
||||||
return AudioQuality{}, fmt.Errorf("not a FLAC file")
|
// Check if it's a FLAC file
|
||||||
|
if string(marker) == "fLaC" {
|
||||||
|
// Continue reading FLAC metadata
|
||||||
|
// Read metadata block header (4 bytes)
|
||||||
|
header := make([]byte, 4)
|
||||||
|
if _, err := file.Read(header); err != nil {
|
||||||
|
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
blockType := header[0] & 0x7F
|
||||||
|
if blockType != 0 {
|
||||||
|
return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read STREAMINFO block (34 bytes minimum)
|
||||||
|
streamInfo := make([]byte, 34)
|
||||||
|
if _, err := file.Read(streamInfo); err != nil {
|
||||||
|
return AudioQuality{}, fmt.Errorf("failed to read STREAMINFO: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse sample rate (20 bits starting at byte 10)
|
||||||
|
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
|
||||||
|
|
||||||
|
// Parse bits per sample (5 bits)
|
||||||
|
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
|
||||||
|
|
||||||
|
// Parse total samples (36 bits: 4 bits from byte 13, all of bytes 14-17)
|
||||||
|
totalSamples := int64(streamInfo[13]&0x0F)<<32 |
|
||||||
|
int64(streamInfo[14])<<24 |
|
||||||
|
int64(streamInfo[15])<<16 |
|
||||||
|
int64(streamInfo[16])<<8 |
|
||||||
|
int64(streamInfo[17])
|
||||||
|
|
||||||
|
return AudioQuality{
|
||||||
|
BitDepth: bitsPerSample,
|
||||||
|
SampleRate: sampleRate,
|
||||||
|
TotalSamples: totalSamples,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read metadata block header (4 bytes)
|
// Check if it's an M4A/MP4 file (starts with size + "ftyp")
|
||||||
// Byte 0: bit 7 = last block flag, bits 0-6 = block type (0 = STREAMINFO)
|
// First 4 bytes are size, next 4 should be "ftyp"
|
||||||
// Bytes 1-3: block length (24-bit big-endian)
|
file.Seek(0, 0) // Reset to beginning
|
||||||
header := make([]byte, 4)
|
header8 := make([]byte, 8)
|
||||||
if _, err := file.Read(header); err != nil {
|
if _, err := file.Read(header8); err != nil {
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
blockType := header[0] & 0x7F
|
if string(header8[4:8]) == "ftyp" {
|
||||||
if blockType != 0 {
|
// It's an M4A/MP4 file, use M4A quality reader
|
||||||
return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO")
|
file.Close() // Close before calling GetM4AQuality which opens the file again
|
||||||
|
return GetM4AQuality(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read STREAMINFO block (34 bytes minimum)
|
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
|
||||||
// Bytes 10-13 contain sample rate (20 bits), channels (3 bits), bits per sample (5 bits)
|
}
|
||||||
streamInfo := make([]byte, 34)
|
|
||||||
if _, err := file.Read(streamInfo); err != nil {
|
// ========================================
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read STREAMINFO: %w", err)
|
// M4A (MP4/AAC) Metadata Embedding
|
||||||
}
|
// ========================================
|
||||||
|
|
||||||
// Parse sample rate (20 bits starting at byte 10)
|
// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
|
||||||
// Bytes 10-12: [SSSS SSSS] [SSSS SSSS] [SSSS CCCC] where S=sample rate, C=channels
|
// This is a simplified implementation that writes metadata to the file
|
||||||
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
|
func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
|
||||||
|
// Read the entire file
|
||||||
// Parse bits per sample (5 bits)
|
data, err := os.ReadFile(filePath)
|
||||||
// Byte 12 bits 0-3 and byte 13 bit 7: [.... BBBB] [B...] where B=bits per sample - 1
|
if err != nil {
|
||||||
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
|
return fmt.Errorf("failed to read M4A file: %w", err)
|
||||||
|
}
|
||||||
return AudioQuality{
|
|
||||||
BitDepth: bitsPerSample,
|
// Find moov atom position
|
||||||
SampleRate: sampleRate,
|
moovPos := findAtom(data, "moov", 0)
|
||||||
}, nil
|
if moovPos < 0 {
|
||||||
|
return fmt.Errorf("moov atom not found in M4A file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find udta atom inside moov, or create one
|
||||||
|
moovSize := int(uint32(data[moovPos])<<24 | uint32(data[moovPos+1])<<16 | uint32(data[moovPos+2])<<8 | uint32(data[moovPos+3]))
|
||||||
|
udtaPos := findAtom(data, "udta", moovPos+8)
|
||||||
|
|
||||||
|
// Build new metadata atoms
|
||||||
|
metaAtom := buildMetaAtom(metadata, coverData)
|
||||||
|
|
||||||
|
var newData []byte
|
||||||
|
if udtaPos >= 0 && udtaPos < moovPos+moovSize {
|
||||||
|
// udta exists, find meta inside it or replace
|
||||||
|
udtaSize := int(uint32(data[udtaPos])<<24 | uint32(data[udtaPos+1])<<16 | uint32(data[udtaPos+2])<<8 | uint32(data[udtaPos+3]))
|
||||||
|
metaPos := findAtom(data, "meta", udtaPos+8)
|
||||||
|
|
||||||
|
if metaPos >= 0 && metaPos < udtaPos+udtaSize {
|
||||||
|
// Replace existing meta atom
|
||||||
|
metaSize := int(uint32(data[metaPos])<<24 | uint32(data[metaPos+1])<<16 | uint32(data[metaPos+2])<<8 | uint32(data[metaPos+3]))
|
||||||
|
newData = append(newData, data[:metaPos]...)
|
||||||
|
newData = append(newData, metaAtom...)
|
||||||
|
newData = append(newData, data[metaPos+metaSize:]...)
|
||||||
|
} else {
|
||||||
|
// Add meta atom to udta
|
||||||
|
newUdtaContent := append(data[udtaPos+8:udtaPos+udtaSize], metaAtom...)
|
||||||
|
newUdtaSize := 8 + len(newUdtaContent)
|
||||||
|
newUdta := make([]byte, 4)
|
||||||
|
newUdta[0] = byte(newUdtaSize >> 24)
|
||||||
|
newUdta[1] = byte(newUdtaSize >> 16)
|
||||||
|
newUdta[2] = byte(newUdtaSize >> 8)
|
||||||
|
newUdta[3] = byte(newUdtaSize)
|
||||||
|
newUdta = append(newUdta, []byte("udta")...)
|
||||||
|
newUdta = append(newUdta, newUdtaContent...)
|
||||||
|
|
||||||
|
newData = append(newData, data[:udtaPos]...)
|
||||||
|
newData = append(newData, newUdta...)
|
||||||
|
newData = append(newData, data[udtaPos+udtaSize:]...)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new udta with meta
|
||||||
|
udtaContent := metaAtom
|
||||||
|
udtaSize := 8 + len(udtaContent)
|
||||||
|
newUdta := make([]byte, 4)
|
||||||
|
newUdta[0] = byte(udtaSize >> 24)
|
||||||
|
newUdta[1] = byte(udtaSize >> 16)
|
||||||
|
newUdta[2] = byte(udtaSize >> 8)
|
||||||
|
newUdta[3] = byte(udtaSize)
|
||||||
|
newUdta = append(newUdta, []byte("udta")...)
|
||||||
|
newUdta = append(newUdta, udtaContent...)
|
||||||
|
|
||||||
|
// Insert udta at end of moov
|
||||||
|
insertPos := moovPos + moovSize
|
||||||
|
newData = append(newData, data[:insertPos]...)
|
||||||
|
newData = append(newData, newUdta...)
|
||||||
|
newData = append(newData, data[insertPos:]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update moov size
|
||||||
|
newMoovSize := moovSize + len(newData) - len(data)
|
||||||
|
newData[moovPos] = byte(newMoovSize >> 24)
|
||||||
|
newData[moovPos+1] = byte(newMoovSize >> 16)
|
||||||
|
newData[moovPos+2] = byte(newMoovSize >> 8)
|
||||||
|
newData[moovPos+3] = byte(newMoovSize)
|
||||||
|
|
||||||
|
// Write back to file
|
||||||
|
if err := os.WriteFile(filePath, newData, 0644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write M4A file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[M4A] Metadata embedded successfully\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findAtom finds an atom by name starting from offset
|
||||||
|
func findAtom(data []byte, name string, offset int) int {
|
||||||
|
for i := offset; i < len(data)-8; {
|
||||||
|
size := int(uint32(data[i])<<24 | uint32(data[i+1])<<16 | uint32(data[i+2])<<8 | uint32(data[i+3]))
|
||||||
|
if size < 8 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
atomName := string(data[i+4 : i+8])
|
||||||
|
if atomName == name {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
i += size
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMetaAtom builds a complete meta atom with ilst containing metadata
|
||||||
|
func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
||||||
|
// Build ilst content
|
||||||
|
var ilst []byte
|
||||||
|
|
||||||
|
// ©nam - Title
|
||||||
|
if metadata.Title != "" {
|
||||||
|
ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ©ART - Artist
|
||||||
|
if metadata.Artist != "" {
|
||||||
|
ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ©alb - Album
|
||||||
|
if metadata.Album != "" {
|
||||||
|
ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// aART - Album Artist
|
||||||
|
if metadata.AlbumArtist != "" {
|
||||||
|
ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ©day - Year/Date
|
||||||
|
if metadata.Date != "" {
|
||||||
|
ilst = append(ilst, buildTextAtom("©day", metadata.Date)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// trkn - Track Number
|
||||||
|
if metadata.TrackNumber > 0 {
|
||||||
|
ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// disk - Disc Number
|
||||||
|
if metadata.DiscNumber > 0 {
|
||||||
|
ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ©lyr - Lyrics
|
||||||
|
if metadata.Lyrics != "" {
|
||||||
|
ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// covr - Cover Art
|
||||||
|
if len(coverData) > 0 {
|
||||||
|
ilst = append(ilst, buildCoverAtom(coverData)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build ilst atom
|
||||||
|
ilstSize := 8 + len(ilst)
|
||||||
|
ilstAtom := make([]byte, 4)
|
||||||
|
ilstAtom[0] = byte(ilstSize >> 24)
|
||||||
|
ilstAtom[1] = byte(ilstSize >> 16)
|
||||||
|
ilstAtom[2] = byte(ilstSize >> 8)
|
||||||
|
ilstAtom[3] = byte(ilstSize)
|
||||||
|
ilstAtom = append(ilstAtom, []byte("ilst")...)
|
||||||
|
ilstAtom = append(ilstAtom, ilst...)
|
||||||
|
|
||||||
|
// Build hdlr atom (required for meta)
|
||||||
|
hdlr := []byte{
|
||||||
|
0, 0, 0, 33, // size = 33
|
||||||
|
'h', 'd', 'l', 'r',
|
||||||
|
0, 0, 0, 0, // version + flags
|
||||||
|
0, 0, 0, 0, // predefined
|
||||||
|
'm', 'd', 'i', 'r', // handler type
|
||||||
|
'a', 'p', 'p', 'l', // manufacturer
|
||||||
|
0, 0, 0, 0, // component flags
|
||||||
|
0, 0, 0, 0, // component flags mask
|
||||||
|
0, // null terminator
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build meta atom
|
||||||
|
metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr
|
||||||
|
metaContent = append(metaContent, ilstAtom...)
|
||||||
|
|
||||||
|
metaSize := 8 + len(metaContent)
|
||||||
|
metaAtom := make([]byte, 4)
|
||||||
|
metaAtom[0] = byte(metaSize >> 24)
|
||||||
|
metaAtom[1] = byte(metaSize >> 16)
|
||||||
|
metaAtom[2] = byte(metaSize >> 8)
|
||||||
|
metaAtom[3] = byte(metaSize)
|
||||||
|
metaAtom = append(metaAtom, []byte("meta")...)
|
||||||
|
metaAtom = append(metaAtom, metaContent...)
|
||||||
|
|
||||||
|
return metaAtom
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTextAtom builds a text metadata atom (©nam, ©ART, etc.)
|
||||||
|
func buildTextAtom(name, value string) []byte {
|
||||||
|
valueBytes := []byte(value)
|
||||||
|
|
||||||
|
// data atom
|
||||||
|
dataSize := 16 + len(valueBytes)
|
||||||
|
dataAtom := make([]byte, 4)
|
||||||
|
dataAtom[0] = byte(dataSize >> 24)
|
||||||
|
dataAtom[1] = byte(dataSize >> 16)
|
||||||
|
dataAtom[2] = byte(dataSize >> 8)
|
||||||
|
dataAtom[3] = byte(dataSize)
|
||||||
|
dataAtom = append(dataAtom, []byte("data")...)
|
||||||
|
dataAtom = append(dataAtom, 0, 0, 0, 1) // type = UTF-8
|
||||||
|
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
||||||
|
dataAtom = append(dataAtom, valueBytes...)
|
||||||
|
|
||||||
|
// container atom
|
||||||
|
atomSize := 8 + len(dataAtom)
|
||||||
|
atom := make([]byte, 4)
|
||||||
|
atom[0] = byte(atomSize >> 24)
|
||||||
|
atom[1] = byte(atomSize >> 16)
|
||||||
|
atom[2] = byte(atomSize >> 8)
|
||||||
|
atom[3] = byte(atomSize)
|
||||||
|
atom = append(atom, []byte(name)...)
|
||||||
|
atom = append(atom, dataAtom...)
|
||||||
|
|
||||||
|
return atom
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTrackNumberAtom builds trkn atom
|
||||||
|
func buildTrackNumberAtom(track, total int) []byte {
|
||||||
|
// data atom with track number
|
||||||
|
dataAtom := []byte{
|
||||||
|
0, 0, 0, 24, // size
|
||||||
|
'd', 'a', 't', 'a',
|
||||||
|
0, 0, 0, 0, // type = implicit
|
||||||
|
0, 0, 0, 0, // locale
|
||||||
|
0, 0, // padding
|
||||||
|
byte(track >> 8), byte(track), // track number
|
||||||
|
byte(total >> 8), byte(total), // total tracks
|
||||||
|
0, 0, // padding
|
||||||
|
}
|
||||||
|
|
||||||
|
// trkn atom
|
||||||
|
atomSize := 8 + len(dataAtom)
|
||||||
|
atom := make([]byte, 4)
|
||||||
|
atom[0] = byte(atomSize >> 24)
|
||||||
|
atom[1] = byte(atomSize >> 16)
|
||||||
|
atom[2] = byte(atomSize >> 8)
|
||||||
|
atom[3] = byte(atomSize)
|
||||||
|
atom = append(atom, []byte("trkn")...)
|
||||||
|
atom = append(atom, dataAtom...)
|
||||||
|
|
||||||
|
return atom
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDiscNumberAtom builds disk atom
|
||||||
|
func buildDiscNumberAtom(disc, total int) []byte {
|
||||||
|
// data atom with disc number
|
||||||
|
dataAtom := []byte{
|
||||||
|
0, 0, 0, 22, // size
|
||||||
|
'd', 'a', 't', 'a',
|
||||||
|
0, 0, 0, 0, // type = implicit
|
||||||
|
0, 0, 0, 0, // locale
|
||||||
|
0, 0, // padding
|
||||||
|
byte(disc >> 8), byte(disc), // disc number
|
||||||
|
byte(total >> 8), byte(total), // total discs
|
||||||
|
}
|
||||||
|
|
||||||
|
// disk atom
|
||||||
|
atomSize := 8 + len(dataAtom)
|
||||||
|
atom := make([]byte, 4)
|
||||||
|
atom[0] = byte(atomSize >> 24)
|
||||||
|
atom[1] = byte(atomSize >> 16)
|
||||||
|
atom[2] = byte(atomSize >> 8)
|
||||||
|
atom[3] = byte(atomSize)
|
||||||
|
atom = append(atom, []byte("disk")...)
|
||||||
|
atom = append(atom, dataAtom...)
|
||||||
|
|
||||||
|
return atom
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCoverAtom builds covr atom with image data
|
||||||
|
func buildCoverAtom(coverData []byte) []byte {
|
||||||
|
// Detect image type (JPEG = 13, PNG = 14)
|
||||||
|
imageType := byte(13) // default JPEG
|
||||||
|
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
|
||||||
|
imageType = 14 // PNG
|
||||||
|
}
|
||||||
|
|
||||||
|
// data atom
|
||||||
|
dataSize := 16 + len(coverData)
|
||||||
|
dataAtom := make([]byte, 4)
|
||||||
|
dataAtom[0] = byte(dataSize >> 24)
|
||||||
|
dataAtom[1] = byte(dataSize >> 16)
|
||||||
|
dataAtom[2] = byte(dataSize >> 8)
|
||||||
|
dataAtom[3] = byte(dataSize)
|
||||||
|
dataAtom = append(dataAtom, []byte("data")...)
|
||||||
|
dataAtom = append(dataAtom, 0, 0, 0, imageType) // type = JPEG or PNG
|
||||||
|
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
||||||
|
dataAtom = append(dataAtom, coverData...)
|
||||||
|
|
||||||
|
// covr atom
|
||||||
|
atomSize := 8 + len(dataAtom)
|
||||||
|
atom := make([]byte, 4)
|
||||||
|
atom[0] = byte(atomSize >> 24)
|
||||||
|
atom[1] = byte(atomSize >> 16)
|
||||||
|
atom[2] = byte(atomSize >> 8)
|
||||||
|
atom[3] = byte(atomSize)
|
||||||
|
atom = append(atom, []byte("covr")...)
|
||||||
|
atom = append(atom, dataAtom...)
|
||||||
|
|
||||||
|
return atom
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetM4AQuality reads audio quality from M4A file
|
||||||
|
func GetM4AQuality(filePath string) (AudioQuality, error) {
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return AudioQuality{}, fmt.Errorf("failed to read M4A file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find moov -> trak -> mdia -> minf -> stbl -> stsd
|
||||||
|
moovPos := findAtom(data, "moov", 0)
|
||||||
|
if moovPos < 0 {
|
||||||
|
return AudioQuality{}, fmt.Errorf("moov atom not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for mp4a or alac atom which contains audio info
|
||||||
|
// This is a simplified search - real implementation would traverse the atom tree
|
||||||
|
for i := moovPos; i < len(data)-20; i++ {
|
||||||
|
if string(data[i:i+4]) == "mp4a" || string(data[i:i+4]) == "alac" {
|
||||||
|
// Sample rate is at offset 22-23 from atom start (16-bit big-endian)
|
||||||
|
if i+24 < len(data) {
|
||||||
|
sampleRate := int(data[i+22])<<8 | int(data[i+23])
|
||||||
|
// For AAC, bit depth is typically 16
|
||||||
|
bitDepth := 16
|
||||||
|
if string(data[i:i+4]) == "alac" {
|
||||||
|
// ALAC can have higher bit depth, check esds or alac specific data
|
||||||
|
bitDepth = 24 // Assume 24-bit for ALAC
|
||||||
|
}
|
||||||
|
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return AudioQuality{}, fmt.Errorf("audio info not found in M4A file")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ISRC to Track ID Cache
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// TrackIDCacheEntry holds cached track ID with metadata
|
||||||
|
type TrackIDCacheEntry struct {
|
||||||
|
TidalTrackID int64
|
||||||
|
QobuzTrackID int64
|
||||||
|
AmazonTrackID string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrackIDCache caches ISRC to track ID mappings
|
||||||
|
type TrackIDCache struct {
|
||||||
|
cache map[string]*TrackIDCacheEntry
|
||||||
|
mu sync.RWMutex
|
||||||
|
ttl time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
globalTrackIDCache *TrackIDCache
|
||||||
|
trackIDCacheOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetTrackIDCache returns the global track ID cache
|
||||||
|
func GetTrackIDCache() *TrackIDCache {
|
||||||
|
trackIDCacheOnce.Do(func() {
|
||||||
|
globalTrackIDCache = &TrackIDCache{
|
||||||
|
cache: make(map[string]*TrackIDCacheEntry),
|
||||||
|
ttl: 30 * time.Minute, // Cache for 30 minutes
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalTrackIDCache
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a cached entry by ISRC
|
||||||
|
func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
entry, exists := c.cache[isrc]
|
||||||
|
if !exists || time.Now().After(entry.ExpiresAt) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTidal caches Tidal track ID for an ISRC
|
||||||
|
func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
entry, exists := c.cache[isrc]
|
||||||
|
if !exists {
|
||||||
|
entry = &TrackIDCacheEntry{}
|
||||||
|
c.cache[isrc] = entry
|
||||||
|
}
|
||||||
|
entry.TidalTrackID = trackID
|
||||||
|
entry.ExpiresAt = time.Now().Add(c.ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetQobuz caches Qobuz track ID for an ISRC
|
||||||
|
func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
entry, exists := c.cache[isrc]
|
||||||
|
if !exists {
|
||||||
|
entry = &TrackIDCacheEntry{}
|
||||||
|
c.cache[isrc] = entry
|
||||||
|
}
|
||||||
|
entry.QobuzTrackID = trackID
|
||||||
|
entry.ExpiresAt = time.Now().Add(c.ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAmazon caches Amazon track ID for an ISRC
|
||||||
|
func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
entry, exists := c.cache[isrc]
|
||||||
|
if !exists {
|
||||||
|
entry = &TrackIDCacheEntry{}
|
||||||
|
c.cache[isrc] = entry
|
||||||
|
}
|
||||||
|
entry.AmazonTrackID = trackID
|
||||||
|
entry.ExpiresAt = time.Now().Add(c.ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear removes all cached entries
|
||||||
|
func (c *TrackIDCache) Clear() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.cache = make(map[string]*TrackIDCacheEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size returns the number of cached entries
|
||||||
|
func (c *TrackIDCache) Size() int {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return len(c.cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Parallel Download Helper
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// ParallelDownloadResult holds results from parallel operations
|
||||||
|
type ParallelDownloadResult struct {
|
||||||
|
CoverData []byte
|
||||||
|
LyricsData *LyricsResponse
|
||||||
|
LyricsLRC string
|
||||||
|
CoverErr error
|
||||||
|
LyricsErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchCoverAndLyricsParallel downloads cover and fetches lyrics in parallel
|
||||||
|
// This runs while the main audio download is happening
|
||||||
|
func FetchCoverAndLyricsParallel(
|
||||||
|
coverURL string,
|
||||||
|
maxQualityCover bool,
|
||||||
|
spotifyID string,
|
||||||
|
trackName string,
|
||||||
|
artistName string,
|
||||||
|
embedLyrics bool,
|
||||||
|
) *ParallelDownloadResult {
|
||||||
|
result := &ParallelDownloadResult{}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
// Download cover in parallel
|
||||||
|
if coverURL != "" {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
fmt.Println("[Parallel] Starting cover download...")
|
||||||
|
data, err := downloadCoverToMemory(coverURL, maxQualityCover)
|
||||||
|
if err != nil {
|
||||||
|
result.CoverErr = err
|
||||||
|
fmt.Printf("[Parallel] Cover download failed: %v\n", err)
|
||||||
|
} else {
|
||||||
|
result.CoverData = data
|
||||||
|
fmt.Printf("[Parallel] Cover downloaded: %d bytes\n", len(data))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch lyrics in parallel
|
||||||
|
if embedLyrics {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
fmt.Println("[Parallel] Starting lyrics fetch...")
|
||||||
|
client := NewLyricsClient()
|
||||||
|
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
||||||
|
if err != nil {
|
||||||
|
result.LyricsErr = err
|
||||||
|
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
|
||||||
|
} else if lyrics != nil && len(lyrics.Lines) > 0 {
|
||||||
|
result.LyricsData = lyrics
|
||||||
|
// Use LRC with metadata headers (like PC version)
|
||||||
|
result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName)
|
||||||
|
fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines))
|
||||||
|
} else {
|
||||||
|
result.LyricsErr = fmt.Errorf("no lyrics found")
|
||||||
|
fmt.Println("[Parallel] No lyrics found")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Pre-warm Cache for Album/Playlist
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// PreWarmCacheRequest represents a track to pre-warm cache for
|
||||||
|
type PreWarmCacheRequest struct {
|
||||||
|
ISRC string
|
||||||
|
TrackName string
|
||||||
|
ArtistName string
|
||||||
|
SpotifyID string // Needed for Amazon (SongLink lookup)
|
||||||
|
Service string // "tidal", "qobuz", "amazon"
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreWarmTrackCache pre-fetches track IDs for multiple tracks (for album/playlist)
|
||||||
|
// This runs in background while user is viewing the track list
|
||||||
|
func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
||||||
|
if len(requests) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[Cache] Pre-warming cache for %d tracks...\n", len(requests))
|
||||||
|
cache := GetTrackIDCache()
|
||||||
|
|
||||||
|
// Limit concurrent pre-warm requests
|
||||||
|
semaphore := make(chan struct{}, 3) // Max 3 concurrent
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for _, req := range requests {
|
||||||
|
// Skip if already cached
|
||||||
|
if cached := cache.Get(req.ISRC); cached != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func(r PreWarmCacheRequest) {
|
||||||
|
defer wg.Done()
|
||||||
|
semaphore <- struct{}{} // Acquire
|
||||||
|
defer func() { <-semaphore }() // Release
|
||||||
|
|
||||||
|
switch r.Service {
|
||||||
|
case "tidal":
|
||||||
|
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
|
||||||
|
case "qobuz":
|
||||||
|
preWarmQobuzCache(r.ISRC)
|
||||||
|
case "amazon":
|
||||||
|
preWarmAmazonCache(r.ISRC, r.SpotifyID)
|
||||||
|
}
|
||||||
|
}(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
fmt.Printf("[Cache] Pre-warm complete. Cache size: %d\n", cache.Size())
|
||||||
|
}
|
||||||
|
|
||||||
|
func preWarmTidalCache(isrc, _, _ string) {
|
||||||
|
downloader := NewTidalDownloader()
|
||||||
|
track, err := downloader.SearchTrackByISRC(isrc)
|
||||||
|
if err == nil && track != nil {
|
||||||
|
GetTrackIDCache().SetTidal(isrc, track.ID)
|
||||||
|
fmt.Printf("[Cache] Cached Tidal ID for ISRC %s: %d\n", isrc, track.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func preWarmQobuzCache(isrc string) {
|
||||||
|
downloader := NewQobuzDownloader()
|
||||||
|
track, err := downloader.SearchTrackByISRC(isrc)
|
||||||
|
if err == nil && track != nil {
|
||||||
|
GetTrackIDCache().SetQobuz(isrc, track.ID)
|
||||||
|
fmt.Printf("[Cache] Cached Qobuz ID for ISRC %s: %d\n", isrc, track.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func preWarmAmazonCache(isrc, spotifyID string) {
|
||||||
|
// Amazon uses SongLink to get URL, so we pre-warm by checking availability
|
||||||
|
client := NewSongLinkClient()
|
||||||
|
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||||
|
if err == nil && availability != nil && availability.Amazon {
|
||||||
|
// Store Amazon URL in cache (using ISRC as key)
|
||||||
|
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
|
||||||
|
fmt.Printf("[Cache] Cached Amazon URL for ISRC %s\n", isrc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Exported Functions for Flutter
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// PreWarmCache is called from Flutter to pre-warm cache for album/playlist tracks
|
||||||
|
// tracksJSON is a JSON array of {isrc, track_name, artist_name, service}
|
||||||
|
func PreWarmCache(tracksJSON string) error {
|
||||||
|
var requests []PreWarmCacheRequest
|
||||||
|
// Parse JSON (simplified - in production use proper JSON parsing)
|
||||||
|
// For now, this is called from exports.go with proper parsing
|
||||||
|
|
||||||
|
go PreWarmTrackCache(requests) // Run in background
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearTrackCache clears the track ID cache
|
||||||
|
func ClearTrackCache() {
|
||||||
|
GetTrackIDCache().Clear()
|
||||||
|
fmt.Println("[Cache] Track ID cache cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCacheSize returns the current cache size
|
||||||
|
func GetCacheSize() int {
|
||||||
|
return GetTrackIDCache().Size()
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DownloadProgress represents current download progress
|
// DownloadProgress represents current download progress
|
||||||
@@ -22,7 +23,8 @@ type ItemProgress struct {
|
|||||||
ItemID string `json:"item_id"`
|
ItemID string `json:"item_id"`
|
||||||
BytesTotal int64 `json:"bytes_total"`
|
BytesTotal int64 `json:"bytes_total"`
|
||||||
BytesReceived int64 `json:"bytes_received"`
|
BytesReceived int64 `json:"bytes_received"`
|
||||||
Progress float64 `json:"progress"` // 0.0 to 1.0
|
Progress float64 `json:"progress"` // 0.0 to 1.0
|
||||||
|
SpeedMBps float64 `json:"speed_mbps"` // Download speed in MB/s
|
||||||
IsDownloading bool `json:"is_downloading"`
|
IsDownloading bool `json:"is_downloading"`
|
||||||
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
||||||
}
|
}
|
||||||
@@ -124,6 +126,20 @@ func SetItemBytesReceived(itemID string, received int64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetItemBytesReceivedWithSpeed sets bytes received and speed for an item
|
||||||
|
func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps float64) {
|
||||||
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
|
item.BytesReceived = received
|
||||||
|
item.SpeedMBps = speedMBps
|
||||||
|
if item.BytesTotal > 0 {
|
||||||
|
item.Progress = float64(received) / float64(item.BytesTotal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CompleteItemProgress marks an item as complete
|
// CompleteItemProgress marks an item as complete
|
||||||
func CompleteItemProgress(itemID string) {
|
func CompleteItemProgress(itemID string) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
@@ -188,35 +204,64 @@ func setDownloadDir(path string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getDownloadDir returns the default download directory
|
// getDownloadDir returns the default download directory
|
||||||
func getDownloadDir() string {
|
// Kept for potential future use
|
||||||
downloadDirMu.RLock()
|
// func getDownloadDir() string {
|
||||||
defer downloadDirMu.RUnlock()
|
// downloadDirMu.RLock()
|
||||||
return downloadDir
|
// defer downloadDirMu.RUnlock()
|
||||||
}
|
// return downloadDir
|
||||||
|
// }
|
||||||
|
|
||||||
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
||||||
type ItemProgressWriter struct {
|
type ItemProgressWriter struct {
|
||||||
writer interface{ Write([]byte) (int, error) }
|
writer interface{ Write([]byte) (int, error) }
|
||||||
itemID string
|
itemID string
|
||||||
current int64
|
current int64
|
||||||
|
lastReported int64 // Track last reported bytes for threshold-based updates
|
||||||
|
startTime time.Time // Track start time for speed calculation
|
||||||
|
lastTime time.Time // Track last update time for speed calculation
|
||||||
|
lastBytes int64 // Track bytes at last speed calculation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const progressUpdateThreshold = 64 * 1024 // Update progress every 64KB
|
||||||
|
|
||||||
// NewItemProgressWriter creates a new progress writer for a specific item
|
// NewItemProgressWriter creates a new progress writer for a specific item
|
||||||
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
||||||
|
now := time.Now()
|
||||||
return &ItemProgressWriter{
|
return &ItemProgressWriter{
|
||||||
writer: w,
|
writer: w,
|
||||||
itemID: itemID,
|
itemID: itemID,
|
||||||
current: 0,
|
current: 0,
|
||||||
|
lastReported: 0,
|
||||||
|
startTime: now,
|
||||||
|
lastTime: now,
|
||||||
|
lastBytes: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write implements io.Writer
|
// Write implements io.Writer with threshold-based progress updates and speed tracking
|
||||||
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
||||||
n, err := pw.writer.Write(p)
|
n, err := pw.writer.Write(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
pw.current += int64(n)
|
pw.current += int64(n)
|
||||||
SetItemBytesReceived(pw.itemID, pw.current)
|
|
||||||
|
// Update progress when we've received at least 64KB since last update
|
||||||
|
// Also update on first write to show download has started
|
||||||
|
if pw.lastReported == 0 || pw.current-pw.lastReported >= progressUpdateThreshold {
|
||||||
|
// Calculate speed (MB/s) based on bytes received since last update
|
||||||
|
now := time.Now()
|
||||||
|
elapsed := now.Sub(pw.lastTime).Seconds()
|
||||||
|
var speedMBps float64
|
||||||
|
if elapsed > 0 {
|
||||||
|
bytesInInterval := pw.current - pw.lastBytes
|
||||||
|
speedMBps = float64(bytesInInterval) / (1024 * 1024) / elapsed
|
||||||
|
}
|
||||||
|
|
||||||
|
SetItemBytesReceivedWithSpeed(pw.itemID, pw.current, speedMBps)
|
||||||
|
pw.lastReported = pw.current
|
||||||
|
pw.lastTime = now
|
||||||
|
pw.lastBytes = pw.current
|
||||||
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hiragana to Romaji mapping
|
||||||
|
var hiraganaToRomaji = map[rune]string{
|
||||||
|
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
|
||||||
|
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
|
||||||
|
'さ': "sa", 'し': "shi", 'す': "su", 'せ': "se", 'そ': "so",
|
||||||
|
'た': "ta", 'ち': "chi", 'つ': "tsu", 'て': "te", 'と': "to",
|
||||||
|
'な': "na", 'に': "ni", 'ぬ': "nu", 'ね': "ne", 'の': "no",
|
||||||
|
'は': "ha", 'ひ': "hi", 'ふ': "fu", 'へ': "he", 'ほ': "ho",
|
||||||
|
'ま': "ma", 'み': "mi", 'む': "mu", 'め': "me", 'も': "mo",
|
||||||
|
'や': "ya", 'ゆ': "yu", 'よ': "yo",
|
||||||
|
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
|
||||||
|
'わ': "wa", 'を': "wo", 'ん': "n",
|
||||||
|
// Dakuten (voiced)
|
||||||
|
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
|
||||||
|
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
|
||||||
|
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
|
||||||
|
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
|
||||||
|
// Handakuten (semi-voiced)
|
||||||
|
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
|
||||||
|
// Small characters
|
||||||
|
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
|
||||||
|
'っ': "", // Double consonant marker
|
||||||
|
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Katakana to Romaji mapping
|
||||||
|
var katakanaToRomaji = map[rune]string{
|
||||||
|
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
|
||||||
|
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
|
||||||
|
'サ': "sa", 'シ': "shi", 'ス': "su", 'セ': "se", 'ソ': "so",
|
||||||
|
'タ': "ta", 'チ': "chi", 'ツ': "tsu", 'テ': "te", 'ト': "to",
|
||||||
|
'ナ': "na", 'ニ': "ni", 'ヌ': "nu", 'ネ': "ne", 'ノ': "no",
|
||||||
|
'ハ': "ha", 'ヒ': "hi", 'フ': "fu", 'ヘ': "he", 'ホ': "ho",
|
||||||
|
'マ': "ma", 'ミ': "mi", 'ム': "mu", 'メ': "me", 'モ': "mo",
|
||||||
|
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
|
||||||
|
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
|
||||||
|
'ワ': "wa", 'ヲ': "wo", 'ン': "n",
|
||||||
|
// Dakuten (voiced)
|
||||||
|
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
|
||||||
|
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
|
||||||
|
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
|
||||||
|
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
|
||||||
|
// Handakuten (semi-voiced)
|
||||||
|
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
|
||||||
|
// Small characters
|
||||||
|
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
|
||||||
|
'ッ': "", // Double consonant marker
|
||||||
|
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
|
||||||
|
// Extended katakana
|
||||||
|
'ー': "", // Long vowel mark
|
||||||
|
'ヴ': "vu",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combination mappings for きゃ, しゃ, etc.
|
||||||
|
var combinationHiragana = map[string]string{
|
||||||
|
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
|
||||||
|
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
|
||||||
|
"ちゃ": "cha", "ちゅ": "chu", "ちょ": "cho",
|
||||||
|
"にゃ": "nya", "にゅ": "nyu", "にょ": "nyo",
|
||||||
|
"ひゃ": "hya", "ひゅ": "hyu", "ひょ": "hyo",
|
||||||
|
"みゃ": "mya", "みゅ": "myu", "みょ": "myo",
|
||||||
|
"りゃ": "rya", "りゅ": "ryu", "りょ": "ryo",
|
||||||
|
"ぎゃ": "gya", "ぎゅ": "gyu", "ぎょ": "gyo",
|
||||||
|
"じゃ": "ja", "じゅ": "ju", "じょ": "jo",
|
||||||
|
"びゃ": "bya", "びゅ": "byu", "びょ": "byo",
|
||||||
|
"ぴゃ": "pya", "ぴゅ": "pyu", "ぴょ": "pyo",
|
||||||
|
}
|
||||||
|
|
||||||
|
var combinationKatakana = map[string]string{
|
||||||
|
"キャ": "kya", "キュ": "kyu", "キョ": "kyo",
|
||||||
|
"シャ": "sha", "シュ": "shu", "ショ": "sho",
|
||||||
|
"チャ": "cha", "チュ": "chu", "チョ": "cho",
|
||||||
|
"ニャ": "nya", "ニュ": "nyu", "ニョ": "nyo",
|
||||||
|
"ヒャ": "hya", "ヒュ": "hyu", "ヒョ": "hyo",
|
||||||
|
"ミャ": "mya", "ミュ": "myu", "ミョ": "myo",
|
||||||
|
"リャ": "rya", "リュ": "ryu", "リョ": "ryo",
|
||||||
|
"ギャ": "gya", "ギュ": "gyu", "ギョ": "gyo",
|
||||||
|
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
|
||||||
|
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
|
||||||
|
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
|
||||||
|
// Extended combinations
|
||||||
|
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
|
||||||
|
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
||||||
|
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContainsJapanese checks if a string contains Japanese characters
|
||||||
|
func ContainsJapanese(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if isHiragana(r) || isKatakana(r) || isKanji(r) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHiragana(r rune) bool {
|
||||||
|
return r >= 0x3040 && r <= 0x309F
|
||||||
|
}
|
||||||
|
|
||||||
|
func isKatakana(r rune) bool {
|
||||||
|
return r >= 0x30A0 && r <= 0x30FF
|
||||||
|
}
|
||||||
|
|
||||||
|
func isKanji(r rune) bool {
|
||||||
|
return (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs
|
||||||
|
(r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A
|
||||||
|
}
|
||||||
|
|
||||||
|
// JapaneseToRomaji converts Japanese text (hiragana/katakana) to romaji
|
||||||
|
// Note: Kanji cannot be converted without a dictionary, so they are kept as-is
|
||||||
|
func JapaneseToRomaji(text string) string {
|
||||||
|
if !ContainsJapanese(text) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
runes := []rune(text)
|
||||||
|
i := 0
|
||||||
|
|
||||||
|
for i < len(runes) {
|
||||||
|
// Check for っ/ッ (double consonant)
|
||||||
|
if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') {
|
||||||
|
nextRomaji := ""
|
||||||
|
if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok {
|
||||||
|
nextRomaji = romaji
|
||||||
|
} else if romaji, ok := katakanaToRomaji[runes[i+1]]; ok {
|
||||||
|
nextRomaji = romaji
|
||||||
|
}
|
||||||
|
if len(nextRomaji) > 0 {
|
||||||
|
result.WriteByte(nextRomaji[0]) // Double the first consonant
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for two-character combinations
|
||||||
|
if i < len(runes)-1 {
|
||||||
|
combo := string(runes[i : i+2])
|
||||||
|
if romaji, ok := combinationHiragana[combo]; ok {
|
||||||
|
result.WriteString(romaji)
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if romaji, ok := combinationKatakana[combo]; ok {
|
||||||
|
result.WriteString(romaji)
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single character conversion
|
||||||
|
r := runes[i]
|
||||||
|
if romaji, ok := hiraganaToRomaji[r]; ok {
|
||||||
|
result.WriteString(romaji)
|
||||||
|
} else if romaji, ok := katakanaToRomaji[r]; ok {
|
||||||
|
result.WriteString(romaji)
|
||||||
|
} else if isKanji(r) {
|
||||||
|
// Keep kanji as-is (would need dictionary for proper conversion)
|
||||||
|
result.WriteRune(r)
|
||||||
|
} else {
|
||||||
|
// Keep other characters (punctuation, spaces, etc.)
|
||||||
|
result.WriteRune(r)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildSearchQuery creates a search query from track name and artist
|
||||||
|
// Converts Japanese to romaji if present
|
||||||
|
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)
|
||||||
|
|
||||||
|
return strings.TrimSpace(artistClean + " " + trackClean)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanSearchQuery removes special characters that might interfere with search
|
||||||
|
func cleanSearchQuery(s string) string {
|
||||||
|
var result strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
if unicode.IsLetter(r) || unicode.IsNumber(r) || unicode.IsSpace(r) {
|
||||||
|
result.WriteRune(r)
|
||||||
|
} else if r == '-' || r == '\'' {
|
||||||
|
result.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(result.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanToASCII removes all non-ASCII characters and keeps only letters, numbers, spaces
|
||||||
|
// This is useful for creating search queries that work better with Tidal's search
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,20 +22,37 @@ type TrackAvailability struct {
|
|||||||
Tidal bool `json:"tidal"`
|
Tidal bool `json:"tidal"`
|
||||||
Amazon bool `json:"amazon"`
|
Amazon bool `json:"amazon"`
|
||||||
Qobuz bool `json:"qobuz"`
|
Qobuz bool `json:"qobuz"`
|
||||||
|
Deezer bool `json:"deezer"`
|
||||||
TidalURL string `json:"tidal_url,omitempty"`
|
TidalURL string `json:"tidal_url,omitempty"`
|
||||||
AmazonURL string `json:"amazon_url,omitempty"`
|
AmazonURL string `json:"amazon_url,omitempty"`
|
||||||
QobuzURL string `json:"qobuz_url,omitempty"`
|
QobuzURL string `json:"qobuz_url,omitempty"`
|
||||||
|
DeezerURL string `json:"deezer_url,omitempty"`
|
||||||
|
DeezerID string `json:"deezer_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSongLinkClient creates a new SongLink client
|
var (
|
||||||
|
// Global SongLink client instance for connection reuse
|
||||||
|
globalSongLinkClient *SongLinkClient
|
||||||
|
songLinkClientOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewSongLinkClient creates a new SongLink client (returns singleton for connection reuse)
|
||||||
func NewSongLinkClient() *SongLinkClient {
|
func NewSongLinkClient() *SongLinkClient {
|
||||||
return &SongLinkClient{
|
songLinkClientOnce.Do(func() {
|
||||||
client: NewHTTPClientWithTimeout(SongLinkTimeout), // 30s timeout
|
globalSongLinkClient = &SongLinkClient{
|
||||||
}
|
client: NewHTTPClientWithTimeout(SongLinkTimeout), // 30s timeout
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalSongLinkClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckTrackAvailability checks track availability on streaming platforms
|
// CheckTrackAvailability checks track availability on streaming platforms
|
||||||
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||||
|
// Validate Spotify ID format (should be 22 characters alphanumeric)
|
||||||
|
if spotifyTrackID == "" {
|
||||||
|
return nil, fmt.Errorf("spotify track ID is empty")
|
||||||
|
}
|
||||||
|
|
||||||
// Use global rate limiter - blocks until request is allowed
|
// Use global rate limiter - blocks until request is allowed
|
||||||
songLinkRateLimiter.WaitForSlot()
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
@@ -57,8 +76,18 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Handle specific error codes
|
||||||
|
if resp.StatusCode == 400 {
|
||||||
|
return nil, fmt.Errorf("track not found on SongLink (invalid Spotify ID or track unavailable)")
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 404 {
|
||||||
|
return nil, fmt.Errorf("track not found on any streaming platform")
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 429 {
|
||||||
|
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||||
|
}
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := ReadResponseBody(resp)
|
body, err := ReadResponseBody(resp)
|
||||||
@@ -92,7 +121,15 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
availability.AmazonURL = amazonLink.URL
|
availability.AmazonURL = amazonLink.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Qobuz using ISRC
|
// Check Deezer
|
||||||
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
|
availability.Deezer = true
|
||||||
|
availability.DeezerURL = deezerLink.URL
|
||||||
|
// Extract Deezer ID from URL (e.g., https://www.deezer.com/track/123456)
|
||||||
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Qobuz using ISRC (SongLink doesn't support Qobuz directly)
|
||||||
if isrc != "" {
|
if isrc != "" {
|
||||||
availability.Qobuz = checkQobuzAvailability(isrc)
|
availability.Qobuz = checkQobuzAvailability(isrc)
|
||||||
}
|
}
|
||||||
@@ -151,3 +188,357 @@ func checkQobuzAvailability(isrc string) bool {
|
|||||||
|
|
||||||
return searchResp.Tracks.Total > 0
|
return searchResp.Tracks.Total > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
|
||||||
|
func extractDeezerIDFromURL(deezerURL string) string {
|
||||||
|
// URL format: https://www.deezer.com/track/123456 or https://www.deezer.com/en/track/123456
|
||||||
|
parts := strings.Split(deezerURL, "/")
|
||||||
|
if len(parts) > 0 {
|
||||||
|
// Get the last part which should be the ID
|
||||||
|
lastPart := parts[len(parts)-1]
|
||||||
|
// Remove any query parameters
|
||||||
|
if idx := strings.Index(lastPart, "?"); idx > 0 {
|
||||||
|
lastPart = lastPart[:idx]
|
||||||
|
}
|
||||||
|
return lastPart
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeezerIDFromSpotify converts a Spotify track ID to Deezer track ID using SongLink
|
||||||
|
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
|
||||||
|
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !availability.Deezer || availability.DeezerID == "" {
|
||||||
|
return "", fmt.Errorf("track not found on Deezer")
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability.DeezerID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlbumAvailability represents album availability on different platforms
|
||||||
|
type AlbumAvailability struct {
|
||||||
|
SpotifyID string `json:"spotify_id"`
|
||||||
|
Deezer bool `json:"deezer"`
|
||||||
|
DeezerURL string `json:"deezer_url,omitempty"`
|
||||||
|
DeezerID string `json:"deezer_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckAlbumAvailability checks album availability on streaming platforms using SongLink
|
||||||
|
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
|
||||||
|
// Use global rate limiter
|
||||||
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
|
// Build API URL for album
|
||||||
|
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLw==")
|
||||||
|
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyAlbumID)
|
||||||
|
|
||||||
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||||
|
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retryConfig := DefaultRetryConfig()
|
||||||
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check album availability: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := ReadResponseBody(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var songLinkResp struct {
|
||||||
|
LinksByPlatform map[string]struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"linksByPlatform"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
availability := &AlbumAvailability{
|
||||||
|
SpotifyID: spotifyAlbumID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Deezer
|
||||||
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
|
availability.Deezer = true
|
||||||
|
availability.DeezerURL = deezerLink.URL
|
||||||
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeezerAlbumIDFromSpotify converts a Spotify album ID to Deezer album ID using SongLink
|
||||||
|
func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (string, error) {
|
||||||
|
availability, err := s.CheckAlbumAvailability(spotifyAlbumID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !availability.Deezer || availability.DeezerID == "" {
|
||||||
|
return "", fmt.Errorf("album not found on Deezer")
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability.DeezerID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Deezer ID Support - Query SongLink using Deezer as source
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// CheckAvailabilityFromDeezer checks track availability using Deezer track ID as source
|
||||||
|
// This is useful when we have Deezer metadata and want to find the track on other platforms
|
||||||
|
func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
|
||||||
|
if deezerTrackID == "" {
|
||||||
|
return nil, fmt.Errorf("deezer track ID is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use global rate limiter
|
||||||
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
|
// Build Deezer URL
|
||||||
|
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
||||||
|
|
||||||
|
// Build API URL using Deezer URL as source
|
||||||
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||||
|
apiURL := fmt.Sprintf("%s%s&userCountry=US", string(apiBase), url.QueryEscape(deezerURL))
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retryConfig := DefaultRetryConfig()
|
||||||
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Handle specific error codes
|
||||||
|
if resp.StatusCode == 400 {
|
||||||
|
return nil, fmt.Errorf("track not found on SongLink (invalid Deezer ID)")
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 404 {
|
||||||
|
return nil, fmt.Errorf("track not found on any streaming platform")
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 429 {
|
||||||
|
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := ReadResponseBody(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var songLinkResp struct {
|
||||||
|
LinksByPlatform map[string]struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"linksByPlatform"`
|
||||||
|
EntitiesByUniqueId map[string]struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
ArtistName string `json:"artistName"`
|
||||||
|
} `json:"entitiesByUniqueId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
availability := &TrackAvailability{
|
||||||
|
Deezer: true,
|
||||||
|
DeezerID: deezerTrackID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Spotify
|
||||||
|
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||||
|
// Extract Spotify ID from URL
|
||||||
|
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Tidal
|
||||||
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||||
|
availability.Tidal = true
|
||||||
|
availability.TidalURL = tidalLink.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Amazon
|
||||||
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
|
availability.Amazon = true
|
||||||
|
availability.AmazonURL = amazonLink.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Deezer URL
|
||||||
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
|
availability.DeezerURL = deezerLink.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckAvailabilityByPlatform checks track availability using any supported platform
|
||||||
|
// platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube", etc.
|
||||||
|
// entityType: "song" or "album"
|
||||||
|
// entityID: the ID on that platform
|
||||||
|
func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entityID string) (*TrackAvailability, error) {
|
||||||
|
if entityID == "" {
|
||||||
|
return nil, fmt.Errorf("%s ID is empty", platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use global rate limiter
|
||||||
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
|
// Build API URL using platform, type, and id parameters (as per API docs)
|
||||||
|
// https://api.song.link/v1-alpha.1/links?platform=deezer&type=song&id=123456
|
||||||
|
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?platform=%s&type=%s&id=%s&userCountry=US",
|
||||||
|
url.QueryEscape(platform),
|
||||||
|
url.QueryEscape(entityType),
|
||||||
|
url.QueryEscape(entityID))
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retryConfig := DefaultRetryConfig()
|
||||||
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Handle specific error codes
|
||||||
|
if resp.StatusCode == 400 {
|
||||||
|
return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform)
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 404 {
|
||||||
|
return nil, fmt.Errorf("track not found on any streaming platform")
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 429 {
|
||||||
|
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := ReadResponseBody(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var songLinkResp struct {
|
||||||
|
LinksByPlatform map[string]struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"linksByPlatform"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
availability := &TrackAvailability{}
|
||||||
|
|
||||||
|
// Check Spotify
|
||||||
|
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||||
|
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Tidal
|
||||||
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||||
|
availability.Tidal = true
|
||||||
|
availability.TidalURL = tidalLink.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Amazon
|
||||||
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
|
availability.Amazon = true
|
||||||
|
availability.AmazonURL = amazonLink.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Deezer
|
||||||
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
|
availability.Deezer = true
|
||||||
|
availability.DeezerURL = deezerLink.URL
|
||||||
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractSpotifyIDFromURL extracts Spotify track ID from URL
|
||||||
|
func extractSpotifyIDFromURL(spotifyURL string) string {
|
||||||
|
// URL format: https://open.spotify.com/track/0Jcij1eWd5bDMU5iPbxe2i
|
||||||
|
parts := strings.Split(spotifyURL, "/track/")
|
||||||
|
if len(parts) > 1 {
|
||||||
|
// Get the ID part and remove any query parameters
|
||||||
|
idPart := parts[1]
|
||||||
|
if idx := strings.Index(idPart, "?"); idx > 0 {
|
||||||
|
idPart = idPart[:idx]
|
||||||
|
}
|
||||||
|
return idPart
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSpotifyIDFromDeezer converts a Deezer track ID to Spotify track ID using SongLink
|
||||||
|
func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, error) {
|
||||||
|
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if availability.SpotifyID == "" {
|
||||||
|
return "", fmt.Errorf("track not found on Spotify")
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability.SpotifyID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTidalURLFromDeezer converts a Deezer track ID to Tidal URL using SongLink
|
||||||
|
func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, error) {
|
||||||
|
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !availability.Tidal || availability.TidalURL == "" {
|
||||||
|
return "", fmt.Errorf("track not found on Tidal")
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability.TidalURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAmazonURLFromDeezer converts a Deezer track ID to Amazon Music URL using SongLink
|
||||||
|
func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, error) {
|
||||||
|
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !availability.Amazon || availability.AmazonURL == "" {
|
||||||
|
return "", fmt.Errorf("track not found on Amazon Music")
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability.AmazonURL, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package gobackend
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -17,14 +16,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
spotifyTokenURL = "https://accounts.spotify.com/api/token"
|
spotifyTokenURL = "https://accounts.spotify.com/api/token"
|
||||||
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
|
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
|
||||||
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
|
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
|
||||||
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
|
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
|
||||||
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
||||||
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
||||||
searchBaseURL = "https://api.spotify.com/v1/search"
|
searchBaseURL = "https://api.spotify.com/v1/search"
|
||||||
|
|
||||||
// Cache TTL settings
|
// Cache TTL settings
|
||||||
artistCacheTTL = 10 * time.Minute
|
artistCacheTTL = 10 * time.Minute
|
||||||
searchCacheTTL = 5 * time.Minute
|
searchCacheTTL = 5 * time.Minute
|
||||||
@@ -54,7 +53,7 @@ type SpotifyMetadataClient struct {
|
|||||||
rng *rand.Rand
|
rng *rand.Rand
|
||||||
rngMu sync.Mutex
|
rngMu sync.Mutex
|
||||||
userAgent string
|
userAgent string
|
||||||
|
|
||||||
// Caches to reduce API calls
|
// Caches to reduce API calls
|
||||||
artistCache map[string]*cacheEntry // key: artistID
|
artistCache map[string]*cacheEntry // key: artistID
|
||||||
searchCache map[string]*cacheEntry // key: query+type
|
searchCache map[string]*cacheEntry // key: query+type
|
||||||
@@ -69,8 +68,10 @@ var (
|
|||||||
credentialsMu sync.RWMutex
|
credentialsMu sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrNoSpotifyCredentials is returned when Spotify credentials are not configured
|
||||||
|
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)")
|
||||||
|
|
||||||
// SetSpotifyCredentials sets custom Spotify API credentials
|
// SetSpotifyCredentials sets custom Spotify API credentials
|
||||||
// Pass empty strings to use default credentials
|
|
||||||
func SetSpotifyCredentials(clientID, clientSecret string) {
|
func SetSpotifyCredentials(clientID, clientSecret string) {
|
||||||
credentialsMu.Lock()
|
credentialsMu.Lock()
|
||||||
defer credentialsMu.Unlock()
|
defer credentialsMu.Unlock()
|
||||||
@@ -78,39 +79,56 @@ func SetSpotifyCredentials(clientID, clientSecret string) {
|
|||||||
customClientSecret = clientSecret
|
customClientSecret = clientSecret
|
||||||
}
|
}
|
||||||
|
|
||||||
// getCredentials returns the current credentials (custom or default)
|
// HasSpotifyCredentials checks if Spotify credentials are configured
|
||||||
func getCredentials() (string, string) {
|
func HasSpotifyCredentials() bool {
|
||||||
credentialsMu.RLock()
|
credentialsMu.RLock()
|
||||||
defer credentialsMu.RUnlock()
|
defer credentialsMu.RUnlock()
|
||||||
|
|
||||||
|
// Check custom credentials first
|
||||||
if customClientID != "" && customClientSecret != "" {
|
if customClientID != "" && customClientSecret != "" {
|
||||||
return customClientID, customClientSecret
|
return true
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to default credentials
|
|
||||||
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
|
||||||
if clientID == "" {
|
|
||||||
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
|
|
||||||
clientID = string(decoded)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
// Check environment variables
|
||||||
if clientSecret == "" {
|
if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" {
|
||||||
if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil {
|
return true
|
||||||
clientSecret = string(decoded)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return clientID, clientSecret
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCredentials returns the current credentials or error if not configured
|
||||||
|
func getCredentials() (string, string, error) {
|
||||||
|
credentialsMu.RLock()
|
||||||
|
defer credentialsMu.RUnlock()
|
||||||
|
|
||||||
|
// Check custom credentials first
|
||||||
|
if customClientID != "" && customClientSecret != "" {
|
||||||
|
return customClientID, customClientSecret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check environment variables
|
||||||
|
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
||||||
|
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
||||||
|
|
||||||
|
if clientID != "" && clientSecret != "" {
|
||||||
|
return clientID, clientSecret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// No credentials available
|
||||||
|
return "", "", ErrNoSpotifyCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSpotifyMetadataClient creates a new Spotify client
|
// NewSpotifyMetadataClient creates a new Spotify client
|
||||||
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
// Returns error if credentials are not configured
|
||||||
src := rand.NewSource(time.Now().UnixNano())
|
func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
|
||||||
|
// Get credentials - will error if not configured
|
||||||
|
clientID, clientSecret, err := getCredentials()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// Get credentials (custom or default)
|
src := rand.NewSource(time.Now().UnixNano())
|
||||||
clientID, clientSecret := getCredentials()
|
|
||||||
|
|
||||||
c := &SpotifyMetadataClient{
|
c := &SpotifyMetadataClient{
|
||||||
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
|
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
|
||||||
@@ -122,7 +140,7 @@ func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
|||||||
albumCache: make(map[string]*cacheEntry),
|
albumCache: make(map[string]*cacheEntry),
|
||||||
}
|
}
|
||||||
c.userAgent = c.randomUserAgent()
|
c.userAgent = c.randomUserAgent()
|
||||||
return c
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TrackMetadata represents track information
|
// TrackMetadata represents track information
|
||||||
@@ -140,6 +158,7 @@ type TrackMetadata struct {
|
|||||||
DiscNumber int `json:"disc_number,omitempty"`
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
ExternalURL string `json:"external_urls"`
|
ExternalURL string `json:"external_urls"`
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
|
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlbumTrackMetadata holds per-track info for album/playlist
|
// AlbumTrackMetadata holds per-track info for album/playlist
|
||||||
@@ -159,6 +178,7 @@ type AlbumTrackMetadata struct {
|
|||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
AlbumID string `json:"album_id,omitempty"`
|
AlbumID string `json:"album_id,omitempty"`
|
||||||
AlbumURL string `json:"album_url,omitempty"`
|
AlbumURL string `json:"album_url,omitempty"`
|
||||||
|
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlbumInfoMetadata holds album information
|
// AlbumInfoMetadata holds album information
|
||||||
@@ -283,6 +303,7 @@ type albumSimplified struct {
|
|||||||
Images []image `json:"images"`
|
Images []image `json:"images"`
|
||||||
ExternalURL externalURL `json:"external_urls"`
|
ExternalURL externalURL `json:"external_urls"`
|
||||||
Artists []artist `json:"artists"`
|
Artists []artist `json:"artists"`
|
||||||
|
AlbumType string `json:"album_type"` // album, single, compilation
|
||||||
}
|
}
|
||||||
|
|
||||||
type trackFull struct {
|
type trackFull struct {
|
||||||
@@ -331,14 +352,14 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
searchURL := fmt.Sprintf("%s?q=%s&type=track&limit=%d", searchBaseURL, url.QueryEscape(query), limit)
|
searchURL := fmt.Sprintf("%s?q=%s&type=track&limit=%d", searchBaseURL, url.QueryEscape(query), limit)
|
||||||
|
|
||||||
var response struct {
|
var response struct {
|
||||||
Tracks struct {
|
Tracks struct {
|
||||||
Items []trackFull `json:"items"`
|
Items []trackFull `json:"items"`
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.getJSON(ctx, searchURL, token, &response); err != nil {
|
if err := c.getJSON(ctx, searchURL, token, &response); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -363,6 +384,7 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
|||||||
DiscNumber: track.DiscNumber,
|
DiscNumber: track.DiscNumber,
|
||||||
ExternalURL: track.ExternalURL.Spotify,
|
ExternalURL: track.ExternalURL.Spotify,
|
||||||
ISRC: track.ExternalID.ISRC,
|
ISRC: track.ExternalID.ISRC,
|
||||||
|
AlbumType: track.Album.AlbumType,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,7 +395,7 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
|||||||
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
||||||
// Create cache key
|
// Create cache key
|
||||||
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
|
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
|
||||||
|
|
||||||
// Check cache first
|
// Check cache first
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||||
@@ -388,24 +410,24 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
|
|||||||
}
|
}
|
||||||
|
|
||||||
searchURL := fmt.Sprintf("%s?q=%s&type=track,artist&limit=%d", searchBaseURL, url.QueryEscape(query), trackLimit)
|
searchURL := fmt.Sprintf("%s?q=%s&type=track,artist&limit=%d", searchBaseURL, url.QueryEscape(query), trackLimit)
|
||||||
|
|
||||||
var response struct {
|
var response struct {
|
||||||
Tracks struct {
|
Tracks struct {
|
||||||
Items []trackFull `json:"items"`
|
Items []trackFull `json:"items"`
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
Artists struct {
|
Artists struct {
|
||||||
Items []struct {
|
Items []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Images []image `json:"images"`
|
Images []image `json:"images"`
|
||||||
Followers struct {
|
Followers struct {
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
} `json:"followers"`
|
} `json:"followers"`
|
||||||
Popularity int `json:"popularity"`
|
Popularity int `json:"popularity"`
|
||||||
} `json:"items"`
|
} `json:"items"`
|
||||||
} `json:"artists"`
|
} `json:"artists"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.getJSON(ctx, searchURL, token, &response); err != nil {
|
if err := c.getJSON(ctx, searchURL, token, &response); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -430,6 +452,7 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
|
|||||||
DiscNumber: track.DiscNumber,
|
DiscNumber: track.DiscNumber,
|
||||||
ExternalURL: track.ExternalURL.Spotify,
|
ExternalURL: track.ExternalURL.Spotify,
|
||||||
ISRC: track.ExternalID.ISRC,
|
ISRC: track.ExternalID.ISRC,
|
||||||
|
AlbumType: track.Album.AlbumType,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,7 +461,7 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
|
|||||||
if artistCount > artistLimit {
|
if artistCount > artistLimit {
|
||||||
artistCount = artistLimit
|
artistCount = artistLimit
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < artistCount; i++ {
|
for i := 0; i < artistCount; i++ {
|
||||||
artist := response.Artists.Items[i]
|
artist := response.Artists.Items[i]
|
||||||
result.Artists = append(result.Artists, SearchArtistResult{
|
result.Artists = append(result.Artists, SearchArtistResult{
|
||||||
@@ -495,6 +518,17 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
}
|
}
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
// Track item structure for pagination
|
||||||
|
type trackItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DurationMS int `json:"duration_ms"`
|
||||||
|
TrackNumber int `json:"track_number"`
|
||||||
|
DiscNumber int `json:"disc_number"`
|
||||||
|
ExternalURL externalURL `json:"external_urls"`
|
||||||
|
Artists []artist `json:"artists"`
|
||||||
|
}
|
||||||
|
|
||||||
var data struct {
|
var data struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
@@ -502,15 +536,8 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
Images []image `json:"images"`
|
Images []image `json:"images"`
|
||||||
Artists []artist `json:"artists"`
|
Artists []artist `json:"artists"`
|
||||||
Tracks struct {
|
Tracks struct {
|
||||||
Items []struct {
|
Items []trackItem `json:"items"`
|
||||||
ID string `json:"id"`
|
Next string `json:"next"`
|
||||||
Name string `json:"name"`
|
|
||||||
DurationMS int `json:"duration_ms"`
|
|
||||||
TrackNumber int `json:"track_number"`
|
|
||||||
DiscNumber int `json:"disc_number"`
|
|
||||||
ExternalURL externalURL `json:"external_urls"`
|
|
||||||
Artists []artist `json:"artists"`
|
|
||||||
} `json:"items"`
|
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,11 +554,39 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
Images: albumImage,
|
Images: albumImage,
|
||||||
}
|
}
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(data.Tracks.Items))
|
// Collect all tracks (including paginated)
|
||||||
for _, item := range data.Tracks.Items {
|
allTrackItems := data.Tracks.Items
|
||||||
// Fetch ISRC for each track
|
nextURL := data.Tracks.Next
|
||||||
isrc := c.fetchTrackISRC(ctx, item.ID, token)
|
|
||||||
|
// Fetch remaining tracks using pagination (no limit)
|
||||||
|
for nextURL != "" {
|
||||||
|
var pageData struct {
|
||||||
|
Items []trackItem `json:"items"`
|
||||||
|
Next string `json:"next"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil {
|
||||||
|
fmt.Printf("[Spotify] Warning: failed to fetch album tracks page: %v\n", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
allTrackItems = append(allTrackItems, pageData.Items...)
|
||||||
|
nextURL = pageData.Next
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[Spotify] Album has %d tracks (total: %d)\n", len(allTrackItems), data.TotalTracks)
|
||||||
|
|
||||||
|
// Collect track IDs for parallel ISRC fetching
|
||||||
|
trackIDs := make([]string, len(allTrackItems))
|
||||||
|
for i, item := range allTrackItems {
|
||||||
|
trackIDs[i] = item.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch ISRCs in parallel for ALL tracks (like Deezer implementation)
|
||||||
|
isrcMap := c.fetchISRCsParallel(ctx, trackIDs, token)
|
||||||
|
|
||||||
|
tracks := make([]AlbumTrackMetadata, 0, len(allTrackItems))
|
||||||
|
for _, item := range allTrackItems {
|
||||||
|
isrc := isrcMap[item.ID]
|
||||||
|
|
||||||
tracks = append(tracks, AlbumTrackMetadata{
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
SpotifyID: item.ID,
|
SpotifyID: item.ID,
|
||||||
Artists: joinArtists(item.Artists),
|
Artists: joinArtists(item.Artists),
|
||||||
@@ -566,6 +621,47 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel
|
||||||
|
// Similar to Deezer implementation for consistency
|
||||||
|
func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs []string, token string) map[string]string {
|
||||||
|
const maxParallelISRC = 10 // Max concurrent ISRC fetches
|
||||||
|
|
||||||
|
result := make(map[string]string)
|
||||||
|
var resultMu sync.Mutex
|
||||||
|
|
||||||
|
if len(trackIDs) == 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use semaphore to limit concurrent requests
|
||||||
|
sem := make(chan struct{}, maxParallelISRC)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for _, trackID := range trackIDs {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(id string) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
// Acquire semaphore
|
||||||
|
select {
|
||||||
|
case sem <- struct{}{}:
|
||||||
|
defer func() { <-sem }()
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isrc := c.fetchTrackISRC(ctx, id, token)
|
||||||
|
|
||||||
|
resultMu.Lock()
|
||||||
|
result[id] = isrc
|
||||||
|
resultMu.Unlock()
|
||||||
|
}(trackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
|
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
|
||||||
// First request to get playlist info and first batch of tracks
|
// First request to get playlist info and first batch of tracks
|
||||||
var data struct {
|
var data struct {
|
||||||
@@ -595,7 +691,7 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
|
|
||||||
// Pre-allocate with expected capacity
|
// Pre-allocate with expected capacity
|
||||||
tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total)
|
tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total)
|
||||||
|
|
||||||
// Add first batch of tracks
|
// Add first batch of tracks
|
||||||
for _, item := range data.Tracks.Items {
|
for _, item := range data.Tracks.Items {
|
||||||
if item.Track == nil {
|
if item.Track == nil {
|
||||||
@@ -620,11 +716,10 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch remaining tracks using pagination (up to 1000 tracks max)
|
// Fetch remaining tracks using pagination (NO LIMIT - fetch all tracks)
|
||||||
nextURL := data.Tracks.Next
|
nextURL := data.Tracks.Next
|
||||||
maxTracks := 1000
|
|
||||||
|
for nextURL != "" {
|
||||||
for nextURL != "" && len(tracks) < maxTracks {
|
|
||||||
var pageData struct {
|
var pageData struct {
|
||||||
Items []struct {
|
Items []struct {
|
||||||
Track *trackFull `json:"track"`
|
Track *trackFull `json:"track"`
|
||||||
@@ -642,9 +737,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
if item.Track == nil {
|
if item.Track == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(tracks) >= maxTracks {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
tracks = append(tracks, AlbumTrackMetadata{
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
SpotifyID: item.Track.ID,
|
SpotifyID: item.Track.ID,
|
||||||
Artists: joinArtists(item.Track.Artists),
|
Artists: joinArtists(item.Track.Artists),
|
||||||
@@ -686,10 +778,10 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
|
|||||||
|
|
||||||
// Fetch artist info
|
// Fetch artist info
|
||||||
var artistData struct {
|
var artistData struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Images []image `json:"images"`
|
Images []image `json:"images"`
|
||||||
Followers struct {
|
Followers struct {
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
} `json:"followers"`
|
} `json:"followers"`
|
||||||
Popularity int `json:"popularity"`
|
Popularity int `json:"popularity"`
|
||||||
@@ -835,8 +927,16 @@ func (c *SpotifyMetadataClient) getJSON(ctx context.Context, endpoint, token str
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set headers (same as PC version baseHeaders)
|
||||||
req.Header.Set("User-Agent", c.userAgent)
|
req.Header.Set("User-Agent", c.userAgent)
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||||
|
req.Header.Set("sec-ch-ua-platform", "\"Windows\"")
|
||||||
|
req.Header.Set("sec-fetch-dest", "empty")
|
||||||
|
req.Header.Set("sec-fetch-mode", "cors")
|
||||||
|
req.Header.Set("sec-fetch-site", "same-origin")
|
||||||
|
req.Header.Set("Referer", "https://open.spotify.com/")
|
||||||
|
req.Header.Set("Origin", "https://open.spotify.com")
|
||||||
if token != "" {
|
if token != "" {
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
}
|
}
|
||||||
@@ -863,13 +963,23 @@ func (c *SpotifyMetadataClient) randomUserAgent() string {
|
|||||||
c.rngMu.Lock()
|
c.rngMu.Lock()
|
||||||
defer c.rngMu.Unlock()
|
defer c.rngMu.Unlock()
|
||||||
|
|
||||||
chromeMajor := 80 + c.rng.Intn(25)
|
// Use Mac User-Agent format (same as PC version)
|
||||||
chromeBuild := 3000 + c.rng.Intn(1500)
|
macMajor := c.rng.Intn(4) + 11 // 11-14
|
||||||
chromePatch := 60 + c.rng.Intn(65)
|
macMinor := c.rng.Intn(5) + 4 // 4-8
|
||||||
|
webkitMajor := c.rng.Intn(7) + 530 // 530-536
|
||||||
|
webkitMinor := c.rng.Intn(7) + 30 // 30-36
|
||||||
|
chromeMajor := c.rng.Intn(25) + 80 // 80-104
|
||||||
|
chromeBuild := c.rng.Intn(1500) + 3000 // 3000-4499
|
||||||
|
chromePatch := c.rng.Intn(65) + 60 // 60-124
|
||||||
|
safariMajor := c.rng.Intn(7) + 530 // 530-536
|
||||||
|
safariMinor := c.rng.Intn(6) + 30 // 30-35
|
||||||
|
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
|
||||||
|
macMajor, macMinor,
|
||||||
|
webkitMajor, webkitMinor,
|
||||||
chromeMajor, chromeBuild, chromePatch,
|
chromeMajor, chromeBuild, chromePatch,
|
||||||
|
safariMajor, safariMinor,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 70 KiB |
@@ -427,7 +427,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
@@ -484,7 +484,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
|||||||
@@ -181,6 +181,383 @@ import Gobackend // Import Go framework
|
|||||||
GobackendCleanupConnections()
|
GobackendCleanupConnections()
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
|
case "readFileMetadata":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let filePath = args["file_path"] as! String
|
||||||
|
let response = GobackendReadFileMetadata(filePath, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "searchDeezerAll":
|
||||||
|
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 = GobackendSearchDeezerAll(query, Int(trackLimit), Int(artistLimit), &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getDeezerMetadata":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let resourceType = args["resource_type"] as! String
|
||||||
|
let resourceId = args["resource_id"] as! String
|
||||||
|
let response = GobackendGetDeezerMetadata(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
|
||||||
|
let response = GobackendParseDeezerURLExport(url, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "searchDeezerByISRC":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let isrc = args["isrc"] as! String
|
||||||
|
let response = GobackendSearchDeezerByISRC(isrc, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "convertSpotifyToDeezer":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let resourceType = args["resource_type"] as! String
|
||||||
|
let spotifyId = args["spotify_id"] as! String
|
||||||
|
let response = GobackendConvertSpotifyToDeezer(resourceType, spotifyId, &error)
|
||||||
|
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 "preWarmTrackCache":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let tracksJson = args["tracks"] as! String
|
||||||
|
let _ = GobackendPreWarmTrackCacheJSON(tracksJson, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "getTrackCacheSize":
|
||||||
|
let response = GobackendGetTrackCacheSize()
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "clearTrackCache":
|
||||||
|
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()
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getLogsSince":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let index = args["index"] as? Int ?? 0
|
||||||
|
let response = GobackendGetLogsSince(Int(index))
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "clearLogs":
|
||||||
|
GobackendClearLogs()
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "getLogCount":
|
||||||
|
let response = GobackendGetLogCount()
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "setLoggingEnabled":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let enabled = args["enabled"] as? Bool ?? false
|
||||||
|
GobackendSetLoggingEnabled(enabled)
|
||||||
|
return nil
|
||||||
|
|
||||||
|
// Extension System methods
|
||||||
|
case "initExtensionSystem":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionsDir = args["extensions_dir"] as! String
|
||||||
|
let dataDir = args["data_dir"] as! String
|
||||||
|
GobackendInitExtensionSystem(extensionsDir, dataDir, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "loadExtensionsFromDir":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let dirPath = args["dir_path"] as! String
|
||||||
|
let response = GobackendLoadExtensionsFromDir(dirPath, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "loadExtensionFromPath":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let filePath = args["file_path"] as! String
|
||||||
|
let response = GobackendLoadExtensionFromPath(filePath, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "unloadExtension":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
GobackendUnloadExtensionByID(extensionId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "getInstalledExtensions":
|
||||||
|
let response = GobackendGetInstalledExtensions(&error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "setExtensionEnabled":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
let enabled = args["enabled"] as? Bool ?? false
|
||||||
|
GobackendSetExtensionEnabledByID(extensionId, enabled, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "setProviderPriority":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let priorityJson = args["priority"] as! String
|
||||||
|
GobackendSetProviderPriorityJSON(priorityJson, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "getProviderPriority":
|
||||||
|
let response = GobackendGetProviderPriorityJSON(&error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "setMetadataProviderPriority":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let priorityJson = args["priority"] as! String
|
||||||
|
GobackendSetMetadataProviderPriorityJSON(priorityJson, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "getMetadataProviderPriority":
|
||||||
|
let response = GobackendGetMetadataProviderPriorityJSON(&error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getExtensionSettings":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
let response = GobackendGetExtensionSettingsJSON(extensionId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "setExtensionSettings":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
let settingsJson = args["settings"] as! String
|
||||||
|
GobackendSetExtensionSettingsJSON(extensionId, settingsJson, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "searchTracksWithExtensions":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let query = args["query"] as! String
|
||||||
|
let limit = args["limit"] as? Int ?? 20
|
||||||
|
let response = GobackendSearchTracksWithExtensionsJSON(query, Int(limit), &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "downloadWithExtensions":
|
||||||
|
let requestJson = call.arguments as! String
|
||||||
|
let response = GobackendDownloadWithExtensionsJSON(requestJson, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "removeExtension":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
GobackendRemoveExtensionByID(extensionId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "upgradeExtension":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let filePath = args["file_path"] as! String
|
||||||
|
let response = GobackendUpgradeExtensionFromPath(filePath, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "checkExtensionUpgrade":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let filePath = args["file_path"] as! String
|
||||||
|
let response = GobackendCheckExtensionUpgradeFromPath(filePath, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "cleanupExtensions":
|
||||||
|
GobackendCleanupExtensions()
|
||||||
|
return nil
|
||||||
|
|
||||||
|
// Extension Auth API
|
||||||
|
case "getExtensionPendingAuth":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
let response = GobackendGetExtensionPendingAuthJSON(extensionId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "setExtensionAuthCode":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
let authCode = args["auth_code"] as! String
|
||||||
|
GobackendSetExtensionAuthCodeByID(extensionId, authCode)
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "setExtensionTokens":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
let accessToken = args["access_token"] as! String
|
||||||
|
let refreshToken = args["refresh_token"] as? String ?? ""
|
||||||
|
let expiresIn = args["expires_in"] as? Int ?? 0
|
||||||
|
GobackendSetExtensionTokensByID(extensionId, accessToken, refreshToken, Int(expiresIn))
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "clearExtensionPendingAuth":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
GobackendClearExtensionPendingAuthByID(extensionId)
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "isExtensionAuthenticated":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
let response = GobackendIsExtensionAuthenticatedByID(extensionId)
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getAllPendingAuthRequests":
|
||||||
|
let response = GobackendGetAllPendingAuthRequestsJSON(&error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
// Extension FFmpeg API
|
||||||
|
case "getPendingFFmpegCommand":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let commandId = args["command_id"] as! String
|
||||||
|
let response = GobackendGetPendingFFmpegCommandJSON(commandId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "setFFmpegCommandResult":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let commandId = args["command_id"] as! String
|
||||||
|
let success = args["success"] as? Bool ?? false
|
||||||
|
let output = args["output"] as? String ?? ""
|
||||||
|
let errorMsg = args["error"] as? String ?? ""
|
||||||
|
GobackendSetFFmpegCommandResult(commandId, success, output, errorMsg)
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "getAllPendingFFmpegCommands":
|
||||||
|
let response = GobackendGetAllPendingFFmpegCommandsJSON(&error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
// Extension Custom Search API
|
||||||
|
case "customSearchWithExtension":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
let query = args["query"] as! String
|
||||||
|
let optionsJson = args["options"] as? String ?? ""
|
||||||
|
let response = GobackendCustomSearchWithExtensionJSON(extensionId, query, optionsJson, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getSearchProviders":
|
||||||
|
let response = GobackendGetSearchProvidersJSON(&error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
// Extension URL Handler API
|
||||||
|
case "handleURLWithExtension":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let url = args["url"] as! String
|
||||||
|
let response = GobackendHandleURLWithExtensionJSON(url, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "findURLHandler":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let url = args["url"] as! String
|
||||||
|
let response = GobackendFindURLHandlerJSON(url)
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getURLHandlers":
|
||||||
|
let response = GobackendGetURLHandlersJSON(&error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
// Extension Post-Processing API
|
||||||
|
case "runPostProcessing":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let filePath = args["file_path"] as! String
|
||||||
|
let metadataJson = args["metadata"] as? String ?? ""
|
||||||
|
let response = GobackendRunPostProcessingJSON(filePath, metadataJson, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getPostProcessingProviders":
|
||||||
|
let response = GobackendGetPostProcessingProvidersJSON(&error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
// Extension Store
|
||||||
|
case "initExtensionStore":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let cacheDir = args["cache_dir"] as! String
|
||||||
|
GobackendInitExtensionStoreJSON(cacheDir, &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
|
||||||
|
let response = GobackendGetStoreExtensionsJSON(forceRefresh, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "searchStoreExtensions":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let query = args["query"] as? String ?? ""
|
||||||
|
let category = args["category"] as? String ?? ""
|
||||||
|
let response = GobackendSearchStoreExtensionsJSON(query, category, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getStoreCategories":
|
||||||
|
let response = GobackendGetStoreCategoriesJSON(&error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "downloadStoreExtension":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
let destDir = args["dest_dir"] as! String
|
||||||
|
let response = GobackendDownloadStoreExtensionJSON(extensionId, destDir, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "clearStoreCache":
|
||||||
|
GobackendClearStoreCacheJSON(&error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return nil
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw NSError(
|
throw NSError(
|
||||||
domain: "SpotiFLAC",
|
domain: "SpotiFLAC",
|
||||||
|
|||||||
@@ -1,122 +1 @@
|
|||||||
{
|
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-20x20@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-20x20@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-40x40@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-40x40@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "60x60",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-60x60@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "60x60",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-60x60@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-20x20@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-20x20@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-29x29@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-29x29@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-40x40@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-40x40@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "76x76",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-76x76@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "76x76",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-76x76@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "83.5x83.5",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "1024x1024",
|
|
||||||
"idiom" : "ios-marketing",
|
|
||||||
"filename" : "Icon-App-1024x1024@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"version" : 1,
|
|
||||||
"author" : "xcode"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 318 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 576 B |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 744 B |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 419 B |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 789 B |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 576 B |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 717 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 752 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 932 B |
|
After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.3 KiB |
@@ -1,8 +1,8 @@
|
|||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '2.0.7-preview';
|
static const String version = '3.0.0';
|
||||||
static const String buildNumber = '37';
|
static const String buildNumber = '57';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
@@ -15,4 +15,6 @@ class AppInfo {
|
|||||||
static const String githubRepo = 'zarzet/SpotiFLAC-Mobile';
|
static const String githubRepo = 'zarzet/SpotiFLAC-Mobile';
|
||||||
static const String githubUrl = 'https://github.com/$githubRepo';
|
static const String githubUrl = 'https://github.com/$githubRepo';
|
||||||
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
|
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
|
||||||
|
|
||||||
|
static const String kofiUrl = 'https://ko-fi.com/zarzet';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:spotiflac_android/app.dart';
|
import 'package:spotiflac_android/app.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
import 'package:spotiflac_android/services/notification_service.dart';
|
import 'package:spotiflac_android/services/notification_service.dart';
|
||||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||||
|
|
||||||
@@ -24,14 +27,42 @@ void main() async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Widget to eagerly initialize providers that need to load data on startup
|
/// Widget to eagerly initialize providers that need to load data on startup
|
||||||
class _EagerInitialization extends ConsumerWidget {
|
class _EagerInitialization extends ConsumerStatefulWidget {
|
||||||
const _EagerInitialization({required this.child});
|
const _EagerInitialization({required this.child});
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<_EagerInitialization> createState() => _EagerInitializationState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initializeExtensions();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initializeExtensions() async {
|
||||||
|
try {
|
||||||
|
final appDir = await getApplicationDocumentsDirectory();
|
||||||
|
final extensionsDir = '${appDir.path}/extensions';
|
||||||
|
final dataDir = '${appDir.path}/extension_data';
|
||||||
|
|
||||||
|
// Create directories if needed
|
||||||
|
await Directory(extensionsDir).create(recursive: true);
|
||||||
|
await Directory(dataDir).create(recursive: true);
|
||||||
|
|
||||||
|
// Initialize extension system
|
||||||
|
await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to initialize extensions: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
// Eagerly initialize download history provider to load from storage
|
// Eagerly initialize download history provider to load from storage
|
||||||
ref.watch(downloadHistoryProvider);
|
ref.watch(downloadHistoryProvider);
|
||||||
return child;
|
return widget.child;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ enum DownloadStatus {
|
|||||||
skipped,
|
skipped,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Error type enum for better error handling
|
||||||
|
enum DownloadErrorType {
|
||||||
|
unknown,
|
||||||
|
notFound, // Track not found on any service
|
||||||
|
rateLimit, // Rate limited by service
|
||||||
|
network, // Network/connection error
|
||||||
|
permission, // File/folder permission error
|
||||||
|
}
|
||||||
|
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class DownloadItem {
|
class DownloadItem {
|
||||||
final String id;
|
final String id;
|
||||||
@@ -20,8 +29,10 @@ class DownloadItem {
|
|||||||
final String service;
|
final String service;
|
||||||
final DownloadStatus status;
|
final DownloadStatus status;
|
||||||
final double progress;
|
final double progress;
|
||||||
|
final double speedMBps; // Download speed in MB/s
|
||||||
final String? filePath;
|
final String? filePath;
|
||||||
final String? error;
|
final String? error;
|
||||||
|
final DownloadErrorType? errorType;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
final String? qualityOverride; // Override quality for this specific download
|
final String? qualityOverride; // Override quality for this specific download
|
||||||
|
|
||||||
@@ -31,8 +42,10 @@ class DownloadItem {
|
|||||||
required this.service,
|
required this.service,
|
||||||
this.status = DownloadStatus.queued,
|
this.status = DownloadStatus.queued,
|
||||||
this.progress = 0.0,
|
this.progress = 0.0,
|
||||||
|
this.speedMBps = 0.0,
|
||||||
this.filePath,
|
this.filePath,
|
||||||
this.error,
|
this.error,
|
||||||
|
this.errorType,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
this.qualityOverride,
|
this.qualityOverride,
|
||||||
});
|
});
|
||||||
@@ -43,8 +56,10 @@ class DownloadItem {
|
|||||||
String? service,
|
String? service,
|
||||||
DownloadStatus? status,
|
DownloadStatus? status,
|
||||||
double? progress,
|
double? progress,
|
||||||
|
double? speedMBps,
|
||||||
String? filePath,
|
String? filePath,
|
||||||
String? error,
|
String? error,
|
||||||
|
DownloadErrorType? errorType,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
String? qualityOverride,
|
String? qualityOverride,
|
||||||
}) {
|
}) {
|
||||||
@@ -54,13 +69,33 @@ class DownloadItem {
|
|||||||
service: service ?? this.service,
|
service: service ?? this.service,
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
progress: progress ?? this.progress,
|
progress: progress ?? this.progress,
|
||||||
|
speedMBps: speedMBps ?? this.speedMBps,
|
||||||
filePath: filePath ?? this.filePath,
|
filePath: filePath ?? this.filePath,
|
||||||
error: error ?? this.error,
|
error: error ?? this.error,
|
||||||
|
errorType: errorType ?? this.errorType,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
qualityOverride: qualityOverride ?? this.qualityOverride,
|
qualityOverride: qualityOverride ?? this.qualityOverride,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get user-friendly error message based on error type
|
||||||
|
String get errorMessage {
|
||||||
|
if (error == null) return '';
|
||||||
|
|
||||||
|
switch (errorType) {
|
||||||
|
case DownloadErrorType.notFound:
|
||||||
|
return 'Song not found on any service';
|
||||||
|
case DownloadErrorType.rateLimit:
|
||||||
|
return 'Rate limit reached, try again later';
|
||||||
|
case DownloadErrorType.network:
|
||||||
|
return 'Connection failed, check your internet';
|
||||||
|
case DownloadErrorType.permission:
|
||||||
|
return 'Cannot write to folder, check storage permission';
|
||||||
|
default:
|
||||||
|
return error ?? 'An error occurred';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
factory DownloadItem.fromJson(Map<String, dynamic> json) =>
|
factory DownloadItem.fromJson(Map<String, dynamic> json) =>
|
||||||
_$DownloadItemFromJson(json);
|
_$DownloadItemFromJson(json);
|
||||||
Map<String, dynamic> toJson() => _$DownloadItemToJson(this);
|
Map<String, dynamic> toJson() => _$DownloadItemToJson(this);
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
|
|||||||
$enumDecodeNullable(_$DownloadStatusEnumMap, json['status']) ??
|
$enumDecodeNullable(_$DownloadStatusEnumMap, json['status']) ??
|
||||||
DownloadStatus.queued,
|
DownloadStatus.queued,
|
||||||
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
|
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0,
|
||||||
filePath: json['filePath'] as String?,
|
filePath: json['filePath'] as String?,
|
||||||
error: json['error'] as String?,
|
error: json['error'] as String?,
|
||||||
|
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
|
||||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
qualityOverride: json['qualityOverride'] as String?,
|
qualityOverride: json['qualityOverride'] as String?,
|
||||||
);
|
);
|
||||||
@@ -27,8 +29,10 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
|||||||
'service': instance.service,
|
'service': instance.service,
|
||||||
'status': _$DownloadStatusEnumMap[instance.status]!,
|
'status': _$DownloadStatusEnumMap[instance.status]!,
|
||||||
'progress': instance.progress,
|
'progress': instance.progress,
|
||||||
|
'speedMBps': instance.speedMBps,
|
||||||
'filePath': instance.filePath,
|
'filePath': instance.filePath,
|
||||||
'error': instance.error,
|
'error': instance.error,
|
||||||
|
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
|
||||||
'createdAt': instance.createdAt.toIso8601String(),
|
'createdAt': instance.createdAt.toIso8601String(),
|
||||||
'qualityOverride': instance.qualityOverride,
|
'qualityOverride': instance.qualityOverride,
|
||||||
};
|
};
|
||||||
@@ -41,3 +45,11 @@ const _$DownloadStatusEnumMap = {
|
|||||||
DownloadStatus.failed: 'failed',
|
DownloadStatus.failed: 'failed',
|
||||||
DownloadStatus.skipped: 'skipped',
|
DownloadStatus.skipped: 'skipped',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _$DownloadErrorTypeEnumMap = {
|
||||||
|
DownloadErrorType.unknown: 'unknown',
|
||||||
|
DownloadErrorType.notFound: 'notFound',
|
||||||
|
DownloadErrorType.rateLimit: 'rateLimit',
|
||||||
|
DownloadErrorType.network: 'network',
|
||||||
|
DownloadErrorType.permission: 'permission',
|
||||||
|
};
|
||||||
|
|||||||
@@ -14,13 +14,22 @@ class AppSettings {
|
|||||||
final bool isFirstLaunch;
|
final bool isFirstLaunch;
|
||||||
final int concurrentDownloads; // 1 = sequential (default), max 3
|
final int concurrentDownloads; // 1 = sequential (default), max 3
|
||||||
final bool checkForUpdates; // Check for updates on app start
|
final bool checkForUpdates; // Check for updates on app start
|
||||||
|
final String updateChannel; // stable, preview
|
||||||
final bool hasSearchedBefore; // Hide helper text after first search
|
final bool hasSearchedBefore; // Hide helper text after first search
|
||||||
final String folderOrganization; // none, artist, album, artist_album
|
final String folderOrganization; // none, artist, album, artist_album
|
||||||
final String historyViewMode; // list, grid
|
final String historyViewMode; // list, grid
|
||||||
|
final String historyFilterMode; // all, albums, singles
|
||||||
final bool askQualityBeforeDownload; // Show quality picker before each download
|
final bool askQualityBeforeDownload; // Show quality picker before each download
|
||||||
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
|
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
|
||||||
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
|
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
|
||||||
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
|
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
|
||||||
|
final String metadataSource; // spotify, deezer - source for search and metadata
|
||||||
|
final bool enableLogging; // Enable detailed logging for debugging
|
||||||
|
final bool useExtensionProviders; // Use extension providers for downloads when available
|
||||||
|
final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID
|
||||||
|
final bool separateSingles; // Separate singles/EPs into their own folder
|
||||||
|
final String albumFolderStructure; // artist_album or album_only
|
||||||
|
final bool showExtensionStore; // Show Extension Store tab in navigation
|
||||||
|
|
||||||
const AppSettings({
|
const AppSettings({
|
||||||
this.defaultService = 'tidal',
|
this.defaultService = 'tidal',
|
||||||
@@ -33,13 +42,22 @@ class AppSettings {
|
|||||||
this.isFirstLaunch = true,
|
this.isFirstLaunch = true,
|
||||||
this.concurrentDownloads = 1, // Default: sequential (off)
|
this.concurrentDownloads = 1, // Default: sequential (off)
|
||||||
this.checkForUpdates = true, // Default: enabled
|
this.checkForUpdates = true, // Default: enabled
|
||||||
|
this.updateChannel = 'stable', // Default: stable releases only
|
||||||
this.hasSearchedBefore = false, // Default: show helper text
|
this.hasSearchedBefore = false, // Default: show helper text
|
||||||
this.folderOrganization = 'none', // Default: no folder organization
|
this.folderOrganization = 'none', // Default: no folder organization
|
||||||
this.historyViewMode = 'grid', // Default: grid view
|
this.historyViewMode = 'grid', // Default: grid view
|
||||||
|
this.historyFilterMode = 'all', // Default: show all
|
||||||
this.askQualityBeforeDownload = true, // Default: ask quality before download
|
this.askQualityBeforeDownload = true, // Default: ask quality before download
|
||||||
this.spotifyClientId = '', // Default: use built-in credentials
|
this.spotifyClientId = '', // Default: use built-in credentials
|
||||||
this.spotifyClientSecret = '', // Default: use built-in credentials
|
this.spotifyClientSecret = '', // Default: use built-in credentials
|
||||||
this.useCustomSpotifyCredentials = true, // Default: use custom if set
|
this.useCustomSpotifyCredentials = true, // Default: use custom if set
|
||||||
|
this.metadataSource = 'deezer', // Default: Deezer (no rate limit)
|
||||||
|
this.enableLogging = false, // Default: disabled for performance
|
||||||
|
this.useExtensionProviders = true, // Default: use extensions when available
|
||||||
|
this.searchProvider, // Default: null (use Deezer/Spotify)
|
||||||
|
this.separateSingles = false, // Default: disabled
|
||||||
|
this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album
|
||||||
|
this.showExtensionStore = true, // Default: show store
|
||||||
});
|
});
|
||||||
|
|
||||||
AppSettings copyWith({
|
AppSettings copyWith({
|
||||||
@@ -53,13 +71,23 @@ class AppSettings {
|
|||||||
bool? isFirstLaunch,
|
bool? isFirstLaunch,
|
||||||
int? concurrentDownloads,
|
int? concurrentDownloads,
|
||||||
bool? checkForUpdates,
|
bool? checkForUpdates,
|
||||||
|
String? updateChannel,
|
||||||
bool? hasSearchedBefore,
|
bool? hasSearchedBefore,
|
||||||
String? folderOrganization,
|
String? folderOrganization,
|
||||||
String? historyViewMode,
|
String? historyViewMode,
|
||||||
|
String? historyFilterMode,
|
||||||
bool? askQualityBeforeDownload,
|
bool? askQualityBeforeDownload,
|
||||||
String? spotifyClientId,
|
String? spotifyClientId,
|
||||||
String? spotifyClientSecret,
|
String? spotifyClientSecret,
|
||||||
bool? useCustomSpotifyCredentials,
|
bool? useCustomSpotifyCredentials,
|
||||||
|
String? metadataSource,
|
||||||
|
bool? enableLogging,
|
||||||
|
bool? useExtensionProviders,
|
||||||
|
String? searchProvider,
|
||||||
|
bool clearSearchProvider = false, // Set to true to clear searchProvider to null
|
||||||
|
bool? separateSingles,
|
||||||
|
String? albumFolderStructure,
|
||||||
|
bool? showExtensionStore,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
defaultService: defaultService ?? this.defaultService,
|
defaultService: defaultService ?? this.defaultService,
|
||||||
@@ -72,13 +100,22 @@ class AppSettings {
|
|||||||
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
||||||
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
|
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
|
||||||
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
|
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
|
||||||
|
updateChannel: updateChannel ?? this.updateChannel,
|
||||||
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
||||||
folderOrganization: folderOrganization ?? this.folderOrganization,
|
folderOrganization: folderOrganization ?? this.folderOrganization,
|
||||||
historyViewMode: historyViewMode ?? this.historyViewMode,
|
historyViewMode: historyViewMode ?? this.historyViewMode,
|
||||||
|
historyFilterMode: historyFilterMode ?? this.historyFilterMode,
|
||||||
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
|
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
|
||||||
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
|
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
|
||||||
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
||||||
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
||||||
|
metadataSource: metadataSource ?? this.metadataSource,
|
||||||
|
enableLogging: enableLogging ?? this.enableLogging,
|
||||||
|
useExtensionProviders: useExtensionProviders ?? this.useExtensionProviders,
|
||||||
|
searchProvider: clearSearchProvider ? null : (searchProvider ?? this.searchProvider),
|
||||||
|
separateSingles: separateSingles ?? this.separateSingles,
|
||||||
|
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
||||||
|
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,14 +17,23 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
||||||
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
|
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
|
||||||
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
|
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
|
||||||
|
updateChannel: json['updateChannel'] as String? ?? 'stable',
|
||||||
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
||||||
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
||||||
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
||||||
|
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
|
||||||
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
||||||
spotifyClientId: json['spotifyClientId'] as String? ?? '',
|
spotifyClientId: json['spotifyClientId'] as String? ?? '',
|
||||||
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
|
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
|
||||||
useCustomSpotifyCredentials:
|
useCustomSpotifyCredentials:
|
||||||
json['useCustomSpotifyCredentials'] as bool? ?? true,
|
json['useCustomSpotifyCredentials'] as bool? ?? true,
|
||||||
|
metadataSource: json['metadataSource'] as String? ?? 'deezer',
|
||||||
|
enableLogging: json['enableLogging'] as bool? ?? false,
|
||||||
|
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
|
||||||
|
searchProvider: json['searchProvider'] as String?,
|
||||||
|
separateSingles: json['separateSingles'] as bool? ?? false,
|
||||||
|
albumFolderStructure: json['albumFolderStructure'] as String? ?? 'artist_album',
|
||||||
|
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||||
@@ -39,11 +48,20 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
|||||||
'isFirstLaunch': instance.isFirstLaunch,
|
'isFirstLaunch': instance.isFirstLaunch,
|
||||||
'concurrentDownloads': instance.concurrentDownloads,
|
'concurrentDownloads': instance.concurrentDownloads,
|
||||||
'checkForUpdates': instance.checkForUpdates,
|
'checkForUpdates': instance.checkForUpdates,
|
||||||
|
'updateChannel': instance.updateChannel,
|
||||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||||
'folderOrganization': instance.folderOrganization,
|
'folderOrganization': instance.folderOrganization,
|
||||||
'historyViewMode': instance.historyViewMode,
|
'historyViewMode': instance.historyViewMode,
|
||||||
|
'historyFilterMode': instance.historyFilterMode,
|
||||||
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
||||||
'spotifyClientId': instance.spotifyClientId,
|
'spotifyClientId': instance.spotifyClientId,
|
||||||
'spotifyClientSecret': instance.spotifyClientSecret,
|
'spotifyClientSecret': instance.spotifyClientSecret,
|
||||||
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
||||||
|
'metadataSource': instance.metadataSource,
|
||||||
|
'enableLogging': instance.enableLogging,
|
||||||
|
'useExtensionProviders': instance.useExtensionProviders,
|
||||||
|
'searchProvider': instance.searchProvider,
|
||||||
|
'separateSingles': instance.separateSingles,
|
||||||
|
'albumFolderStructure': instance.albumFolderStructure,
|
||||||
|
'showExtensionStore': instance.showExtensionStore,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
const String kThemeModeKey = 'theme_mode';
|
const String kThemeModeKey = 'theme_mode';
|
||||||
const String kUseDynamicColorKey = 'use_dynamic_color';
|
const String kUseDynamicColorKey = 'use_dynamic_color';
|
||||||
const String kSeedColorKey = 'seed_color';
|
const String kSeedColorKey = 'seed_color';
|
||||||
|
const String kUseAmoledKey = 'use_amoled';
|
||||||
|
|
||||||
/// Default Spotify green color for fallback
|
/// Default Spotify green color for fallback
|
||||||
const int kDefaultSeedColor = 0xFF1DB954;
|
const int kDefaultSeedColor = 0xFF1DB954;
|
||||||
@@ -13,11 +14,13 @@ class ThemeSettings {
|
|||||||
final ThemeMode themeMode;
|
final ThemeMode themeMode;
|
||||||
final bool useDynamicColor;
|
final bool useDynamicColor;
|
||||||
final int seedColorValue;
|
final int seedColorValue;
|
||||||
|
final bool useAmoled; // Pure black background for OLED screens
|
||||||
|
|
||||||
const ThemeSettings({
|
const ThemeSettings({
|
||||||
this.themeMode = ThemeMode.system,
|
this.themeMode = ThemeMode.system,
|
||||||
this.useDynamicColor = true,
|
this.useDynamicColor = true,
|
||||||
this.seedColorValue = kDefaultSeedColor,
|
this.seedColorValue = kDefaultSeedColor,
|
||||||
|
this.useAmoled = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Get seed color as Color object
|
/// Get seed color as Color object
|
||||||
@@ -28,11 +31,13 @@ class ThemeSettings {
|
|||||||
ThemeMode? themeMode,
|
ThemeMode? themeMode,
|
||||||
bool? useDynamicColor,
|
bool? useDynamicColor,
|
||||||
int? seedColorValue,
|
int? seedColorValue,
|
||||||
|
bool? useAmoled,
|
||||||
}) {
|
}) {
|
||||||
return ThemeSettings(
|
return ThemeSettings(
|
||||||
themeMode: themeMode ?? this.themeMode,
|
themeMode: themeMode ?? this.themeMode,
|
||||||
useDynamicColor: useDynamicColor ?? this.useDynamicColor,
|
useDynamicColor: useDynamicColor ?? this.useDynamicColor,
|
||||||
seedColorValue: seedColorValue ?? this.seedColorValue,
|
seedColorValue: seedColorValue ?? this.seedColorValue,
|
||||||
|
useAmoled: useAmoled ?? this.useAmoled,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +46,7 @@ class ThemeSettings {
|
|||||||
kThemeModeKey: themeMode.name,
|
kThemeModeKey: themeMode.name,
|
||||||
kUseDynamicColorKey: useDynamicColor,
|
kUseDynamicColorKey: useDynamicColor,
|
||||||
kSeedColorKey: seedColorValue,
|
kSeedColorKey: seedColorValue,
|
||||||
|
kUseAmoledKey: useAmoled,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Create from JSON map
|
/// Create from JSON map
|
||||||
@@ -49,6 +55,7 @@ class ThemeSettings {
|
|||||||
themeMode: _themeModeFromString(json[kThemeModeKey] as String?),
|
themeMode: _themeModeFromString(json[kThemeModeKey] as String?),
|
||||||
useDynamicColor: json[kUseDynamicColorKey] as bool? ?? true,
|
useDynamicColor: json[kUseDynamicColorKey] as bool? ?? true,
|
||||||
seedColorValue: json[kSeedColorKey] as int? ?? kDefaultSeedColor,
|
seedColorValue: json[kSeedColorKey] as int? ?? kDefaultSeedColor,
|
||||||
|
useAmoled: json[kUseAmoledKey] as bool? ?? false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,12 +65,13 @@ class ThemeSettings {
|
|||||||
return other is ThemeSettings &&
|
return other is ThemeSettings &&
|
||||||
other.themeMode == themeMode &&
|
other.themeMode == themeMode &&
|
||||||
other.useDynamicColor == useDynamicColor &&
|
other.useDynamicColor == useDynamicColor &&
|
||||||
other.seedColorValue == seedColorValue;
|
other.seedColorValue == seedColorValue &&
|
||||||
|
other.useAmoled == useAmoled;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode;
|
themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode ^ useAmoled.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper to convert string to ThemeMode
|
/// Helper to convert string to ThemeMode
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ class Track {
|
|||||||
final int? trackNumber;
|
final int? trackNumber;
|
||||||
final int? discNumber;
|
final int? discNumber;
|
||||||
final String? releaseDate;
|
final String? releaseDate;
|
||||||
|
final String? deezerId;
|
||||||
final ServiceAvailability? availability;
|
final ServiceAvailability? availability;
|
||||||
|
final String? source; // Extension ID that provided this track (null for built-in sources)
|
||||||
|
final String? albumType; // album, single, ep, compilation (from metadata API)
|
||||||
|
|
||||||
const Track({
|
const Track({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -30,11 +33,20 @@ class Track {
|
|||||||
this.trackNumber,
|
this.trackNumber,
|
||||||
this.discNumber,
|
this.discNumber,
|
||||||
this.releaseDate,
|
this.releaseDate,
|
||||||
|
this.deezerId,
|
||||||
this.availability,
|
this.availability,
|
||||||
|
this.source,
|
||||||
|
this.albumType,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Check if this track is a single (based on album_type metadata)
|
||||||
|
bool get isSingle => albumType == 'single' || albumType == 'ep';
|
||||||
|
|
||||||
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
|
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
|
||||||
Map<String, dynamic> toJson() => _$TrackToJson(this);
|
Map<String, dynamic> toJson() => _$TrackToJson(this);
|
||||||
|
|
||||||
|
/// Check if this track is from an extension
|
||||||
|
bool get isFromExtension => source != null && source!.isNotEmpty;
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
@@ -42,17 +54,23 @@ class ServiceAvailability {
|
|||||||
final bool tidal;
|
final bool tidal;
|
||||||
final bool qobuz;
|
final bool qobuz;
|
||||||
final bool amazon;
|
final bool amazon;
|
||||||
|
final bool deezer;
|
||||||
final String? tidalUrl;
|
final String? tidalUrl;
|
||||||
final String? qobuzUrl;
|
final String? qobuzUrl;
|
||||||
final String? amazonUrl;
|
final String? amazonUrl;
|
||||||
|
final String? deezerUrl;
|
||||||
|
final String? deezerId;
|
||||||
|
|
||||||
const ServiceAvailability({
|
const ServiceAvailability({
|
||||||
this.tidal = false,
|
this.tidal = false,
|
||||||
this.qobuz = false,
|
this.qobuz = false,
|
||||||
this.amazon = false,
|
this.amazon = false,
|
||||||
|
this.deezer = false,
|
||||||
this.tidalUrl,
|
this.tidalUrl,
|
||||||
this.qobuzUrl,
|
this.qobuzUrl,
|
||||||
this.amazonUrl,
|
this.amazonUrl,
|
||||||
|
this.deezerUrl,
|
||||||
|
this.deezerId,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory ServiceAvailability.fromJson(Map<String, dynamic> json) =>
|
factory ServiceAvailability.fromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
@@ -18,11 +18,14 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
|||||||
trackNumber: (json['trackNumber'] as num?)?.toInt(),
|
trackNumber: (json['trackNumber'] as num?)?.toInt(),
|
||||||
discNumber: (json['discNumber'] as num?)?.toInt(),
|
discNumber: (json['discNumber'] as num?)?.toInt(),
|
||||||
releaseDate: json['releaseDate'] as String?,
|
releaseDate: json['releaseDate'] as String?,
|
||||||
|
deezerId: json['deezerId'] as String?,
|
||||||
availability: json['availability'] == null
|
availability: json['availability'] == null
|
||||||
? null
|
? null
|
||||||
: ServiceAvailability.fromJson(
|
: ServiceAvailability.fromJson(
|
||||||
json['availability'] as Map<String, dynamic>,
|
json['availability'] as Map<String, dynamic>,
|
||||||
),
|
),
|
||||||
|
source: json['source'] as String?,
|
||||||
|
albumType: json['albumType'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||||
@@ -37,7 +40,10 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
|||||||
'trackNumber': instance.trackNumber,
|
'trackNumber': instance.trackNumber,
|
||||||
'discNumber': instance.discNumber,
|
'discNumber': instance.discNumber,
|
||||||
'releaseDate': instance.releaseDate,
|
'releaseDate': instance.releaseDate,
|
||||||
|
'deezerId': instance.deezerId,
|
||||||
'availability': instance.availability,
|
'availability': instance.availability,
|
||||||
|
'source': instance.source,
|
||||||
|
'albumType': instance.albumType,
|
||||||
};
|
};
|
||||||
|
|
||||||
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
|
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
|
||||||
@@ -45,9 +51,12 @@ ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
|
|||||||
tidal: json['tidal'] as bool? ?? false,
|
tidal: json['tidal'] as bool? ?? false,
|
||||||
qobuz: json['qobuz'] as bool? ?? false,
|
qobuz: json['qobuz'] as bool? ?? false,
|
||||||
amazon: json['amazon'] as bool? ?? false,
|
amazon: json['amazon'] as bool? ?? false,
|
||||||
|
deezer: json['deezer'] as bool? ?? false,
|
||||||
tidalUrl: json['tidalUrl'] as String?,
|
tidalUrl: json['tidalUrl'] as String?,
|
||||||
qobuzUrl: json['qobuzUrl'] as String?,
|
qobuzUrl: json['qobuzUrl'] as String?,
|
||||||
amazonUrl: json['amazonUrl'] as String?,
|
amazonUrl: json['amazonUrl'] as String?,
|
||||||
|
deezerUrl: json['deezerUrl'] as String?,
|
||||||
|
deezerId: json['deezerId'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$ServiceAvailabilityToJson(
|
Map<String, dynamic> _$ServiceAvailabilityToJson(
|
||||||
@@ -56,7 +65,10 @@ Map<String, dynamic> _$ServiceAvailabilityToJson(
|
|||||||
'tidal': instance.tidal,
|
'tidal': instance.tidal,
|
||||||
'qobuz': instance.qobuz,
|
'qobuz': instance.qobuz,
|
||||||
'amazon': instance.amazon,
|
'amazon': instance.amazon,
|
||||||
|
'deezer': instance.deezer,
|
||||||
'tidalUrl': instance.tidalUrl,
|
'tidalUrl': instance.tidalUrl,
|
||||||
'qobuzUrl': instance.qobuzUrl,
|
'qobuzUrl': instance.qobuzUrl,
|
||||||
'amazonUrl': instance.amazonUrl,
|
'amazonUrl': instance.amazonUrl,
|
||||||
|
'deezerUrl': instance.deezerUrl,
|
||||||
|
'deezerId': instance.deezerId,
|
||||||
};
|
};
|
||||||
|
|||||||