Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9088455c3 |
@@ -1,20 +0,0 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
# Windows scripts
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
*.ps1 text eol=crlf
|
||||
|
||||
# Binary files
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.webp binary
|
||||
*.ico binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
*.jar binary
|
||||
*.aar binary
|
||||
*.keystore binary
|
||||
*.jks binary
|
||||
@@ -1,3 +0,0 @@
|
||||
github: zarzet
|
||||
ko_fi: zarzet
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
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...
|
||||
@@ -1,8 +0,0 @@
|
||||
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://spotiflac.zarz.moe/docs
|
||||
about: Documentation for building SpotiFLAC extensions
|
||||
@@ -1,111 +0,0 @@
|
||||
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...
|
||||
@@ -1,117 +0,0 @@
|
||||
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: ..."
|
||||
@@ -1,66 +0,0 @@
|
||||
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...
|
||||
@@ -1,27 +0,0 @@
|
||||
## Description
|
||||
|
||||
<!-- What does this PR do? Why is it needed? -->
|
||||
|
||||
## Related Issues
|
||||
|
||||
<!-- Link related issues, e.g. "Fixes #123" -->
|
||||
|
||||
## Type of Change
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Refactor (no functional change)
|
||||
- [ ] Documentation
|
||||
- [ ] Other (describe below)
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] `flutter analyze` and `flutter test` pass
|
||||
- [ ] `go vet ./...` and `go test ./...` pass in `go_backend/` (if Go code changed)
|
||||
- [ ] Documentation updated (if needed)
|
||||
- [ ] Commit messages follow the [conventional commits](https://www.conventionalcommits.org) format
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!-- For UI changes, add before/after screenshots. Delete this section otherwise. -->
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2 # Need previous commit to compare
|
||||
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Detect changed paths
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
dart: ${{ steps.filter.outputs.dart }}
|
||||
go: ${{ steps.filter.outputs.go }}
|
||||
android: ${{ steps.filter.outputs.android }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Filter paths
|
||||
uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
dart:
|
||||
- 'lib/**'
|
||||
- 'test/**'
|
||||
- 'assets/**'
|
||||
- 'pubspec.yaml'
|
||||
- 'pubspec.lock'
|
||||
- 'analysis_options.yaml'
|
||||
- 'l10n.yaml'
|
||||
- '.github/workflows/ci.yml'
|
||||
go:
|
||||
- 'go_backend/**'
|
||||
- '.github/workflows/ci.yml'
|
||||
android:
|
||||
- 'android/**'
|
||||
- 'go_backend/**'
|
||||
- 'lib/models/settings.dart'
|
||||
- 'lib/providers/download_queue_provider*.dart'
|
||||
- 'lib/services/download_request_payload.dart'
|
||||
- 'lib/services/history_database.dart'
|
||||
- 'lib/services/platform_bridge.dart'
|
||||
- 'pubspec.yaml'
|
||||
- 'pubspec.lock'
|
||||
- '.github/workflows/ci.yml'
|
||||
|
||||
flutter:
|
||||
name: Flutter analyze & test
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.dart == 'true'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
|
||||
- name: Cache pub dependencies
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.pub-cache
|
||||
key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }}
|
||||
restore-keys: pub-${{ runner.os }}-
|
||||
|
||||
- name: Get Flutter dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Analyze
|
||||
run: flutter analyze
|
||||
|
||||
- name: Run tests
|
||||
run: flutter test
|
||||
|
||||
go:
|
||||
name: Go vet & test
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: go_backend
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
# Keep in sync with release.yml
|
||||
go-version: "1.26.5"
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
- name: Check formatting
|
||||
run: test -z "$(gofmt -l .)"
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Run tests
|
||||
run: go test ./...
|
||||
|
||||
android:
|
||||
name: Android compile & native tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.android == 'true'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "25"
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.26.5"
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-${{ runner.os }}-
|
||||
|
||||
- name: Install Android SDK & NDK
|
||||
run: |
|
||||
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \
|
||||
"ndk;29.0.14206865" \
|
||||
"platforms;android-37" \
|
||||
"build-tools;37.0.0"
|
||||
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build Go backend for Android
|
||||
working-directory: go_backend
|
||||
run: |
|
||||
go install golang.org/x/mobile/cmd/gomobile
|
||||
gomobile init
|
||||
mkdir -p ../android/app/libs
|
||||
gomobile bind \
|
||||
-target=android/arm,android/arm64 \
|
||||
-androidapi 24 \
|
||||
-o ../android/app/libs/gobackend.aar \
|
||||
.
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
|
||||
- name: Get Flutter dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Configure Flutter SDK for Gradle
|
||||
run: echo "flutter.sdk=$FLUTTER_ROOT" > android/local.properties
|
||||
|
||||
- name: Compile Kotlin and run native unit tests
|
||||
run: ./android/gradlew -p android :app:compileDebugKotlin :app:testDebugUnitTest
|
||||
@@ -1,44 +0,0 @@
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'site/**'
|
||||
- '.github/workflows/pages.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: site
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -3,13 +3,13 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version tag (e.g., v1.0.0)"
|
||||
description: 'Version tag (e.g., v1.0.0)'
|
||||
required: true
|
||||
default: "v1.0.0"
|
||||
default: 'v1.0.0'
|
||||
|
||||
jobs:
|
||||
# Get version first (quick job)
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
# Check if version contains -preview, -beta, -rc, or -alpha (NOT -hotfix)
|
||||
VERSION_LOWER=$(echo "$VERSION" | tr '[:upper:]' '[:lower:]')
|
||||
if [[ "$VERSION_LOWER" == *"-preview"* ]] || [[ "$VERSION_LOWER" == *"-beta"* ]] || [[ "$VERSION_LOWER" == *"-rc"* ]] || [[ "$VERSION_LOWER" == *"-alpha"* ]]; then
|
||||
@@ -43,39 +43,40 @@ jobs:
|
||||
build-android:
|
||||
runs-on: ubuntu-latest
|
||||
needs: get-version
|
||||
|
||||
|
||||
steps:
|
||||
- name: Free disk space
|
||||
run: |
|
||||
# Remove large unused tools (~15GB total). Docker prune was dropped:
|
||||
# it took 1-2 minutes and the rm below already frees enough.
|
||||
# Remove large unused tools (~15GB total)
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo rm -rf /opt/ghc
|
||||
sudo rm -rf /opt/hostedtoolcache/CodeQL
|
||||
sudo rm -rf /usr/local/share/boost
|
||||
sudo rm -rf /usr/share/swift
|
||||
sudo rm -rf /usr/local/.ghcup
|
||||
# Clean docker images
|
||||
sudo docker image prune --all --force
|
||||
# Show available space
|
||||
df -h
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "25"
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26.5"
|
||||
go-version: '1.21'
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
# Cache Gradle for faster builds
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -83,61 +84,28 @@ jobs:
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-${{ runner.os }}-
|
||||
|
||||
- name: Cache Android NDK
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: /usr/local/lib/android/sdk/ndk/29.0.14206865
|
||||
key: ndk-29.0.14206865
|
||||
|
||||
- name: Install Android SDK & NDK
|
||||
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 r29 (supports 16KB page size for Android 15+)
|
||||
# Keep the installed platform aligned with compileSdk/targetSdk.
|
||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;29.0.14206865" "platforms;android-37" "build-tools;37.0.0"
|
||||
|
||||
# Set NDK path
|
||||
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> $GITHUB_ENV
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Install gomobile
|
||||
working-directory: go_backend
|
||||
run: |
|
||||
# Installed from inside the module so the go.mod-pinned x/mobile
|
||||
# version is used (reproducible + covered by the setup-go cache).
|
||||
go install golang.org/x/mobile/cmd/gomobile
|
||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
gomobile init
|
||||
|
||||
- name: Build Go backend for Android
|
||||
working-directory: go_backend
|
||||
run: |
|
||||
mkdir -p ../android/app/libs
|
||||
# arm/arm64 only: ndk.abiFilters in app/build.gradle.kts strips
|
||||
# every other ABI from all outputs (universal APK included), so an
|
||||
# amd64 slice would never reach a shipped APK — it only bloats the
|
||||
# aar and slows this step.
|
||||
gomobile bind -target=android/arm,android/arm64 -androidapi 24 -o ../android/app/libs/gobackend.aar .
|
||||
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
|
||||
- name: Cache pub dependencies
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.pub-cache
|
||||
key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }}
|
||||
restore-keys: pub-${{ runner.os }}-
|
||||
|
||||
- name: Get Flutter dependencies
|
||||
run: flutter pub get
|
||||
|
||||
@@ -145,14 +113,7 @@ jobs:
|
||||
run: dart run flutter_launcher_icons
|
||||
|
||||
- name: Build APK (Release - unsigned)
|
||||
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
|
||||
run: flutter build apk --release --split-per-abi
|
||||
|
||||
- name: Sign APKs
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
@@ -164,7 +125,7 @@ jobs:
|
||||
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: "36.0.0"
|
||||
BUILD_TOOLS_VERSION: "34.0.0"
|
||||
|
||||
- name: Rename APKs
|
||||
run: |
|
||||
@@ -177,50 +138,43 @@ jobs:
|
||||
ls -la
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: android-apk
|
||||
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
|
||||
|
||||
build-ios:
|
||||
runs-on: macos-15
|
||||
needs: get-version # Only depends on version, NOT android build!
|
||||
|
||||
runs-on: macos-latest
|
||||
needs: get-version # Only depends on version, NOT android build!
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Select Xcode 26.1.1
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.1.1.app
|
||||
xcodebuild -version
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26.5"
|
||||
go-version: '1.21'
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
# Cache CocoaPods
|
||||
- name: Cache CocoaPods
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ios/Pods
|
||||
key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}
|
||||
restore-keys: pods-${{ runner.os }}-
|
||||
|
||||
- name: Install gomobile
|
||||
working-directory: go_backend
|
||||
run: |
|
||||
# go.mod-pinned x/mobile version (reproducible + cached by setup-go).
|
||||
go install golang.org/x/mobile/cmd/gomobile
|
||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
gomobile init
|
||||
|
||||
- name: Build Go backend for iOS
|
||||
working-directory: go_backend
|
||||
run: |
|
||||
mkdir -p ../ios/Frameworks
|
||||
gomobile bind -target=ios -tags ios -o ../ios/Frameworks/Gobackend.xcframework .
|
||||
gomobile bind -target=ios -o ../ios/Frameworks/Gobackend.xcframework .
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
|
||||
@@ -228,117 +182,91 @@ jobs:
|
||||
run: |
|
||||
ls -la ios/Frameworks/
|
||||
ls -la ios/Frameworks/Gobackend.xcframework/ || (echo "ERROR: XCFramework not found!" && exit 1)
|
||||
|
||||
|
||||
- name: Add XCFramework to Xcode project
|
||||
run: |
|
||||
# xcodeproj usually ships with the preinstalled CocoaPods; only
|
||||
# install it when missing.
|
||||
gem list -i xcodeproj >/dev/null || sudo gem install xcodeproj
|
||||
|
||||
# Install xcodeproj gem for modifying Xcode project
|
||||
sudo gem install xcodeproj
|
||||
|
||||
# Create Ruby script to add framework
|
||||
cat > add_framework.rb << 'EOF'
|
||||
require 'xcodeproj'
|
||||
|
||||
|
||||
project_path = 'ios/Runner.xcodeproj'
|
||||
project = Xcodeproj::Project.open(project_path)
|
||||
|
||||
|
||||
# Get the main target
|
||||
target = project.targets.find { |t| t.name == 'Runner' }
|
||||
|
||||
|
||||
# Get or create Frameworks group
|
||||
frameworks_group = project.main_group.find_subpath('Frameworks', true)
|
||||
frameworks_group ||= project.main_group.new_group('Frameworks')
|
||||
|
||||
|
||||
# Add XCFramework reference
|
||||
framework_path = 'Frameworks/Gobackend.xcframework'
|
||||
framework_ref = frameworks_group.new_file(framework_path, :project)
|
||||
|
||||
|
||||
# Add to frameworks build phase
|
||||
frameworks_build_phase = target.frameworks_build_phase
|
||||
frameworks_build_phase.add_file_reference(framework_ref)
|
||||
|
||||
|
||||
# Add to embed frameworks build phase
|
||||
embed_phase = target.build_phases.find { |p| p.is_a?(Xcodeproj::Project::Object::PBXCopyFilesBuildPhase) && p.name == 'Embed Frameworks' }
|
||||
if embed_phase
|
||||
build_file = embed_phase.add_file_reference(framework_ref)
|
||||
build_file.settings = { 'ATTRIBUTES' => ['CodeSignOnCopy', 'RemoveHeadersOnCopy'] }
|
||||
end
|
||||
|
||||
|
||||
project.save
|
||||
puts "Successfully added Gobackend.xcframework to Xcode project"
|
||||
EOF
|
||||
|
||||
|
||||
ruby add_framework.rb
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
|
||||
- name: Cache pub dependencies
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.pub-cache
|
||||
key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }}
|
||||
restore-keys: pub-${{ runner.os }}-
|
||||
# Swap pubspec for iOS build (includes ffmpeg_kit_flutter)
|
||||
- name: Use iOS pubspec with FFmpeg plugin
|
||||
run: |
|
||||
cp pubspec.yaml pubspec_android_backup.yaml
|
||||
cp pubspec_ios.yaml pubspec.yaml
|
||||
echo "Swapped to iOS pubspec with ffmpeg_kit_flutter"
|
||||
|
||||
# Swap FFmpeg service for iOS
|
||||
- name: Use iOS FFmpeg service
|
||||
run: |
|
||||
cp lib/services/ffmpeg_service.dart lib/services/ffmpeg_service_android.dart
|
||||
cp build_assets/ffmpeg_service_ios.dart lib/services/ffmpeg_service.dart
|
||||
# Update class name in the swapped file
|
||||
sed -i '' 's/FFmpegServiceIOS/FFmpegService/g' lib/services/ffmpeg_service.dart
|
||||
sed -i '' 's/FFmpegResultIOS/FFmpegResult/g' lib/services/ffmpeg_service.dart
|
||||
echo "Swapped to iOS FFmpeg service"
|
||||
|
||||
- name: Get Flutter dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Normalize ffmpeg plugin shell scripts (strip CRLF)
|
||||
run: |
|
||||
find "$HOME/.pub-cache/hosted" -path "*ffmpeg_kit_flutter_new_full*/scripts/*.sh" -type f -print0 |
|
||||
while IFS= read -r -d '' f; do
|
||||
perl -pi -e 's/\r$//' "$f"
|
||||
chmod +x "$f"
|
||||
echo "Normalized line endings: $f"
|
||||
done
|
||||
|
||||
- name: Generate app icons
|
||||
run: dart run flutter_launcher_icons
|
||||
|
||||
- name: Build iOS (unsigned)
|
||||
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=""
|
||||
run: flutter build ios --release --no-codesign
|
||||
|
||||
- name: Create IPA
|
||||
run: |
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
mkdir -p build/ios/ipa
|
||||
cd ios/build/Runner.xcarchive/Products/Applications
|
||||
cd build/ios/iphoneos
|
||||
mkdir Payload
|
||||
cp -r Runner.app Payload/
|
||||
# Use absolute path to avoid relative path issues
|
||||
zip -r $GITHUB_WORKSPACE/build/ios/ipa/SpotiFLAC-${VERSION}-ios-unsigned.ipa Payload
|
||||
zip -r ../ipa/SpotiFLAC-${VERSION}-ios-unsigned.ipa 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
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: build/ios/ipa/SpotiFLAC-*.ipa
|
||||
@@ -348,36 +276,44 @@ jobs:
|
||||
needs: [get-version, build-android, build-ios]
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for git-cliff
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Generate changelog with git-cliff
|
||||
- name: Extract changelog for version
|
||||
id: changelog
|
||||
uses: orhun/git-cliff-action@v4
|
||||
with:
|
||||
config: cliff.toml
|
||||
args: --latest --strip header
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OUTPUT: /tmp/changelog.txt
|
||||
|
||||
- name: Show generated changelog
|
||||
run: |
|
||||
echo "Generated changelog:"
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
VERSION_NUM=${VERSION#v} # Remove 'v' prefix
|
||||
|
||||
echo "Looking for version: $VERSION_NUM"
|
||||
|
||||
# Extract changelog section for this version using sed
|
||||
# Find the line with version, then print until next version header or end
|
||||
CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md)
|
||||
|
||||
# If no changelog found, use default message
|
||||
if [ -z "$CHANGELOG" ]; then
|
||||
echo "No changelog found for version $VERSION_NUM"
|
||||
CHANGELOG="See CHANGELOG.md for details."
|
||||
else
|
||||
echo "Found changelog content"
|
||||
fi
|
||||
|
||||
# Save to file for multiline support
|
||||
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||
echo "Extracted changelog:"
|
||||
cat /tmp/changelog.txt
|
||||
|
||||
- name: Download Android APK
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: android-apk
|
||||
path: ./release
|
||||
|
||||
- name: Download iOS IPA
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: ./release
|
||||
@@ -385,233 +321,44 @@ jobs:
|
||||
- name: Prepare release body
|
||||
run: |
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
REPO_OWNER="${{ github.repository_owner }}"
|
||||
REPO_NAME="${{ github.event.repository.name }}"
|
||||
CURRENT_REF=$(git rev-list -n 1 "$VERSION" 2>/dev/null || git rev-parse HEAD)
|
||||
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${CURRENT_REF}^" 2>/dev/null || true)
|
||||
|
||||
# Start with git-cliff changelog, but replace its compare footer with a
|
||||
# deterministic previous-tag lookup from git.
|
||||
sed '/^## [0-9][0-9.[:alpha:]-]*$/d; /^\*\*Full Changelog\*\*/d' /tmp/changelog.txt > /tmp/release_body.txt
|
||||
|
||||
if [ -n "$PREVIOUS_TAG" ]; then
|
||||
printf '\n**Full Changelog**: [%s...%s](https://github.com/%s/%s/compare/%s...%s)\n' \
|
||||
"$PREVIOUS_TAG" "$VERSION" "$REPO_OWNER" "$REPO_NAME" "$PREVIOUS_TAG" "$VERSION" \
|
||||
>> /tmp/release_body.txt
|
||||
fi
|
||||
|
||||
# Append download section
|
||||
cat > /tmp/release_body.txt << 'HEADER'
|
||||
## SpotiFLAC $VERSION
|
||||
|
||||
Download Spotify tracks in FLAC quality from Tidal, Qobuz & Amazon Music.
|
||||
|
||||
### What's New
|
||||
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/release_body.txt << FOOTER
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
### Downloads
|
||||
|
||||
#### Android
|
||||
- **arm64**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended for modern devices)
|
||||
- **arm32**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices)
|
||||
|
||||
#### iOS
|
||||
- **Android (arm64)**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended)
|
||||
- **Android (arm32)**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices)
|
||||
- **iOS**: \`SpotiFLAC-${VERSION}-ios-unsigned.ipa\` (sideload required)
|
||||
|
||||
|
||||
### Installation
|
||||
**Android**: Enable "Install from unknown sources" and install the APK
|
||||
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
||||
FOOTER
|
||||
|
||||
|
||||
echo "Release body:"
|
||||
cat /tmp/release_body.txt
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ needs.get-version.outputs.version }}
|
||||
name: SpotiFLAC-Mobile ${{ needs.get-version.outputs.version }}
|
||||
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
||||
body_path: /tmp/release_body.txt
|
||||
files: ./release/*
|
||||
draft: false
|
||||
prerelease: ${{ needs.get-version.outputs.is_prerelease == 'true' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
update-altstore:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [get-version, build-ios, create-release]
|
||||
if: ${{ needs.get-version.outputs.is_prerelease != 'true' }}
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout main branch
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Download iOS IPA
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: ./release
|
||||
|
||||
- name: Update apps.json
|
||||
run: |
|
||||
VERSION="${{ needs.get-version.outputs.version }}"
|
||||
VERSION_NUM="${VERSION#v}"
|
||||
DATE=$(date -u +%Y-%m-%d)
|
||||
IPA_FILE=$(find ./release -name "*ios*.ipa" | head -1)
|
||||
|
||||
if [ -z "$IPA_FILE" ]; then
|
||||
echo "WARNING: IPA file not found, skipping apps.json update"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IPA_SIZE=$(stat -c%s "$IPA_FILE" 2>/dev/null || stat -f%z "$IPA_FILE")
|
||||
|
||||
if [ ! -f apps.json ]; then
|
||||
echo "WARNING: apps.json not found on main, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
jq --arg ver "$VERSION_NUM" \
|
||||
--arg date "$DATE" \
|
||||
--arg url "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa" \
|
||||
--argjson size "$IPA_SIZE" \
|
||||
'.apps[0].version = $ver | .apps[0].versionDate = $date | .apps[0].downloadURL = $url | .apps[0].size = $size' \
|
||||
apps.json > apps.json.tmp && mv apps.json.tmp apps.json
|
||||
|
||||
echo "Updated apps.json:"
|
||||
cat apps.json
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
VERSION="${{ needs.get-version.outputs.version }}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add apps.json
|
||||
git diff --cached --quiet && echo "No changes to commit" || \
|
||||
(git commit -m "chore: update AltStore source to ${VERSION}" && git push)
|
||||
|
||||
notify-telegram:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [get-version, create-release]
|
||||
if: ${{ needs.get-version.outputs.is_prerelease != 'true' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download Android APK
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: android-apk
|
||||
path: ./release
|
||||
|
||||
- name: Download iOS IPA
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: ./release
|
||||
|
||||
- name: Generate changelog with git-cliff for Telegram
|
||||
uses: orhun/git-cliff-action@v4
|
||||
with:
|
||||
config: cliff.toml
|
||||
args: --latest --strip all
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OUTPUT: /tmp/cliff_tg.txt
|
||||
|
||||
- name: Convert changelog for Telegram
|
||||
id: changelog
|
||||
run: |
|
||||
if [ ! -s /tmp/cliff_tg.txt ]; then
|
||||
echo "See release notes on GitHub for details." > /tmp/changelog.txt
|
||||
else
|
||||
# Convert Markdown to Telegram HTML
|
||||
CHANGELOG=$(cat /tmp/cliff_tg.txt | \
|
||||
sed '/^## [0-9][0-9.[:alpha:]-]*$/d' | \
|
||||
sed '/^\*\*Full Changelog\*\*/d' | \
|
||||
sed 's/ by \[@[^]]*\](https:\/\/github\.com\/[^)]*)//g' | \
|
||||
sed 's/ by @[A-Za-z0-9_-]\+//g' | \
|
||||
sed 's/\[#\([0-9]*\)\]([^)]*)/#\1/g' | \
|
||||
sed 's/\[@\([^]]*\)\]([^)]*)/@\1/g' | \
|
||||
sed 's/&/\&/g' | \
|
||||
sed 's/</\</g' | \
|
||||
sed 's/>/\>/g' | \
|
||||
sed 's/\*\*\([^*]*\)\*\*/<b>\1<\/b>/g' | \
|
||||
sed 's/^### \(.*\)$/<b>\1<\/b>/g' | \
|
||||
sed 's/^## \(.*\)$/<b>\1<\/b>/g' | \
|
||||
sed 's/^- /• /g')
|
||||
|
||||
# Truncate for Telegram 4096 char limit
|
||||
CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d')
|
||||
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||
fi
|
||||
|
||||
echo "Telegram changelog:"
|
||||
cat /tmp/changelog.txt
|
||||
|
||||
- name: Send to Telegram Channel
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHANNEL_ID: ${{ secrets.TELEGRAM_CHANNEL_ID }}
|
||||
run: |
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
CHANGELOG=$(cat /tmp/changelog.txt)
|
||||
|
||||
# Find APK files
|
||||
ARM64_APK=$(find ./release -name "*arm64*.apk" | head -1)
|
||||
ARM32_APK=$(find ./release -name "*arm32*.apk" | head -1)
|
||||
|
||||
# Prepare message with changelog (HTML format)
|
||||
printf '%s\n' \
|
||||
"<b>SpotiFLAC Mobile ${VERSION} Released!</b>" \
|
||||
"" \
|
||||
"<b>What's New:</b>" \
|
||||
"${CHANGELOG}" \
|
||||
"" \
|
||||
"<a href=\"https://github.com/${{ github.repository }}/releases/tag/${VERSION}\">View Release Notes</a>" \
|
||||
> /tmp/telegram_message.txt
|
||||
|
||||
MESSAGE=$(cat /tmp/telegram_message.txt)
|
||||
|
||||
# Send message first (using HTML parse mode)
|
||||
# Use --data-urlencode for proper encoding of special chars (+, &, etc.)
|
||||
# Use || true to ensure file uploads continue even if message fails
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||
--data-urlencode "chat_id=${TELEGRAM_CHANNEL_ID}" \
|
||||
--data-urlencode "text=${MESSAGE}" \
|
||||
--data-urlencode "parse_mode=HTML" \
|
||||
--data-urlencode "disable_web_page_preview=true" || true
|
||||
|
||||
# Upload arm64 APK to channel
|
||||
if [ -f "$ARM64_APK" ]; then
|
||||
echo "Uploading arm64 APK to Telegram..."
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||
-F document=@"${ARM64_APK}" \
|
||||
-F caption="SpotiFLAC Mobile ${VERSION} - arm64 (recommended)"
|
||||
fi
|
||||
|
||||
# Upload arm32 APK to channel
|
||||
if [ -f "$ARM32_APK" ]; then
|
||||
echo "Uploading arm32 APK to Telegram..."
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||
-F document=@"${ARM32_APK}" \
|
||||
-F caption="SpotiFLAC Mobile ${VERSION} - arm32"
|
||||
fi
|
||||
|
||||
# Upload iOS IPA to channel
|
||||
IOS_IPA=$(find ./release -name "*ios*.ipa" | head -1)
|
||||
if [ -f "$IOS_IPA" ]; then
|
||||
echo "Uploading iOS IPA to Telegram..."
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||
-F document=@"${IOS_IPA}" \
|
||||
-F caption="SpotiFLAC Mobile ${VERSION} - iOS (unsigned, sideload required)"
|
||||
fi
|
||||
|
||||
echo "Telegram notification sent!"
|
||||
|
||||
@@ -6,21 +6,13 @@ Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
.cursorignore
|
||||
.cursorrules
|
||||
|
||||
# Kiro specs (development only)
|
||||
.kiro/
|
||||
|
||||
# Design assets (banners, mockups)
|
||||
design/
|
||||
|
||||
# Reference folder (development only)
|
||||
referensi/
|
||||
|
||||
# Documentation (development only, published separately)
|
||||
docs/
|
||||
|
||||
# Old spotiflac_android folder (moved to root)
|
||||
spotiflac_android/
|
||||
|
||||
@@ -44,7 +36,6 @@ go_backend/*.xcframework/
|
||||
# Android
|
||||
android/.gradle/
|
||||
android/app/libs/gobackend.aar
|
||||
android/app/libs/gobackend-sources.jar
|
||||
android/local.properties
|
||||
android/*.iml
|
||||
android/key.properties
|
||||
@@ -58,34 +49,4 @@ ios/Pods/
|
||||
ios/.symlinks/
|
||||
ios/Flutter/Flutter.framework/
|
||||
ios/Flutter/Flutter.podspec
|
||||
|
||||
# Extension folder
|
||||
extension/*
|
||||
extension/v2/
|
||||
extension/v2/**
|
||||
|
||||
# Agent instructions
|
||||
AGENTS.md
|
||||
|
||||
# Temp/misc
|
||||
.tmp/
|
||||
nul
|
||||
NUL
|
||||
network_requests.txt
|
||||
*.bak
|
||||
/AndroidManifest.xml
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
hs_err_*.log
|
||||
flutter_*.log
|
||||
|
||||
# Development tools
|
||||
tool/
|
||||
.claude/settings.local.json
|
||||
.playwright-mcp/
|
||||
.rtk/
|
||||
CLAUDE.md
|
||||
|
||||
# FVM Version Cache
|
||||
.fvm/
|
||||
android/app/libs/gobackend-sources.jar
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, caste, color, religion, or sexual
|
||||
identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the overall
|
||||
community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or advances of
|
||||
any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email address,
|
||||
without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official email address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
**[zarzet](https://github.com/zarzet)**.
|
||||
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of
|
||||
actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or permanent
|
||||
ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the
|
||||
community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||
[https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
@@ -1,289 +0,0 @@
|
||||
# Contributing to SpotiFLAC
|
||||
|
||||
First off, thank you for considering contributing to SpotiFLAC! 🎉
|
||||
|
||||
This document provides guidelines and steps for contributing. Following these guidelines helps maintain code quality and ensures a smooth collaboration process.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [How Can I Contribute?](#how-can-i-contribute)
|
||||
- [Reporting Bugs](#reporting-bugs)
|
||||
- [Suggesting Features](#suggesting-features)
|
||||
- [Code Contributions](#code-contributions)
|
||||
- [Translations](#translations)
|
||||
- [Development Setup](#development-setup)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Coding Guidelines](#coding-guidelines)
|
||||
- [Commit Guidelines](#commit-guidelines)
|
||||
- [Pull Request Process](#pull-request-process)
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
This project and everyone participating in it is governed by our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to the project maintainers.
|
||||
|
||||
## How Can I Contribute?
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
Before creating bug reports, please check the [existing issues](https://github.com/zarzet/SpotiFLAC-Mobile/issues) to avoid duplicates.
|
||||
|
||||
When creating a bug report, please use the bug report template and include:
|
||||
|
||||
- **Clear and descriptive title**
|
||||
- **Steps to reproduce** the issue
|
||||
- **Expected behavior** vs **actual behavior**
|
||||
- **Screenshots or screen recordings** if applicable
|
||||
- **Device information** (model, OS version)
|
||||
- **App version**
|
||||
- **Logs** from Settings > About > View Logs
|
||||
|
||||
### Suggesting Features
|
||||
|
||||
Feature requests are welcome! Please use the feature request template and:
|
||||
|
||||
- **Check existing issues** to avoid duplicates
|
||||
- **Describe the feature** clearly
|
||||
- **Explain the use case** - why would this be useful?
|
||||
- **Consider the scope** - is this a small enhancement or a major feature?
|
||||
|
||||
### Code Contributions
|
||||
|
||||
1. **Fork the repository** and create your branch from `main`
|
||||
2. **Make your changes** following our coding guidelines
|
||||
3. **Test your changes** thoroughly
|
||||
4. **Submit a pull request** to the `main` branch
|
||||
|
||||
### Translations
|
||||
|
||||
We use [Crowdin](https://crowdin.com/project/spotiflac-mobile) for translations. To contribute:
|
||||
|
||||
1. Visit our [Crowdin project](https://crowdin.com/project/spotiflac-mobile)
|
||||
2. Select your language or request a new one
|
||||
3. Start translating!
|
||||
|
||||
Translation files are located in `lib/l10n/arb/`.
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Flutter SDK** 3.10.0 or higher
|
||||
- **Dart SDK** 3.10.0 or higher
|
||||
- **Android Studio** or **VS Code** with Flutter extensions
|
||||
- **Git**
|
||||
|
||||
### Getting Started
|
||||
|
||||
1. **Clone your fork**
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/SpotiFLAC-Mobile.git
|
||||
cd SpotiFLAC-Mobile
|
||||
```
|
||||
|
||||
2. **Add upstream remote**
|
||||
```bash
|
||||
git remote add upstream https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
|
||||
```
|
||||
|
||||
3. **Use FVM (Flutter Version: 3.41.5)**
|
||||
```bash
|
||||
fvm use
|
||||
```
|
||||
|
||||
4. **Install dependencies**
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
5. **Generate code** (for Riverpod, JSON serialization, etc.)
|
||||
```bash
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
6. **Set up Go environment (Go Version: 1.25.9)**
|
||||
|
||||
Building the Go backend for Android requires the **Android NDK** (r29 is what CI uses). Make sure `ANDROID_NDK_HOME` points to it and `CGO_ENABLED=1`.
|
||||
|
||||
```bash
|
||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
gomobile init
|
||||
cd go_backend
|
||||
mkdir -p ../android/app/libs
|
||||
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
|
||||
cd ..
|
||||
```
|
||||
|
||||
7. **Run the app**
|
||||
```bash
|
||||
flutter run
|
||||
```
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
# Debug build
|
||||
flutter build apk --debug
|
||||
|
||||
# Release build
|
||||
flutter build apk --release
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
lib/
|
||||
├── l10n/ # Localization files
|
||||
│ └── arb/ # ARB translation files
|
||||
├── models/ # Data models
|
||||
├── providers/ # Riverpod providers
|
||||
├── screens/ # UI screens
|
||||
│ └── settings/ # Settings sub-screens
|
||||
├── services/ # Business logic services
|
||||
├── theme/ # App theming
|
||||
├── utils/ # Utility functions
|
||||
├── widgets/ # Reusable widgets
|
||||
├── app.dart # App configuration
|
||||
└── main.dart # Entry point
|
||||
```
|
||||
|
||||
## Coding Guidelines
|
||||
|
||||
### General
|
||||
|
||||
- Follow [Effective Dart](https://dart.dev/effective-dart) guidelines
|
||||
- Use meaningful variable and function names
|
||||
- Keep functions small and focused
|
||||
- Add comments for complex logic
|
||||
|
||||
### Formatting
|
||||
|
||||
- Use `dart format` before committing
|
||||
- Maximum line length: 80 characters
|
||||
- Use trailing commas for better formatting
|
||||
|
||||
```bash
|
||||
dart format .
|
||||
```
|
||||
|
||||
### Linting
|
||||
|
||||
Ensure your code passes all lints:
|
||||
|
||||
```bash
|
||||
flutter analyze
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
We use **Riverpod** for state management, with hand-written `Notifier`s
|
||||
(no `riverpod_annotation` code generation). Follow this pattern:
|
||||
|
||||
```dart
|
||||
class MyNotifier extends Notifier<MyState> {
|
||||
@override
|
||||
MyState build() => MyState();
|
||||
|
||||
// Methods to update state
|
||||
}
|
||||
|
||||
final myProvider = NotifierProvider<MyNotifier, MyState>(MyNotifier.new);
|
||||
```
|
||||
|
||||
### Localization
|
||||
|
||||
All user-facing strings should be localized:
|
||||
|
||||
```dart
|
||||
// Good
|
||||
Text(AppLocalizations.of(context)!.downloadComplete)
|
||||
|
||||
// Bad
|
||||
Text('Download Complete')
|
||||
```
|
||||
|
||||
To add new strings:
|
||||
1. Add the key to `lib/l10n/arb/app_en.arb`
|
||||
2. Run `flutter gen-l10n`
|
||||
|
||||
## Commit Guidelines
|
||||
|
||||
We follow [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer(s)]
|
||||
```
|
||||
|
||||
### Types
|
||||
|
||||
- `feat`: New feature
|
||||
- `fix`: Bug fix
|
||||
- `docs`: Documentation changes
|
||||
- `style`: Code style changes (formatting, etc.)
|
||||
- `refactor`: Code refactoring
|
||||
- `perf`: Performance improvements
|
||||
- `test`: Adding or updating tests
|
||||
- `chore`: Maintenance tasks
|
||||
|
||||
### Examples
|
||||
|
||||
```
|
||||
feat(download): add batch download support
|
||||
fix(ui): resolve overflow on small screens
|
||||
docs: update contributing guidelines
|
||||
chore(deps): update flutter_riverpod to 3.1.0
|
||||
```
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. **Update your fork**
|
||||
```bash
|
||||
git fetch upstream
|
||||
git rebase upstream/main
|
||||
```
|
||||
|
||||
2. **Create a feature branch**
|
||||
```bash
|
||||
git checkout -b feat/my-new-feature
|
||||
```
|
||||
|
||||
3. **Make your changes** and commit following our guidelines
|
||||
|
||||
4. **Push to your fork**
|
||||
```bash
|
||||
git push origin feat/my-new-feature
|
||||
```
|
||||
|
||||
5. **Create a Pull Request**
|
||||
- Target the `main` branch
|
||||
- Fill in the PR template
|
||||
- Link related issues
|
||||
|
||||
6. **Address review feedback**
|
||||
- Make requested changes
|
||||
- Push additional commits
|
||||
- Request re-review when ready
|
||||
|
||||
### PR Requirements
|
||||
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] All tests pass
|
||||
- [ ] No new linting errors
|
||||
- [ ] Documentation updated (if needed)
|
||||
- [ ] Commit messages follow guidelines
|
||||
- [ ] PR description is clear and complete
|
||||
|
||||
CI runs `flutter analyze`, `flutter test`, `go vet`, and `go test` on every pull request — make sure they pass locally before pushing.
|
||||
|
||||
## Questions?
|
||||
|
||||
If you have questions, feel free to:
|
||||
|
||||
- Open a [Discussion](https://github.com/spotiflacapp/SpotiFLAC-Mobile/discussions)
|
||||
- Check existing [Issues](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues)
|
||||
|
||||
Thank you for contributing! 💚
|
||||
@@ -1,183 +1,43 @@
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/readme/banner-readme-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="assets/readme/banner-readme-light.png">
|
||||
<img alt="SpotiFLAC Mobile" src="assets/readme/banner-readme-light.png" width="650" height="auto">
|
||||
</picture>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/25971" target="_blank">
|
||||
<img src="https://trendshift.io/api/badge/repositories/25971" alt="spotiflacapp%2FSpotiFLAC-Mobile | Trendshift" width="250" height="55">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/31d1bf3c3b2015c13e83c4f909a7c6093a9423e3e702f0c582a3e0035c849424)
|
||||
[](https://crowdin.com/project/spotiflac-mobile)
|
||||
<img src="icon.png" width="128" />
|
||||
|
||||
[](https://t.me/spotiflac)
|
||||
[](https://t.me/spotiflac_chat)
|
||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/readme/1.jpg?v=2" width="200" />
|
||||
<img src="assets/readme/2.jpg?v=2" width="200" />
|
||||
<img src="assets/readme/3.jpg?v=2" width="200" />
|
||||
<img src="assets/readme/4.jpg?v=2" width="200" />
|
||||
<img src="assets/images/1.jpg?v=2" width="200" />
|
||||
<img src="assets/images/2.jpg?v=2" width="200" />
|
||||
<img src="assets/images/3.jpg?v=2" width="200" />
|
||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Extensions
|
||||
|
||||
Extensions let the community add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
|
||||
|
||||
### Installing Extensions
|
||||
|
||||
1. Open the **Store** tab in the app
|
||||
2. On first launch, enter an **Extension Repository URL** when prompted
|
||||
3. Browse and install extensions with one tap
|
||||
4. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
||||
5. Configure extension settings if needed
|
||||
6. Set provider priority under **Settings > Extensions > Provider Priority**
|
||||
|
||||
### Developing Extensions
|
||||
|
||||
> [!NOTE]
|
||||
> Want to build your own extension? The [Extension Development Guide](https://spotiflac.zarz.moe/docs) has everything you need.
|
||||
|
||||
---
|
||||
|
||||
## Related Projects
|
||||
## Other project
|
||||
|
||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||
Download music in true lossless FLAC from extension-provided sources on Windows, macOS & Linux.
|
||||
|
||||
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
|
||||
Python library for SpotiFLAC integration, maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu).
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
<details>
|
||||
<summary><b>Why does the Store tab ask me to enter a URL?</b></summary>
|
||||
<br>
|
||||
|
||||
Starting from version 3.8.0, SpotiFLAC uses a decentralized extension repository system extensions are hosted on GitHub repositories rather than a built-in server, so anyone can create and host their own. Enter a repository URL in the Store tab to browse and install extensions.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Why is my download failing with "Song not found"?</b></summary>
|
||||
<br>
|
||||
|
||||
The track may not be available from your enabled providers. Try enabling more providers under **Settings > Extensions > Provider Priority**, or install additional download extensions from the Store.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
|
||||
<br>
|
||||
|
||||
Quality depends on what's available from the source and the installed download extension. Check each extension's quality options and service notes in the app.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Can I download playlists?</b></summary>
|
||||
<br>
|
||||
|
||||
Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Why do I need to grant storage permission?</b></summary>
|
||||
<br>
|
||||
|
||||
The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant **All files access** under **Settings > Apps > SpotiFLAC > Permissions**.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Is this app safe?</b></summary>
|
||||
<br>
|
||||
|
||||
Yes SpotiFLAC is open source and you can verify the code yourself. Each release is also scanned with VirusTotal (see badge above).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Why is downloading not working in my country?</b></summary>
|
||||
<br>
|
||||
|
||||
Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Can I add SpotiFLAC to AltStore or SideStore?</b></summary>
|
||||
<br>
|
||||
|
||||
Yes! Add the official source to receive updates directly within the app. Copy this link:
|
||||
|
||||
```
|
||||
https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/refs/heads/main/apps.json
|
||||
```
|
||||
|
||||
In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link.
|
||||
|
||||
</details>
|
||||
|
||||
> [!NOTE]
|
||||
> If SpotiFLAC is useful to you, consider supporting development:
|
||||
>
|
||||
> [](https://ko-fi.com/zarzet)
|
||||
|
||||
---
|
||||
|
||||
## Contributors
|
||||
|
||||
Thanks to everyone who has contributed to SpotiFLAC Mobile!
|
||||
|
||||
<a href="https://github.com/zarzet/SpotiFLAC-Mobile/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=zarzet/SpotiFLAC-Mobile" />
|
||||
</a>
|
||||
|
||||
We also appreciate everyone who helped with [translations on Crowdin](https://crowdin.com/project/spotiflac-mobile), reported bugs, suggested features, and spread the word.
|
||||
|
||||
Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md) to get started!
|
||||
|
||||
---
|
||||
|
||||
## API Credits
|
||||
|
||||
| | | | | |
|
||||
|---|---|---|---|---|
|
||||
| [MusicDL](https://www.musicdl.me) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) | [Song.link](https://song.link) |
|
||||
| [IDHS](https://github.com/sjdonado/idonthavespotify) | | | | |
|
||||
|
||||
---
|
||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This repository and its contents are provided strictly for educational and research purposes. The software is provided "as-is" without warranty of any kind, express or implied, as stated in the [MIT License](LICENSE).
|
||||
> **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!
|
||||
|
||||
- No copyrighted content is hosted, stored, mirrored, or distributed by this repository.
|
||||
- Users must ensure that their use of this software is properly authorized and complies with all applicable laws, regulations, and third-party terms of service.
|
||||
- This software is provided free of charge by the maintainer. If you paid a third party for access to this software in its original form from this repository, you may have been misled or scammed. Any redistribution or commercial use by third parties must comply with the terms of the repository license. No affiliation, endorsement, or support by the maintainer is implied unless explicitly stated in writing.
|
||||
- SpotiFLAC Mobile is an independent project. It is not affiliated with, endorsed by, or connected to any other project or version on other platforms that may share a similar name. The maintainer of this repository has no control over or responsibility for third-party projects.
|
||||
- The author(s) disclaim all liability for any direct, indirect, incidental, or consequential damages arising from the use or misuse of this software. Users assume all risk associated with its use.
|
||||
- If you are a copyright holder or authorized representative and believe this repository infringes upon your rights, please contact the maintainer with sufficient detail (including relevant URLs and proof of ownership). The matter will be promptly investigated and appropriate action will be taken, which may include removal of the referenced material.
|
||||
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
||||
|
||||
> [!TIP]
|
||||
> **Star the repo** to get notified about all new releases directly from GitHub.
|
||||
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
|
||||
|
||||
You are solely responsible for:
|
||||
1. Ensuring your use of this software complies with your local laws.
|
||||
2. Reading and adhering to the Terms of Service of the respective platforms.
|
||||
3. Any legal consequences resulting from the misuse of this tool.
|
||||
|
||||
The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use.
|
||||
|
||||
@@ -9,19 +9,6 @@
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
plugins:
|
||||
riverpod_lint: 3.1.4-dev.3
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
- .dart_tool/**
|
||||
- lib/**/*.g.dart
|
||||
- lib/l10n/*.dart
|
||||
language:
|
||||
strict-casts: true
|
||||
strict-inference: true
|
||||
strict-raw-types: true
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
@@ -36,13 +23,6 @@ linter:
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
always_declare_return_types: true
|
||||
avoid_dynamic_calls: true
|
||||
avoid_types_as_parameter_names: true
|
||||
strict_top_level_inference: true
|
||||
type_annotate_public_apis: true
|
||||
cancel_subscriptions: true
|
||||
close_sinks: true
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
plugins {
|
||||
id "com.android.application"
|
||||
id "kotlin-android"
|
||||
id "dev.flutter.flutter-gradle-plugin"
|
||||
}
|
||||
|
||||
def localProperties = new Properties()
|
||||
def localPropertiesFile = rootProject.file('local.properties')
|
||||
if (localPropertiesFile.exists()) {
|
||||
localPropertiesFile.withReader('UTF-8') { reader ->
|
||||
localProperties.load(reader)
|
||||
}
|
||||
}
|
||||
|
||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||
if (flutterVersionCode == null) {
|
||||
flutterVersionCode = '1'
|
||||
}
|
||||
|
||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||
if (flutterVersionName == null) {
|
||||
flutterVersionName = '1.0'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "com.zarz.spotiflac"
|
||||
compileSdk flutter.compileSdkVersion
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.zarz.spotiflac"
|
||||
minSdkVersion flutter.minSdkVersion
|
||||
targetSdk flutter.targetSdkVersion
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.debug
|
||||
minifyEnabled false
|
||||
shrinkResources false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source '../..'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Go backend library (gomobile generated)
|
||||
implementation fileTree(dir: 'libs', include: ['*.aar'])
|
||||
|
||||
// Kotlin coroutines for async Go backend calls
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
import java.util.Properties
|
||||
import java.io.FileInputStream
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
@@ -10,39 +7,35 @@ plugins {
|
||||
|
||||
// Load keystore properties for local builds
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
val keystoreProperties = Properties()
|
||||
val keystoreProperties = java.util.Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
keystoreProperties.load(java.io.FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.zarz.spotiflac"
|
||||
compileSdk = 37
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_25
|
||||
targetCompatibility = JavaVersion.VERSION_25
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
create("release") {
|
||||
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||
storeFile = file(keystoreProperties.getProperty("storeFile"))
|
||||
storePassword = keystoreProperties.getProperty("storePassword")
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,7 +43,7 @@ android {
|
||||
defaultConfig {
|
||||
applicationId = "com.zarz.spotiflac"
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = 37
|
||||
targetSdk = 34
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
multiDexEnabled = true
|
||||
@@ -61,20 +54,6 @@ android {
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-debug"
|
||||
ndk {
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
}
|
||||
|
||||
getByName("profile") {
|
||||
ndk {
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
}
|
||||
|
||||
release {
|
||||
// For local builds: use release signing if key.properties exists
|
||||
// For CI builds: APK is signed by GitHub Action after build
|
||||
@@ -89,9 +68,6 @@ android {
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
ndk {
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,19 +93,9 @@ repositories {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||
|
||||
// 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.11.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0")
|
||||
implementation("androidx.documentfile:documentfile:1.1.0")
|
||||
implementation("androidx.activity:activity-ktx:1.13.0")
|
||||
|
||||
// NativeDownloadFinalizer imports FFmpegKit APIs directly. The Flutter
|
||||
// plugin owns the runtime AAR; compileOnly avoids packaging it twice here.
|
||||
compileOnly("com.antonkarpenko:ffmpeg-kit-full:2.2.1")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
implementation(files("libs/gobackend.aar"))
|
||||
implementation(files("libs/ffmpeg-kit-with-lame.aar"))
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||
}
|
||||
|
||||
@@ -5,112 +5,29 @@
|
||||
-keep class io.flutter.view.** { *; }
|
||||
-keep class io.flutter.** { *; }
|
||||
-keep class io.flutter.plugins.** { *; }
|
||||
-keep class io.flutter.embedding.** { *; }
|
||||
|
||||
# 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) - CRITICAL for release builds
|
||||
# Go backend (gobackend.aar)
|
||||
-keep class gobackend.** { *; }
|
||||
-keep class go.** { *; }
|
||||
-keep interface gobackend.** { *; }
|
||||
-keepclassmembers class gobackend.** { *; }
|
||||
|
||||
# Go mobile binding internals
|
||||
-keep class org.golang.** { *; }
|
||||
-dontwarn org.golang.**
|
||||
|
||||
# FFmpeg Kit
|
||||
-keep class com.arthenica.ffmpegkit.** { *; }
|
||||
-keep class com.arthenica.smartexception.** { *; }
|
||||
# FFmpeg Kit (new fork package)
|
||||
-keep class com.antonkarpenko.ffmpegkit.** { *; }
|
||||
-keep class com.antonkarpenko.smartexception.** { *; }
|
||||
|
||||
# Apache Tika (if used by FFmpeg)
|
||||
-dontwarn org.apache.tika.**
|
||||
|
||||
# Keep native methods
|
||||
-keepclasseswithmembernames class * {
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
# Kotlin coroutines - expanded rules
|
||||
# Kotlin coroutines
|
||||
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
||||
-keepclassmembers class kotlinx.coroutines.** {
|
||||
volatile <fields>;
|
||||
}
|
||||
-keepclassmembernames class kotlinx.** {
|
||||
volatile <fields>;
|
||||
}
|
||||
-dontwarn kotlinx.coroutines.**
|
||||
|
||||
# Kotlin serialization
|
||||
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault
|
||||
-dontwarn kotlin.**
|
||||
-keep class kotlin.** { *; }
|
||||
-keep class kotlin.Metadata { *; }
|
||||
|
||||
# Keep MainActivity and related classes
|
||||
-keep class com.zarz.spotiflac.** { *; }
|
||||
|
||||
# Prevent R8 from removing metadata
|
||||
-keepattributes *Annotation*
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
-keepattributes Signature
|
||||
-keepattributes Exceptions
|
||||
-keepattributes InnerClasses
|
||||
-keepattributes EnclosingMethod
|
||||
|
||||
# JSON parsing (used by Go backend responses)
|
||||
-keep class org.json.** { *; }
|
||||
|
||||
# Shared Preferences
|
||||
-keep class androidx.datastore.** { *; }
|
||||
-dontwarn androidx.datastore.**
|
||||
|
||||
# Flutter Plugins - CRITICAL: Prevent R8 from removing plugin implementations
|
||||
# Path Provider
|
||||
-keep class io.flutter.plugins.pathprovider.** { *; }
|
||||
-keep class dev.flutter.pigeon.** { *; }
|
||||
|
||||
# Local Notifications
|
||||
-keep class com.dexterous.** { *; }
|
||||
-keep class com.dexterous.flutterlocalnotifications.** { *; }
|
||||
|
||||
# Receive Sharing Intent
|
||||
-keep class com.kasem.receive_sharing_intent.** { *; }
|
||||
|
||||
# Permission Handler
|
||||
-keep class com.baseflow.permissionhandler.** { *; }
|
||||
|
||||
# File Picker
|
||||
-keep class com.mr.flutter.plugin.filepicker.** { *; }
|
||||
|
||||
# URL Launcher
|
||||
-keep class io.flutter.plugins.urllauncher.** { *; }
|
||||
|
||||
# Share Plus
|
||||
-keep class dev.fluttercommunity.plus.share.** { *; }
|
||||
|
||||
# Device Info Plus
|
||||
-keep class dev.fluttercommunity.plus.device_info.** { *; }
|
||||
|
||||
# Open File
|
||||
-keep class com.crazecoder.openfile.** { *; }
|
||||
|
||||
# Sqflite
|
||||
-keep class com.tekartik.sqflite.** { *; }
|
||||
|
||||
# Dynamic Color
|
||||
-keep class io.material.** { *; }
|
||||
|
||||
# Keep all Flutter plugin registrants
|
||||
-keep class io.flutter.plugins.GeneratedPluginRegistrant { *; }
|
||||
-keep class ** extends io.flutter.embedding.engine.plugins.FlutterPlugin { *; }
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
<!-- Permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="29" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
@@ -13,22 +12,17 @@
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application
|
||||
android:label="SpotiFLAC Mobile"
|
||||
android:label="SpotiFLAC"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:allowBackup="false"
|
||||
android:fullBackupContent="@xml/backup_rules_legacy"
|
||||
android:dataExtractionRules="@xml/backup_rules"
|
||||
android:usesCleartextTraffic="false"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:localeConfig="@xml/locale_config">
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:enableOnBackInvokedCallback="true">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
@@ -49,7 +43,7 @@
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
|
||||
<!-- Handle music URL sharing (Spotify, Deezer, Tidal, YT Music) -->
|
||||
<!-- Handle Spotify URL sharing -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
@@ -63,53 +57,6 @@
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="open.spotify.com" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Handle Deezer deep links -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="www.deezer.com" />
|
||||
<data android:scheme="https" android:host="deezer.com" />
|
||||
<data android:scheme="https" android:host="deezer.page.link" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Handle Tidal deep links -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="tidal.com" />
|
||||
<data android:scheme="https" android:host="listen.tidal.com" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Handle YouTube Music deep links -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="music.youtube.com" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Extension OAuth (PKCE) redirect: spotiflac://callback?code=...&state=<extension_id> -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="spotiflac" android:host="callback" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="spotiflac" android:host="spotify-callback" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="spotiflac" android:host="session-grant" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Download Service -->
|
||||
@@ -118,23 +65,6 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<service
|
||||
android:name="com.ryanheise.audioservice.AudioService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="true"
|
||||
android:enabled="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<receiver
|
||||
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- flutter_local_notifications receivers -->
|
||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
|
||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
|
||||
@@ -151,10 +81,6 @@
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc" />
|
||||
|
||||
<!-- FileProvider for APK installation -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.example.temp_project
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@@ -1,71 +0,0 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
internal object NativeWorkerPolicy {
|
||||
const val MAX_RATE_LIMIT_RETRIES = 1
|
||||
private const val DEFAULT_RATE_LIMIT_DELAY_SECONDS = 30
|
||||
private const val MIN_RATE_LIMIT_DELAY_SECONDS = 5
|
||||
private const val MAX_RATE_LIMIT_DELAY_SECONDS = 300
|
||||
|
||||
private val retryAfterPattern = Regex(
|
||||
"""retry[- ]?after(?: seconds)?[:= ]+(\d+)""",
|
||||
RegexOption.IGNORE_CASE,
|
||||
)
|
||||
|
||||
fun shouldRetryRateLimit(
|
||||
errorType: String?,
|
||||
errorMessage: String?,
|
||||
attempts: Int,
|
||||
): Boolean {
|
||||
if (attempts >= MAX_RATE_LIMIT_RETRIES) return false
|
||||
if (errorType.equals("rate_limit", ignoreCase = true)) return true
|
||||
|
||||
val message = errorMessage.orEmpty()
|
||||
return message.contains("429") ||
|
||||
message.contains("rate limit", ignoreCase = true) ||
|
||||
message.contains("too many requests", ignoreCase = true)
|
||||
}
|
||||
|
||||
fun rateLimitDelaySeconds(
|
||||
retryAfterSeconds: Int?,
|
||||
errorMessage: String?,
|
||||
): Int {
|
||||
val parsedFromMessage = retryAfterPattern
|
||||
.find(errorMessage.orEmpty())
|
||||
?.groupValues
|
||||
?.getOrNull(1)
|
||||
?.toIntOrNull()
|
||||
return (retryAfterSeconds?.takeIf { it > 0 }
|
||||
?: parsedFromMessage
|
||||
?: DEFAULT_RATE_LIMIT_DELAY_SECONDS)
|
||||
.coerceIn(
|
||||
MIN_RATE_LIMIT_DELAY_SECONDS,
|
||||
MAX_RATE_LIMIT_DELAY_SECONDS,
|
||||
)
|
||||
}
|
||||
|
||||
fun isVerificationRequired(
|
||||
errorType: String?,
|
||||
errorMessage: String?,
|
||||
): Boolean {
|
||||
if (errorType.equals("verification_required", ignoreCase = true)) {
|
||||
return true
|
||||
}
|
||||
val message = errorMessage.orEmpty()
|
||||
return message.contains("verification required", ignoreCase = true) ||
|
||||
message.contains("challenge required", ignoreCase = true)
|
||||
}
|
||||
|
||||
fun requiresWifi(downloadNetworkMode: String?): Boolean =
|
||||
downloadNetworkMode.equals("wifi_only", ignoreCase = true)
|
||||
|
||||
fun shouldPauseForNetwork(
|
||||
downloadNetworkMode: String?,
|
||||
hasWifi: Boolean,
|
||||
): Boolean = requiresWifi(downloadNetworkMode) && !hasWifi
|
||||
|
||||
fun shouldNotifyQueueComplete(
|
||||
cancelRequested: Boolean,
|
||||
completed: Int,
|
||||
failed: Int,
|
||||
): Boolean = !cancelRequested && completed + failed > 0
|
||||
}
|
||||
@@ -1,715 +0,0 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Shared SAF download wrapper for foreground activity calls and service-owned
|
||||
* native workers.
|
||||
*/
|
||||
object SafDownloadHandler {
|
||||
private val safDirLock = Any()
|
||||
private const val MAX_SAF_DISPLAY_NAME_UTF8_BYTES = 180
|
||||
private const val STAGED_SAF_MIME_TYPE = "application/octet-stream"
|
||||
|
||||
// Serializes the exists-check/create/write/publish sequence per target
|
||||
// file so concurrent downloads that sanitize to the same display name
|
||||
// cannot interleave writes into one document. Different names keep
|
||||
// downloading in parallel; the second same-name caller blocks, then hits
|
||||
// the exists check and reports already_exists.
|
||||
private val safNameLocks = java.util.concurrent.ConcurrentHashMap<String, Any>()
|
||||
|
||||
data class UniqueWriteResult(val uri: String, val fileName: String)
|
||||
|
||||
private fun <T> withSafNameLock(
|
||||
treeUriStr: String,
|
||||
relativeDir: String,
|
||||
fileName: String,
|
||||
block: () -> T
|
||||
): T {
|
||||
val key = "$treeUriStr|$relativeDir|${fileName.lowercase(Locale.ROOT)}"
|
||||
val lock = safNameLocks.computeIfAbsent(key) { Any() }
|
||||
return synchronized(lock) { block() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes and fsyncs a SAF output stream so the copied bytes are durable
|
||||
* before the staged document is renamed to its final name; without this a
|
||||
* power loss right after the rename can leave a truncated "complete" file.
|
||||
*/
|
||||
fun syncOutputStream(output: OutputStream) {
|
||||
try {
|
||||
output.flush()
|
||||
(output as? FileOutputStream)?.fd?.sync()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
fun handle(context: Context, requestJson: String, downloader: (String) -> String): String {
|
||||
val req = JSONObject(requestJson)
|
||||
val storageMode = req.optString("storage_mode", "")
|
||||
val treeUriStr = req.optString("saf_tree_uri", "")
|
||||
if (storageMode != "saf" || treeUriStr.isBlank()) {
|
||||
return downloader(requestJson)
|
||||
}
|
||||
|
||||
val relativeDir = sanitizeRelativeDir(req.optString("saf_relative_dir", ""))
|
||||
val outputExt = normalizeExt(req.optString("saf_output_ext", ""))
|
||||
val fileName = buildSafFileName(req, outputExt)
|
||||
return withSafNameLock(treeUriStr, relativeDir, fileName) {
|
||||
handleSafLocked(context, req, downloader, treeUriStr, relativeDir, outputExt, fileName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSafLocked(
|
||||
context: Context,
|
||||
req: JSONObject,
|
||||
downloader: (String) -> String,
|
||||
treeUriStr: String,
|
||||
relativeDir: String,
|
||||
outputExt: String,
|
||||
fileName: String
|
||||
): String {
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val mimeType = mimeTypeForExt(outputExt)
|
||||
val deferSafPublish = req.optBoolean("defer_saf_publish", false)
|
||||
// Downloads are always written under a staged ".partial" name so a
|
||||
// killed process can never leave a half-written file under the final
|
||||
// name (which the exists check would then accept as complete forever).
|
||||
// Callers that set stage_saf_output own the promotion to the final
|
||||
// name (the native finalizer); for everyone else — the foreground
|
||||
// Dart queue — this handler promotes right after a successful write.
|
||||
val finalizerPromotesStaged = req.optBoolean("stage_saf_output", false) && !deferSafPublish
|
||||
val useStagedOutput = !deferSafPublish
|
||||
val stagedFileName = if (useStagedOutput) buildStagedSafFileName(fileName) else fileName
|
||||
val stagedMimeType = if (useStagedOutput) STAGED_SAF_MIME_TYPE else mimeType
|
||||
|
||||
val existingDir = findDocumentDir(context, treeUri, relativeDir)
|
||||
if (existingDir != null) {
|
||||
val existing = existingDir.findFile(fileName)
|
||||
if (existing != null && existing.isFile && existing.length() > 0) {
|
||||
deleteStaleStagedFiles(existingDir, fileName, outputExt)
|
||||
val obj = JSONObject()
|
||||
obj.put("success", true)
|
||||
obj.put("message", "File already exists")
|
||||
obj.put("file_path", existing.uri.toString())
|
||||
obj.put("file_name", existing.name ?: fileName)
|
||||
obj.put("already_exists", true)
|
||||
return obj.toString()
|
||||
}
|
||||
}
|
||||
|
||||
val targetDir = ensureDocumentDir(context, treeUri, relativeDir)
|
||||
?: return errorJson("Failed to access SAF directory")
|
||||
|
||||
if (deferSafPublish) {
|
||||
deleteStaleStagedFiles(targetDir, fileName, outputExt)
|
||||
val workingExt = outputExt.ifBlank { ".tmp" }
|
||||
val workingFile = File.createTempFile("native_saf_work_", workingExt, context.cacheDir)
|
||||
Log.i("SpotiFLAC", "SAF deferred native output: target=$fileName working=${workingFile.name}")
|
||||
return try {
|
||||
req.put("output_path", workingFile.absolutePath)
|
||||
req.put("output_ext", outputExt)
|
||||
req.remove("output_fd")
|
||||
val response = downloader(req.toString())
|
||||
val respObj = JSONObject(response)
|
||||
if (respObj.optBoolean("success", false)) {
|
||||
val reportedPath = respObj.optString("file_path", "").trim()
|
||||
if (reportedPath.isEmpty() || reportedPath.startsWith("/proc/self/fd/")) {
|
||||
respObj.put("file_path", workingFile.absolutePath)
|
||||
} else if (reportedPath != workingFile.absolutePath) {
|
||||
workingFile.delete()
|
||||
}
|
||||
respObj.put("file_name", respObj.optString("file_name", "").ifBlank { fileName })
|
||||
respObj.put("saf_deferred_publish", true)
|
||||
respObj.put("saf_final_file_name", fileName)
|
||||
respObj.put("saf_relative_dir", relativeDir)
|
||||
respObj.put("saf_tree_uri", treeUriStr)
|
||||
respObj.put("saf_output_ext", outputExt)
|
||||
respObj.put("saf_final_mime_type", mimeType)
|
||||
} else {
|
||||
workingFile.delete()
|
||||
}
|
||||
respObj.toString()
|
||||
} catch (e: Exception) {
|
||||
workingFile.delete()
|
||||
errorJson("SAF deferred download failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Remove any stale partial from a previous killed attempt before
|
||||
// creating the staged document: reusing it would let a shorter new
|
||||
// write leave the old tail bytes in place (fd truncation is
|
||||
// best-effort on some providers).
|
||||
deleteStaleStagedFiles(targetDir, fileName, outputExt)
|
||||
var document = createOrReuseDocumentFile(targetDir, stagedMimeType, stagedFileName)
|
||||
?: return errorJson("Failed to create SAF file")
|
||||
|
||||
val pfd = context.contentResolver.openFileDescriptor(document.uri, "rw")
|
||||
?: return errorJson("Failed to open SAF file")
|
||||
|
||||
var detachedFd: Int? = null
|
||||
try {
|
||||
detachedFd = pfd.detachFd()
|
||||
req.put("output_path", "")
|
||||
req.put("output_fd", detachedFd)
|
||||
req.put("output_ext", outputExt)
|
||||
val response = downloader(req.toString())
|
||||
val respObj = JSONObject(response)
|
||||
if (respObj.optBoolean("success", false)) {
|
||||
var finalFileName = fileName
|
||||
val goFilePath = respObj.optString("file_path", "")
|
||||
if (goFilePath.isNotEmpty() &&
|
||||
!goFilePath.startsWith("content://") &&
|
||||
!goFilePath.startsWith("/proc/self/fd/")
|
||||
) {
|
||||
try {
|
||||
val srcFile = File(goFilePath)
|
||||
if (!srcFile.exists() || srcFile.length() <= 0) {
|
||||
throw IllegalStateException("extension output missing or empty: $goFilePath")
|
||||
}
|
||||
val actualExt = normalizeExt(srcFile.extension)
|
||||
if (actualExt.isNotBlank()) {
|
||||
respObj.put("actual_extension", actualExt)
|
||||
}
|
||||
if (actualExt.isNotBlank() && actualExt != outputExt) {
|
||||
val actualFileName = buildSafFileName(req, actualExt)
|
||||
val actualStagedFileName = if (useStagedOutput) {
|
||||
buildStagedSafFileName(actualFileName)
|
||||
} else {
|
||||
actualFileName
|
||||
}
|
||||
val actualMimeType = mimeTypeForExt(actualExt)
|
||||
val replacement = createOrReuseDocumentFile(
|
||||
targetDir,
|
||||
if (useStagedOutput) STAGED_SAF_MIME_TYPE else actualMimeType,
|
||||
actualStagedFileName
|
||||
) ?: throw IllegalStateException(
|
||||
"failed to create SAF output with actual extension"
|
||||
)
|
||||
if (replacement.uri != document.uri) {
|
||||
document.delete()
|
||||
document = replacement
|
||||
}
|
||||
finalFileName = actualFileName
|
||||
}
|
||||
context.contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
|
||||
srcFile.inputStream().use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
syncOutputStream(output)
|
||||
} ?: throw IllegalStateException("failed to open SAF output stream")
|
||||
srcFile.delete()
|
||||
} catch (e: Exception) {
|
||||
document.delete()
|
||||
android.util.Log.w(
|
||||
"SpotiFLAC",
|
||||
"Failed to copy extension output to SAF: ${e.message}"
|
||||
)
|
||||
return errorJson("Failed to copy extension output to SAF: ${e.message}")
|
||||
}
|
||||
}
|
||||
respObj.put("file_path", document.uri.toString())
|
||||
respObj.put("file_name", document.name ?: fileName)
|
||||
if (finalizerPromotesStaged) {
|
||||
respObj.put("saf_staged_output", true)
|
||||
respObj.put("saf_staged_file_name", document.name ?: stagedFileName)
|
||||
} else if (useStagedOutput) {
|
||||
// Legacy caller (foreground Dart queue): publish here by
|
||||
// renaming the staged file to its final name.
|
||||
val published = replaceFinalDocument(targetDir, document, finalFileName)
|
||||
if (published == null) {
|
||||
document.delete()
|
||||
return errorJson("Failed to publish SAF download")
|
||||
}
|
||||
respObj.put("file_path", published.uri.toString())
|
||||
respObj.put("file_name", published.name ?: finalFileName)
|
||||
}
|
||||
} else {
|
||||
document.delete()
|
||||
}
|
||||
return respObj.toString()
|
||||
} catch (e: Exception) {
|
||||
document.delete()
|
||||
return errorJson("SAF download failed: ${e.message}")
|
||||
} finally {
|
||||
if (detachedFd == null) {
|
||||
try {
|
||||
pfd.close()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps [document] into place under [finalName] without a window where the
|
||||
* previous file is gone but the new one is not yet in place. Any existing
|
||||
* file is renamed aside first and only deleted once the staged file holds
|
||||
* the final name; a failed swap restores it. Returns the published
|
||||
* document, or null when the swap failed (the caller owns [document]).
|
||||
*/
|
||||
private fun replaceFinalDocument(
|
||||
targetDir: DocumentFile,
|
||||
document: DocumentFile,
|
||||
finalName: String
|
||||
): DocumentFile? {
|
||||
val existingFinal = targetDir.findFile(finalName)
|
||||
var aside: DocumentFile? = null
|
||||
if (existingFinal != null && existingFinal.uri != document.uri) {
|
||||
val asideName = buildReplacedSafFileName(finalName)
|
||||
try {
|
||||
targetDir.findFile(asideName)?.delete()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
if (!existingFinal.renameTo(asideName)) {
|
||||
return null
|
||||
}
|
||||
aside = targetDir.findFile(asideName) ?: existingFinal
|
||||
}
|
||||
if (!document.renameTo(finalName)) {
|
||||
aside?.renameTo(finalName)
|
||||
return null
|
||||
}
|
||||
aside?.delete()
|
||||
return targetDir.findFile(finalName) ?: document
|
||||
}
|
||||
|
||||
private fun buildReplacedSafFileName(fileName: String): String {
|
||||
return "${sanitizeFilename(fileName)}.replaced"
|
||||
}
|
||||
|
||||
fun copyContentUriToTemp(context: Context, uriStr: String): String? {
|
||||
return try {
|
||||
val uri = Uri.parse(uriStr)
|
||||
val extension = DocumentFile.fromSingleUri(context, uri)
|
||||
?.name
|
||||
?.substringAfterLast('.', "")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { ".$it" }
|
||||
?: ".tmp"
|
||||
val temp = File.createTempFile("native_saf_", extension, context.cacheDir)
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
temp.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
} ?: return null
|
||||
temp.absolutePath
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("SpotiFLAC", "Failed to copy SAF URI to temp: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun writeFileToSaf(
|
||||
context: Context,
|
||||
treeUriStr: String,
|
||||
relativeDir: String,
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
srcPath: String
|
||||
): String? {
|
||||
val finalName = sanitizeFilename(fileName)
|
||||
return withSafNameLock(treeUriStr, sanitizeRelativeDir(relativeDir), finalName) {
|
||||
writeFileToSafLocked(context, treeUriStr, relativeDir, finalName, srcPath)
|
||||
}
|
||||
}
|
||||
|
||||
fun writeFileToSafUnique(
|
||||
context: Context,
|
||||
treeUriStr: String,
|
||||
relativeDir: String,
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
srcPath: String,
|
||||
preservedSuffix: String = "",
|
||||
): UniqueWriteResult? {
|
||||
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||
val preferredName = sanitizeFilenamePreservingSuffix(fileName, preservedSuffix)
|
||||
return withSafNameLock(treeUriStr, safeRelativeDir, preferredName) {
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val targetDir = ensureDocumentDir(context, treeUri, safeRelativeDir) ?: return@withSafNameLock null
|
||||
val availableName = findAvailableFileName(
|
||||
targetDir,
|
||||
preferredName,
|
||||
preservedSuffix,
|
||||
)
|
||||
val uri = writeFileToSafLocked(
|
||||
context,
|
||||
treeUriStr,
|
||||
safeRelativeDir,
|
||||
availableName,
|
||||
srcPath,
|
||||
) ?: return@withSafNameLock null
|
||||
UniqueWriteResult(uri = uri, fileName = availableName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sanitizeFilenamePreservingSuffix(fileName: String, suffix: String): String {
|
||||
val sanitized = sanitizeFilename(fileName)
|
||||
val trimmedSuffix = suffix.trim()
|
||||
if (trimmedSuffix.isEmpty() || sanitized.contains(trimmedSuffix)) return sanitized
|
||||
|
||||
val dotIndex = fileName.lastIndexOf('.')
|
||||
val hasExtension = dotIndex > 0 && dotIndex < fileName.length - 1
|
||||
val extension = if (hasExtension) fileName.substring(dotIndex) else ""
|
||||
val rawStem = if (hasExtension) fileName.substring(0, dotIndex) else fileName
|
||||
val rawPrefix = rawStem.replace(trimmedSuffix, "").trim(' ', '_', '-')
|
||||
val safeSuffix = sanitizeFilename(trimmedSuffix)
|
||||
val reserved = " - $safeSuffix$extension"
|
||||
val prefixBytes = (MAX_SAF_DISPLAY_NAME_UTF8_BYTES - reserved.toByteArray(Charsets.UTF_8).size)
|
||||
.coerceAtLeast(1)
|
||||
val safePrefix = truncateUtf8Bytes(sanitizeFilename(rawPrefix), prefixBytes)
|
||||
.trim()
|
||||
.trim('.', ' ', '_', '-')
|
||||
.ifBlank { "track" }
|
||||
return "$safePrefix$reserved"
|
||||
}
|
||||
|
||||
private fun findAvailableFileName(
|
||||
parent: DocumentFile,
|
||||
preferredName: String,
|
||||
preservedSuffix: String,
|
||||
): String {
|
||||
if (parent.findFile(preferredName) == null) return preferredName
|
||||
for (counter in 2..9999) {
|
||||
val candidate = appendFilenameCounter(
|
||||
preferredName,
|
||||
counter.toLong(),
|
||||
preservedSuffix,
|
||||
)
|
||||
if (parent.findFile(candidate) == null) return candidate
|
||||
}
|
||||
return appendFilenameCounter(
|
||||
preferredName,
|
||||
System.currentTimeMillis(),
|
||||
preservedSuffix,
|
||||
)
|
||||
}
|
||||
|
||||
private fun appendFilenameCounter(
|
||||
fileName: String,
|
||||
counter: Long,
|
||||
preservedSuffix: String,
|
||||
): String {
|
||||
val dotIndex = fileName.lastIndexOf('.')
|
||||
val hasExtension = dotIndex > 0 && dotIndex < fileName.length - 1
|
||||
val extension = if (hasExtension) fileName.substring(dotIndex) else ""
|
||||
val originalStem = if (hasExtension) fileName.substring(0, dotIndex) else fileName
|
||||
val safePreservedSuffix = preservedSuffix.trim()
|
||||
val hasPreservedSuffix = safePreservedSuffix.isNotEmpty() && originalStem.contains(safePreservedSuffix)
|
||||
val stem = if (hasPreservedSuffix) {
|
||||
originalStem.replace(safePreservedSuffix, "").trim(' ', '_', '-')
|
||||
} else {
|
||||
originalStem
|
||||
}
|
||||
val suffix = if (hasPreservedSuffix) {
|
||||
" - $safePreservedSuffix ($counter)"
|
||||
} else {
|
||||
" ($counter)"
|
||||
}
|
||||
val reservedBytes = extension.toByteArray(Charsets.UTF_8).size +
|
||||
suffix.toByteArray(Charsets.UTF_8).size
|
||||
val maxStemBytes = (MAX_SAF_DISPLAY_NAME_UTF8_BYTES - reservedBytes).coerceAtLeast(1)
|
||||
val safeStem = truncateUtf8Bytes(stem, maxStemBytes).trim().trim('.', ' ').ifBlank { "track" }
|
||||
return "$safeStem$suffix$extension"
|
||||
}
|
||||
|
||||
private fun writeFileToSafLocked(
|
||||
context: Context,
|
||||
treeUriStr: String,
|
||||
relativeDir: String,
|
||||
finalName: String,
|
||||
srcPath: String
|
||||
): String? {
|
||||
var stagedDocument: DocumentFile? = null
|
||||
return try {
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val targetDir = ensureDocumentDir(context, treeUri, relativeDir) ?: return null
|
||||
val ext = normalizeExt(finalName.substringAfterLast('.', ""))
|
||||
val stagedName = buildStagedSafFileName(finalName)
|
||||
deleteStaleStagedFiles(targetDir, finalName, ext)
|
||||
val document = createOrReuseDocumentFile(targetDir, STAGED_SAF_MIME_TYPE, stagedName)
|
||||
?: return null
|
||||
stagedDocument = document
|
||||
val outputStream = context.contentResolver.openOutputStream(document.uri, "wt")
|
||||
if (outputStream == null) {
|
||||
document.delete()
|
||||
stagedDocument = null
|
||||
return null
|
||||
}
|
||||
outputStream.use { output ->
|
||||
File(srcPath).inputStream().use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
syncOutputStream(output)
|
||||
}
|
||||
|
||||
val published = replaceFinalDocument(targetDir, document, finalName)
|
||||
if (published == null) {
|
||||
document.delete()
|
||||
return null
|
||||
}
|
||||
stagedDocument = null
|
||||
published.uri.toString()
|
||||
} catch (e: Exception) {
|
||||
stagedDocument?.delete()
|
||||
android.util.Log.w("SpotiFLAC", "Failed to write file to SAF: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteContentUri(context: Context, uriStr: String): Boolean {
|
||||
return try {
|
||||
DocumentFile.fromSingleUri(context, Uri.parse(uriStr))?.delete() == true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
internal fun normalizeExt(ext: String?): String {
|
||||
val trimmed = ext?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return ""
|
||||
return if (trimmed.startsWith(".")) trimmed.lowercase(Locale.ROOT) else ".${trimmed.lowercase(Locale.ROOT)}"
|
||||
}
|
||||
|
||||
internal fun mimeTypeForExt(ext: String?): String {
|
||||
return when (normalizeExt(ext)) {
|
||||
".m4a", ".mp4" -> "audio/mp4"
|
||||
".mp3" -> "audio/mpeg"
|
||||
".opus", ".ogg" -> "audio/ogg"
|
||||
".flac" -> "audio/flac"
|
||||
".wav" -> "audio/wav"
|
||||
".aiff", ".aif", ".aifc" -> "audio/aiff"
|
||||
".lrc" -> "application/octet-stream"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
private fun forceFilenameExt(name: String, outputExt: String): String {
|
||||
val normalizedExt = normalizeExt(outputExt)
|
||||
if (normalizedExt.isBlank()) return sanitizeFilename(name)
|
||||
|
||||
val safeName = sanitizeFilename(name)
|
||||
val lower = safeName.lowercase(Locale.ROOT)
|
||||
val knownExts = listOf(".flac", ".m4a", ".mp4", ".mp3", ".opus", ".lrc")
|
||||
for (knownExt in knownExts) {
|
||||
if (lower.endsWith(knownExt)) {
|
||||
return safeName.dropLast(knownExt.length) + normalizedExt
|
||||
}
|
||||
}
|
||||
return safeName + normalizedExt
|
||||
}
|
||||
|
||||
private fun buildStagedSafFileName(fileName: String): String {
|
||||
val safeName = sanitizeFilename(fileName)
|
||||
return "$safeName.partial"
|
||||
}
|
||||
|
||||
private fun buildLegacyStagedSafFileName(fileName: String, outputExt: String): String {
|
||||
val safeName = sanitizeFilename(fileName)
|
||||
val ext = normalizeExt(outputExt)
|
||||
if (ext.isNotBlank() && safeName.lowercase(Locale.ROOT).endsWith(ext)) {
|
||||
return safeName.dropLast(ext.length).trimEnd('.', ' ') + ".partial$ext"
|
||||
}
|
||||
val dot = safeName.lastIndexOf('.')
|
||||
if (dot > 0 && dot < safeName.lastIndex) {
|
||||
return safeName.substring(0, dot).trimEnd('.', ' ') +
|
||||
".partial" +
|
||||
safeName.substring(dot)
|
||||
}
|
||||
return "$safeName.partial"
|
||||
}
|
||||
|
||||
private fun deleteStaleStagedFiles(parent: DocumentFile, fileName: String, outputExt: String) {
|
||||
val stagedNames = linkedSetOf(
|
||||
buildStagedSafFileName(fileName),
|
||||
buildLegacyStagedSafFileName(fileName, outputExt),
|
||||
buildReplacedSafFileName(fileName)
|
||||
)
|
||||
for (stagedName in stagedNames) {
|
||||
try {
|
||||
parent.findFile(stagedName)?.delete()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun sanitizeFilename(name: String): String {
|
||||
var sanitized = name
|
||||
.replace("/", " ")
|
||||
.replace(Regex("[\\\\:*?\"<>|]"), " ")
|
||||
.filter { ch ->
|
||||
val code = ch.code
|
||||
!((code < 0x20 && ch != '\t' && ch != '\n' && ch != '\r') ||
|
||||
code == 0x7F ||
|
||||
(Character.isISOControl(ch) && ch != '\t' && ch != '\n' && ch != '\r'))
|
||||
}
|
||||
.trim()
|
||||
.trim('.', ' ')
|
||||
|
||||
sanitized = sanitized
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.replace(Regex("_+"), "_")
|
||||
.trim('_', ' ')
|
||||
|
||||
sanitized = truncateSafDisplayName(sanitized, MAX_SAF_DISPLAY_NAME_UTF8_BYTES)
|
||||
sanitized = sanitized.trim().trim('.', ' ').trim('_', ' ')
|
||||
return if (sanitized.isBlank()) "Unknown" else sanitized
|
||||
}
|
||||
|
||||
private fun truncateSafDisplayName(name: String, maxBytes: Int): String {
|
||||
if (maxBytes <= 0 || name.toByteArray(Charsets.UTF_8).size <= maxBytes) return name
|
||||
|
||||
val dotIndex = name.lastIndexOf('.')
|
||||
val ext = if (
|
||||
dotIndex > 0 &&
|
||||
dotIndex < name.length - 1 &&
|
||||
name.length - dotIndex <= 10
|
||||
) {
|
||||
name.substring(dotIndex)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val stem = if (ext.isNotEmpty()) name.substring(0, dotIndex) else name
|
||||
val maxStemBytes = (maxBytes - ext.toByteArray(Charsets.UTF_8).size).coerceAtLeast(1)
|
||||
return truncateUtf8Bytes(stem, maxStemBytes).trim().trim('.', ' ').trim('_', ' ') + ext
|
||||
}
|
||||
|
||||
private fun truncateUtf8Bytes(value: String, maxBytes: Int): String {
|
||||
if (maxBytes <= 0 || value.toByteArray(Charsets.UTF_8).size <= maxBytes) return value
|
||||
|
||||
val builder = StringBuilder()
|
||||
var usedBytes = 0
|
||||
var index = 0
|
||||
while (index < value.length) {
|
||||
val codePoint = value.codePointAt(index)
|
||||
val char = String(Character.toChars(codePoint))
|
||||
val charBytes = char.toByteArray(Charsets.UTF_8).size
|
||||
if (usedBytes + charBytes > maxBytes) break
|
||||
builder.append(char)
|
||||
usedBytes += charBytes
|
||||
index += Character.charCount(codePoint)
|
||||
}
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
internal fun sanitizeRelativeDir(relativeDir: String): String {
|
||||
if (relativeDir.isBlank()) return ""
|
||||
return relativeDir
|
||||
.split("/")
|
||||
.map { sanitizeFilename(it) }
|
||||
.filter { it.isNotBlank() && it != "." && it != ".." }
|
||||
.joinToString("/")
|
||||
}
|
||||
|
||||
internal fun ensureDocumentDir(
|
||||
context: Context,
|
||||
treeUri: Uri,
|
||||
relativeDir: String
|
||||
): DocumentFile? {
|
||||
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||
if (safeRelativeDir.isBlank()) {
|
||||
return DocumentFile.fromTreeUri(context, treeUri)
|
||||
}
|
||||
|
||||
synchronized(safDirLock) {
|
||||
var current = DocumentFile.fromTreeUri(context, treeUri) ?: return null
|
||||
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
|
||||
for (part in parts) {
|
||||
val existing = current.findFile(part)
|
||||
current = if (existing != null && existing.isDirectory) {
|
||||
existing
|
||||
} else {
|
||||
val created = current.createDirectory(part) ?: return null
|
||||
val createdName = created.name ?: part
|
||||
if (createdName != part) {
|
||||
created.delete()
|
||||
current.findFile(part) ?: return null
|
||||
} else {
|
||||
created
|
||||
}
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
}
|
||||
|
||||
internal fun findDocumentDir(
|
||||
context: Context,
|
||||
treeUri: Uri,
|
||||
relativeDir: String
|
||||
): DocumentFile? {
|
||||
var current = DocumentFile.fromTreeUri(context, treeUri) ?: return null
|
||||
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||
if (safeRelativeDir.isBlank()) return current
|
||||
|
||||
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
|
||||
for (part in parts) {
|
||||
val existing = current.findFile(part)
|
||||
if (existing == null || !existing.isDirectory) return null
|
||||
current = existing
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
internal fun createOrReuseDocumentFile(
|
||||
parent: DocumentFile,
|
||||
mimeType: String,
|
||||
fileName: String
|
||||
): DocumentFile? {
|
||||
val safeFileName = sanitizeFilename(fileName)
|
||||
if (safeFileName.isBlank()) return null
|
||||
|
||||
synchronized(safDirLock) {
|
||||
val existing = parent.findFile(safeFileName)
|
||||
if (existing != null && existing.isFile) {
|
||||
return existing
|
||||
}
|
||||
|
||||
val created = parent.createFile(mimeType, safeFileName) ?: return null
|
||||
val createdName = created.name ?: safeFileName
|
||||
if (createdName == safeFileName) {
|
||||
return created
|
||||
}
|
||||
|
||||
val winner = parent.findFile(safeFileName)
|
||||
if (winner != null && winner.isFile) {
|
||||
if (winner.uri != created.uri) {
|
||||
try {
|
||||
created.delete()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
return winner
|
||||
}
|
||||
|
||||
return created
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildSafFileName(req: JSONObject, outputExt: String): String {
|
||||
val provided = req.optString("saf_file_name", "")
|
||||
if (provided.isNotBlank()) return forceFilenameExt(provided, outputExt)
|
||||
|
||||
val trackName = req.optString("track_name", "track")
|
||||
val artistName = req.optString("artist_name", "")
|
||||
val baseName = if (artistName.isNotBlank()) "$artistName - $trackName" else trackName
|
||||
return forceFilenameExt(baseName, outputExt)
|
||||
}
|
||||
|
||||
private fun errorJson(message: String): String {
|
||||
val obj = JSONObject()
|
||||
obj.put("success", false)
|
||||
obj.put("error", message)
|
||||
obj.put("message", message)
|
||||
return obj.toString()
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,4 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1,4 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
||||
@@ -6,9 +6,4 @@
|
||||
android:drawable="@drawable/ic_launcher_foreground"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
<monochrome>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_monochrome"
|
||||
android:inset="16%" />
|
||||
</monochrome>
|
||||
</adaptive-icon>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 11 KiB |
@@ -1,8 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#000000</color>
|
||||
<color name="ic_launcher_background">#1a1a2e</color>
|
||||
</resources>
|
||||
@@ -1,8 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<automotiveApp>
|
||||
<uses name="media" />
|
||||
</automotiveApp>
|
||||
@@ -1,30 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<data-extraction-rules>
|
||||
<!--
|
||||
SpotiFLAC Mobile provides an explicit, schema-aware Backup & Restore
|
||||
feature. Platform backup must not copy device-bound SAF grants, paths,
|
||||
queues, extension credentials, or signed sessions to another install.
|
||||
-->
|
||||
<cloud-backup>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
<exclude domain="device_root" path="." />
|
||||
<exclude domain="device_file" path="." />
|
||||
<exclude domain="device_database" path="." />
|
||||
<exclude domain="device_sharedpref" path="." />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
<exclude domain="device_root" path="." />
|
||||
<exclude domain="device_file" path="." />
|
||||
<exclude domain="device_database" path="." />
|
||||
<exclude domain="device_sharedpref" path="." />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<full-backup-content>
|
||||
<!-- Android 11 and lower use this legacy rule format. -->
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
<exclude domain="device_root" path="." />
|
||||
<exclude domain="device_file" path="." />
|
||||
<exclude domain="device_database" path="." />
|
||||
<exclude domain="device_sharedpref" path="." />
|
||||
</full-backup-content>
|
||||
@@ -1,16 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<locale android:name="en" />
|
||||
<locale android:name="ru" />
|
||||
<locale android:name="es-ES" />
|
||||
<locale android:name="id" />
|
||||
<locale android:name="pt-PT" />
|
||||
<locale android:name="ja" />
|
||||
<locale android:name="tr" />
|
||||
<locale android:name="de" />
|
||||
<locale android:name="fr" />
|
||||
<locale android:name="hi" />
|
||||
<locale android:name="ko" />
|
||||
<locale android:name="nl" />
|
||||
<locale android:name="zh" />
|
||||
</locale-config>
|
||||
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
|
||||
<!-- Allow local loopback cleartext for FFmpeg live decrypt tunnel only. -->
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">localhost</domain>
|
||||
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -1,152 +0,0 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class NativeWorkerPolicyTest {
|
||||
@Test
|
||||
fun rateLimitRetriesExactlyOnce() {
|
||||
assertTrue(
|
||||
NativeWorkerPolicy.shouldRetryRateLimit(
|
||||
errorType = "rate_limit",
|
||||
errorMessage = "Too many requests",
|
||||
attempts = 0,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeWorkerPolicy.shouldRetryRateLimit(
|
||||
errorType = "rate_limit",
|
||||
errorMessage = "Too many requests",
|
||||
attempts = 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rateLimitDetectionFallsBackToMessage() {
|
||||
assertTrue(
|
||||
NativeWorkerPolicy.shouldRetryRateLimit(
|
||||
errorType = "network",
|
||||
errorMessage = "HTTP 429: retry later",
|
||||
attempts = 0,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeWorkerPolicy.shouldRetryRateLimit(
|
||||
errorType = "network",
|
||||
errorMessage = "Connection reset",
|
||||
attempts = 0,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retryDelayUsesServerHintAndSafeBounds() {
|
||||
assertEquals(
|
||||
45,
|
||||
NativeWorkerPolicy.rateLimitDelaySeconds(
|
||||
retryAfterSeconds = 45,
|
||||
errorMessage = null,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
12,
|
||||
NativeWorkerPolicy.rateLimitDelaySeconds(
|
||||
retryAfterSeconds = null,
|
||||
errorMessage = "Retry-After seconds: 12",
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
5,
|
||||
NativeWorkerPolicy.rateLimitDelaySeconds(
|
||||
retryAfterSeconds = 1,
|
||||
errorMessage = null,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
300,
|
||||
NativeWorkerPolicy.rateLimitDelaySeconds(
|
||||
retryAfterSeconds = 900,
|
||||
errorMessage = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wifiOnlyPausesWithoutWifi() {
|
||||
assertTrue(
|
||||
NativeWorkerPolicy.shouldPauseForNetwork(
|
||||
downloadNetworkMode = "wifi_only",
|
||||
hasWifi = false,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeWorkerPolicy.shouldPauseForNetwork(
|
||||
downloadNetworkMode = "wifi_only",
|
||||
hasWifi = true,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeWorkerPolicy.shouldPauseForNetwork(
|
||||
downloadNetworkMode = "any",
|
||||
hasWifi = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verificationDetectionUsesTypeAndMessageFallback() {
|
||||
assertTrue(
|
||||
NativeWorkerPolicy.isVerificationRequired(
|
||||
errorType = "verification_required",
|
||||
errorMessage = null,
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
NativeWorkerPolicy.isVerificationRequired(
|
||||
errorType = "unknown",
|
||||
errorMessage = "Challenge required before download",
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeWorkerPolicy.isVerificationRequired(
|
||||
errorType = "network",
|
||||
errorMessage = "Connection reset",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completionAlertSkipsCancelledOrEmptyRuns() {
|
||||
assertTrue(
|
||||
NativeWorkerPolicy.shouldNotifyQueueComplete(
|
||||
cancelRequested = false,
|
||||
completed = 1,
|
||||
failed = 0,
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
NativeWorkerPolicy.shouldNotifyQueueComplete(
|
||||
cancelRequested = false,
|
||||
completed = 0,
|
||||
failed = 1,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeWorkerPolicy.shouldNotifyQueueComplete(
|
||||
cancelRequested = true,
|
||||
completed = 1,
|
||||
failed = 0,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeWorkerPolicy.shouldNotifyQueueComplete(
|
||||
cancelRequested = false,
|
||||
completed = 0,
|
||||
failed = 0,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@ subprojects {
|
||||
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_25
|
||||
targetCompatibility = JavaVersion.VERSION_25
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
// Enable multidex for all subprojects
|
||||
@@ -22,12 +22,12 @@ subprojects {
|
||||
}
|
||||
|
||||
// Add desugaring dependency to all Android subprojects
|
||||
project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.5")
|
||||
project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
}
|
||||
|
||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-all.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||
|
||||
@@ -19,8 +19,8 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "9.3.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.4.10" apply false
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.3.0" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"name": "SpotiFLAC Mobile Source",
|
||||
"identifier": "com.zarzet.spotiflac.source",
|
||||
"subtitle": "FLAC Downloader for iOS",
|
||||
"apps": [
|
||||
{
|
||||
"name": "SpotiFLAC Mobile",
|
||||
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||
"developerName": "zarzet",
|
||||
"version": "4.7.1",
|
||||
"versionDate": "2026-07-01",
|
||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.7.1/SpotiFLAC-v4.7.1-ios-unsigned.ipa",
|
||||
"localizedDescription": "SpotiFLAC Mobile is written in Flutter. Download tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
|
||||
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
||||
"size": 37455821
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 539 KiB |
|
Before Width: | Height: | Size: 811 KiB |
|
Before Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,136 @@
|
||||
import 'dart:io';
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('FFmpeg');
|
||||
|
||||
/// FFmpeg service for iOS using ffmpeg_kit_flutter plugin
|
||||
class FFmpegServiceIOS {
|
||||
/// Execute FFmpeg command and return result
|
||||
static Future<FFmpegResultIOS> _execute(String command) async {
|
||||
try {
|
||||
final session = await FFmpegKit.execute(command);
|
||||
final returnCode = await session.getReturnCode();
|
||||
final output = await session.getOutput() ?? '';
|
||||
return FFmpegResultIOS(
|
||||
success: ReturnCode.isSuccess(returnCode),
|
||||
returnCode: returnCode?.getValue() ?? -1,
|
||||
output: output,
|
||||
);
|
||||
} catch (e) {
|
||||
_log.e('FFmpeg execute error: $e');
|
||||
return FFmpegResultIOS(success: false, returnCode: -1, output: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert M4A (DASH segments) to FLAC
|
||||
static Future<String?> convertM4aToFlac(String inputPath) async {
|
||||
final outputPath = inputPath.replaceAll('.m4a', '.flac');
|
||||
final command = '-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
|
||||
final result = await _execute(command);
|
||||
|
||||
if (result.success) {
|
||||
try {
|
||||
await File(inputPath).delete();
|
||||
} catch (_) {}
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
_log.e('M4A to FLAC conversion failed: ${result.output}');
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Convert FLAC to MP3
|
||||
static Future<String?> convertFlacToMp3(String inputPath, {String bitrate = '320k'}) async {
|
||||
final dir = File(inputPath).parent.path;
|
||||
final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
|
||||
final outputDir = '$dir${Platform.pathSeparator}MP3';
|
||||
await Directory(outputDir).create(recursive: true);
|
||||
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.mp3';
|
||||
|
||||
final command = '-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
|
||||
final result = await _execute(command);
|
||||
|
||||
if (result.success) return outputPath;
|
||||
_log.e('FLAC to MP3 conversion failed: ${result.output}');
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Convert FLAC to M4A
|
||||
static Future<String?> convertFlacToM4a(String inputPath, {String codec = 'aac', String bitrate = '256k'}) async {
|
||||
final dir = File(inputPath).parent.path;
|
||||
final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
|
||||
final outputDir = '$dir${Platform.pathSeparator}M4A';
|
||||
await Directory(outputDir).create(recursive: true);
|
||||
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.m4a';
|
||||
|
||||
String command;
|
||||
if (codec == 'alac') {
|
||||
command = '-i "$inputPath" -codec:a alac -map 0:a -map_metadata 0 "$outputPath" -y';
|
||||
} else {
|
||||
command = '-i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y';
|
||||
}
|
||||
|
||||
final result = await _execute(command);
|
||||
if (result.success) return outputPath;
|
||||
_log.e('FLAC to M4A conversion failed: ${result.output}');
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Embed cover art to FLAC file
|
||||
static Future<String?> embedCover(String flacPath, String coverPath) async {
|
||||
final tempOutput = '$flacPath.tmp';
|
||||
final command = '-i "$flacPath" -i "$coverPath" -map 0:a -map 1:0 -c copy -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" -disposition:v attached_pic "$tempOutput" -y';
|
||||
|
||||
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 cover embed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final tempFile = File(tempOutput);
|
||||
if (await tempFile.exists()) await tempFile.delete();
|
||||
} catch (_) {}
|
||||
|
||||
_log.e('Cover embed failed: ${result.output}');
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Check if FFmpeg is available
|
||||
static Future<bool> isAvailable() async {
|
||||
try {
|
||||
final session = await FFmpegKit.execute('-version');
|
||||
final returnCode = await session.getReturnCode();
|
||||
return ReturnCode.isSuccess(returnCode);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get FFmpeg version info
|
||||
static Future<String?> getVersion() async {
|
||||
try {
|
||||
final session = await FFmpegKit.execute('-version');
|
||||
return await session.getOutput();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FFmpegResultIOS {
|
||||
final bool success;
|
||||
final int returnCode;
|
||||
final String output;
|
||||
|
||||
FFmpegResultIOS({required this.success, required this.returnCode, required this.output});
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
# git-cliff configuration for SpotiFLAC Mobile
|
||||
# https://git-cliff.org/docs/configuration
|
||||
|
||||
[changelog]
|
||||
# Template for the changelog body
|
||||
body = """
|
||||
{%- macro remote_url() -%}
|
||||
https://github.com/zarzet/SpotiFLAC-Mobile
|
||||
{%- endmacro -%}
|
||||
|
||||
{% if version %}\
|
||||
## {{ version | trim_start_matches(pat="v") }}
|
||||
{% else %}\
|
||||
## Unreleased
|
||||
{% endif %}\
|
||||
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | striptags | trim | upper_first }}
|
||||
{% for commit in commits %}
|
||||
- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}\
|
||||
{{ commit.message | upper_first }}\
|
||||
{% if commit.github.pr_number %} \
|
||||
([#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}))\
|
||||
{% endif %}\
|
||||
{%- if commit.github.username and commit.github.username != "zarzet" %} by [@{{ commit.github.username }}](https://github.com/{{ commit.github.username }}){%- endif %}
|
||||
{%- endfor %}
|
||||
{% endfor %}
|
||||
|
||||
{%- if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %}
|
||||
|
||||
### New Contributors
|
||||
{%- for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}
|
||||
* @{{ contributor.username }} made their first contribution
|
||||
{%- if contributor.pr_number %} in \
|
||||
[#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- endif -%}
|
||||
|
||||
{% if version %}
|
||||
{% if previous.version %}
|
||||
**Full Changelog**: [{{ previous.version }}...{{ version }}]({{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }})
|
||||
{% endif %}
|
||||
{% else -%}
|
||||
{% raw %}\n{% endraw %}
|
||||
{% endif %}
|
||||
"""
|
||||
# Remove leading and trailing whitespace
|
||||
trim = true
|
||||
|
||||
[git]
|
||||
# Parse conventional commits
|
||||
conventional_commits = true
|
||||
filter_unconventional = true
|
||||
|
||||
# Process each line of a commit as an individual commit
|
||||
split_commits = false
|
||||
|
||||
# Regex for preprocessing the commit messages
|
||||
commit_preprocessors = [
|
||||
# Strip conventional commit prefix for cleaner messages
|
||||
# (group header already shows the type)
|
||||
]
|
||||
|
||||
# Regex for parsing and grouping commits
|
||||
commit_parsers = [
|
||||
# Skip noise: translation commits from Crowdin
|
||||
{ message = "^New translations", skip = true },
|
||||
{ message = "^Update source file", skip = true },
|
||||
# Skip merge commits
|
||||
{ message = "^Merge", skip = true },
|
||||
# Skip version bump commits
|
||||
{ message = "^v\\d+", skip = true },
|
||||
{ message = "^chore: update VirusTotal", skip = true },
|
||||
|
||||
# Group by conventional commit type
|
||||
{ message = "^feat", group = "<!-- 0 -->New Features" },
|
||||
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
|
||||
{ message = "^perf", group = "<!-- 2 -->Performance" },
|
||||
{ message = "^refactor", group = "<!-- 3 -->Refactoring" },
|
||||
{ message = "^doc", group = "<!-- 4 -->Documentation" },
|
||||
{ message = "^style", group = "<!-- 5 -->Styling" },
|
||||
{ message = "^test", group = "<!-- 6 -->Testing" },
|
||||
{ message = "^chore\\(deps\\)", group = "<!-- 7 -->Dependencies" },
|
||||
{ message = "^chore\\(l10n\\)", skip = true },
|
||||
{ message = "^chore|^ci", group = "<!-- 8 -->Chores" },
|
||||
]
|
||||
|
||||
# Protect breaking changes from being skipped
|
||||
protect_breaking_commits = true
|
||||
|
||||
# Filter out commits by matching patterns
|
||||
filter_commits = false
|
||||
|
||||
# Tag pattern for version detection
|
||||
tag_pattern = "v[0-9].*"
|
||||
|
||||
# Sort commits by newest first
|
||||
sort_commits = "newest"
|
||||
|
||||
[remote.github]
|
||||
owner = "zarzet"
|
||||
repo = "SpotiFLAC-Mobile"
|
||||
@@ -1,23 +0,0 @@
|
||||
files:
|
||||
- source: /lib/l10n/arb/app_en.arb
|
||||
translation: /lib/l10n/arb/app_%locale%.arb
|
||||
languages_mapping:
|
||||
locale:
|
||||
# Keys MUST be the project's Crowdin language ids; values are the
|
||||
# %locale% suffix used in app_%locale%.arb (underscores so Flutter
|
||||
# gen-l10n parses them — hyphenated filenames break gen-l10n).
|
||||
ar: ar
|
||||
de: de
|
||||
es-ES: es_ES
|
||||
fr: fr
|
||||
hi: hi
|
||||
id: id
|
||||
ja: ja
|
||||
ko: ko
|
||||
nl: nl
|
||||
pt-PT: pt_PT
|
||||
ru: ru
|
||||
tr: tr
|
||||
uk: uk
|
||||
zh-CN: zh_CN
|
||||
zh-TW: zh_TW
|
||||
@@ -1,408 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// mp4Box is a minimal ISO-BMFF / QuickTime box view over an in-memory buffer.
|
||||
type mp4Box struct {
|
||||
offset int64
|
||||
size int64
|
||||
hdr int64
|
||||
typ string
|
||||
}
|
||||
|
||||
func (b mp4Box) body() int64 { return b.offset + b.hdr }
|
||||
func (b mp4Box) end() int64 { return b.offset + b.size }
|
||||
|
||||
func readMP4Box(data []byte, pos int64) (mp4Box, bool) {
|
||||
n := int64(len(data))
|
||||
if pos < 0 || pos+8 > n {
|
||||
return mp4Box{}, false
|
||||
}
|
||||
size := int64(binary.BigEndian.Uint32(data[pos : pos+4]))
|
||||
typ := string(data[pos+4 : pos+8])
|
||||
hdr := int64(8)
|
||||
switch size {
|
||||
case 1:
|
||||
if pos+16 > n {
|
||||
return mp4Box{}, false
|
||||
}
|
||||
size = int64(binary.BigEndian.Uint64(data[pos+8 : pos+16]))
|
||||
hdr = 16
|
||||
case 0:
|
||||
size = n - pos
|
||||
}
|
||||
if size < hdr || pos+size > n {
|
||||
return mp4Box{}, false
|
||||
}
|
||||
return mp4Box{offset: pos, size: size, hdr: hdr, typ: typ}, true
|
||||
}
|
||||
|
||||
func findChildMP4(data []byte, start, end int64, typ string) (mp4Box, bool) {
|
||||
pos := start
|
||||
for pos+8 <= end {
|
||||
b, ok := readMP4Box(data, pos)
|
||||
if !ok {
|
||||
return mp4Box{}, false
|
||||
}
|
||||
if b.typ == typ {
|
||||
return b, true
|
||||
}
|
||||
pos = b.end()
|
||||
}
|
||||
return mp4Box{}, false
|
||||
}
|
||||
|
||||
func eachChildMP4(data []byte, start, end int64, typ string, fn func(mp4Box) bool) {
|
||||
pos := start
|
||||
for pos+8 <= end {
|
||||
b, ok := readMP4Box(data, pos)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if b.typ == typ && !fn(b) {
|
||||
return
|
||||
}
|
||||
pos = b.end()
|
||||
}
|
||||
}
|
||||
|
||||
// findBoxBySignature scans [start,end) for a box of the given type, matching the
|
||||
// 4-byte type tag and validating the preceding size field. Used to locate dac4
|
||||
// which may be nested inside an encrypted (enca) sample entry.
|
||||
func findBoxBySignature(data []byte, start, end int64, typ string) (mp4Box, bool) {
|
||||
if len(typ) != 4 {
|
||||
return mp4Box{}, false
|
||||
}
|
||||
for i := start; i+8 <= end; i++ {
|
||||
if data[i+4] == typ[0] && data[i+5] == typ[1] && data[i+6] == typ[2] && data[i+7] == typ[3] {
|
||||
if b, ok := readMP4Box(data, i); ok && b.typ == typ {
|
||||
return b, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return mp4Box{}, false
|
||||
}
|
||||
|
||||
// audioSampleEntryHeaderLen returns the byte length of the fixed audio sample
|
||||
// entry header (from the box body start) before child boxes begin. ok is false
|
||||
// for malformed/truncated entries whose declared header is not fully present.
|
||||
func audioSampleEntryHeaderLen(data []byte, entry mp4Box) (hdrLen int64, ok bool) {
|
||||
// 6 bytes reserved + 2 bytes data_reference_index, then the audio fields.
|
||||
base := entry.body()
|
||||
if base+10 > entry.end() {
|
||||
return 0, false
|
||||
}
|
||||
version := binary.BigEndian.Uint16(data[base+8 : base+10])
|
||||
hdrLen = 8 + 20
|
||||
switch version {
|
||||
case 1:
|
||||
hdrLen += 16
|
||||
case 2:
|
||||
hdrLen += 36
|
||||
}
|
||||
if base+hdrLen > entry.end() {
|
||||
return 0, false
|
||||
}
|
||||
return hdrLen, true
|
||||
}
|
||||
|
||||
type ac4Location struct {
|
||||
chain []mp4Box // moov, trak, mdia, minf, stbl, stsd (ancestors to grow)
|
||||
entry mp4Box // the ac-4 sample entry
|
||||
}
|
||||
|
||||
func locateAC4Entry(data []byte) (ac4Location, bool) {
|
||||
moov, ok := findChildMP4(data, 0, int64(len(data)), "moov")
|
||||
if !ok {
|
||||
return ac4Location{}, false
|
||||
}
|
||||
var found ac4Location
|
||||
var ok2 bool
|
||||
eachChildMP4(data, moov.body(), moov.end(), "trak", func(trak mp4Box) bool {
|
||||
mdia, ok := findChildMP4(data, trak.body(), trak.end(), "mdia")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
minf, ok := findChildMP4(data, mdia.body(), mdia.end(), "minf")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
stbl, ok := findChildMP4(data, minf.body(), minf.end(), "stbl")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
stsd, ok := findChildMP4(data, stbl.body(), stbl.end(), "stsd")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
entry, ok := findChildMP4(data, stsd.body()+8, stsd.end(), "ac-4")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
found = ac4Location{chain: []mp4Box{moov, trak, mdia, minf, stbl, stsd}, entry: entry}
|
||||
ok2 = true
|
||||
return false
|
||||
})
|
||||
return found, ok2
|
||||
}
|
||||
|
||||
func growBoxSize(data []byte, b mp4Box, delta int64) {
|
||||
if b.hdr == 16 {
|
||||
binary.BigEndian.PutUint64(data[b.offset+8:b.offset+16], uint64(b.size+delta))
|
||||
} else {
|
||||
binary.BigEndian.PutUint32(data[b.offset:b.offset+4], uint32(b.size+delta))
|
||||
}
|
||||
}
|
||||
|
||||
// shiftChunkOffsets adds delta to every stco/co64 entry that references a file
|
||||
// offset at or beyond insertPos, keeping sample pointers valid after bytes are
|
||||
// inserted into moov.
|
||||
func shiftChunkOffsets(data []byte, moov mp4Box, insertPos, delta int64) {
|
||||
eachChildMP4(data, moov.body(), moov.end(), "trak", func(trak mp4Box) bool {
|
||||
mdia, ok := findChildMP4(data, trak.body(), trak.end(), "mdia")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
minf, ok := findChildMP4(data, mdia.body(), mdia.end(), "minf")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
stbl, ok := findChildMP4(data, minf.body(), minf.end(), "stbl")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if stco, ok := findChildMP4(data, stbl.body(), stbl.end(), "stco"); ok {
|
||||
base := stco.body() + 4
|
||||
if base+4 <= stco.end() {
|
||||
count := int64(binary.BigEndian.Uint32(data[base : base+4]))
|
||||
p := base + 4
|
||||
for i := int64(0); i < count && p+4 <= stco.end(); i++ {
|
||||
v := int64(binary.BigEndian.Uint32(data[p : p+4]))
|
||||
if v >= insertPos {
|
||||
binary.BigEndian.PutUint32(data[p:p+4], uint32(v+delta))
|
||||
}
|
||||
p += 4
|
||||
}
|
||||
}
|
||||
}
|
||||
if co64, ok := findChildMP4(data, stbl.body(), stbl.end(), "co64"); ok {
|
||||
base := co64.body() + 4
|
||||
if base+4 <= co64.end() {
|
||||
count := int64(binary.BigEndian.Uint32(data[base : base+4]))
|
||||
p := base + 4
|
||||
for i := int64(0); i < count && p+8 <= co64.end(); i++ {
|
||||
v := int64(binary.BigEndian.Uint64(data[p : p+8]))
|
||||
if v >= insertPos {
|
||||
binary.BigEndian.PutUint64(data[p:p+8], uint64(v+delta))
|
||||
}
|
||||
p += 8
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// normalizeQuickTimeBrandsInBuf rewrites a "qt " ftyp brand to isom/mp42 in
|
||||
// place. Works on any buffer whose top level contains the ftyp box (whole file
|
||||
// or the ftyp box alone). Returns whether anything changed.
|
||||
func normalizeQuickTimeBrandsInBuf(data []byte) bool {
|
||||
ftyp, ok := findChildMP4(data, 0, int64(len(data)), "ftyp")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
if ftyp.body()+4 <= int64(len(data)) && string(data[ftyp.body():ftyp.body()+4]) != "mp42" {
|
||||
copy(data[ftyp.body():ftyp.body()+4], []byte("mp42"))
|
||||
changed = true
|
||||
}
|
||||
for p := ftyp.body() + 8; p+4 <= ftyp.end(); p += 4 {
|
||||
if string(data[p:p+4]) == "qt " {
|
||||
copy(data[p:p+4], []byte("isom"))
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// normalizeQuickTimeAudioEntry rewrites a version-1 QuickTime sound sample
|
||||
// entry into a plain version-0 AudioSampleEntry, dropping the 16-byte v1
|
||||
// extension. base is the buffer's absolute file offset (0 for a whole-file
|
||||
// buffer, the moov offset for a moov-only buffer).
|
||||
func normalizeQuickTimeAudioEntry(data []byte, base int64) []byte {
|
||||
loc, ok := locateAC4Entry(data)
|
||||
if !ok {
|
||||
return data
|
||||
}
|
||||
entry := loc.entry
|
||||
verPos := entry.body() + 8
|
||||
if verPos+2 > entry.end() {
|
||||
return data
|
||||
}
|
||||
if binary.BigEndian.Uint16(data[verPos:verPos+2]) != 1 {
|
||||
return data // already v0 (or v2, left untouched)
|
||||
}
|
||||
|
||||
// The v1 QuickTime sound extension is the 16 bytes following the 20-byte v0
|
||||
// audio fields (samplesPerPacket, bytesPerPacket, bytesPerFrame, bytesPerSample).
|
||||
extStart := entry.body() + 8 + 20
|
||||
extEnd := extStart + 16
|
||||
if extEnd > entry.end() {
|
||||
return data
|
||||
}
|
||||
delta := int64(-16)
|
||||
|
||||
binary.BigEndian.PutUint16(data[verPos:verPos+2], 0)
|
||||
shiftChunkOffsets(data, loc.chain[0], base+extStart, delta)
|
||||
for _, b := range loc.chain {
|
||||
growBoxSize(data, b, delta)
|
||||
}
|
||||
growBoxSize(data, entry, delta)
|
||||
|
||||
out := make([]byte, 0, len(data)-16)
|
||||
out = append(out, data[:extStart]...)
|
||||
out = append(out, data[extEnd:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeQuickTimeAudioToMP4 rewrites a QuickTime-flavored file (FFmpeg mov
|
||||
// muxer output: ftyp brand "qt " and a version-1 sound sample entry) into a
|
||||
// standard ISO MP4: an isom/mp42 brand and a plain version-0 AudioSampleEntry.
|
||||
// Windows Media Foundation (and other strict parsers) reject the QuickTime
|
||||
// flavor for AC-4 even when dac4 is present.
|
||||
func normalizeQuickTimeAudioToMP4(data []byte) []byte {
|
||||
normalizeQuickTimeBrandsInBuf(data)
|
||||
return normalizeQuickTimeAudioEntry(data, 0)
|
||||
}
|
||||
|
||||
// EnsureAC4ConfigBox makes a decrypted AC-4 MP4 standards-compliant and
|
||||
// playable: it normalizes FFmpeg's QuickTime-flavored mov output to an ISO MP4
|
||||
// and injects the AC-4 configuration box (dac4) into the ac-4 sample entry. The
|
||||
// dac4 box is copied verbatim from sourcePath (the original MP4, whose plaintext
|
||||
// moov still carries it). No-op when the file has no AC-4 track. Only the ftyp
|
||||
// and moov boxes are held in memory; the audio bulk is streamed.
|
||||
func EnsureAC4ConfigBox(decryptedPath, sourcePath string) error {
|
||||
f, err := os.Open(decryptedPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
// A non-MP4 decrypt output (e.g. a raw FLAC stream) is not an AC-4 file;
|
||||
// bail out before the box parser reports it as a corrupt MP4. A real
|
||||
// ISO-BMFF box type is four printable ASCII bytes.
|
||||
var head [8]byte
|
||||
if _, err := f.ReadAt(head[:], 0); err != nil {
|
||||
f.Close()
|
||||
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
for _, c := range head[4:8] {
|
||||
if c < 0x20 || c > 0x7e {
|
||||
f.Close()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
moovBuf, moovOffset, moovFound, err := loadTopLevelMP4Box(f, info.Size(), "moov")
|
||||
if err != nil || !moovFound {
|
||||
f.Close()
|
||||
return err // parse/read failure, or no moov: nothing to do
|
||||
}
|
||||
ftypBuf, ftypOffset, ftypFound, err := loadTopLevelMP4Box(f, info.Size(), "ftyp")
|
||||
f.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
moovLen := int64(len(moovBuf))
|
||||
|
||||
if _, ok := locateAC4Entry(moovBuf); !ok {
|
||||
return nil // not an AC-4 file; nothing to do
|
||||
}
|
||||
|
||||
ftypChanged := ftypFound && normalizeQuickTimeBrandsInBuf(ftypBuf)
|
||||
dst := normalizeQuickTimeAudioEntry(moovBuf, moovOffset)
|
||||
|
||||
loc, ok := locateAC4Entry(dst)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
hdrLen, ok := audioSampleEntryHeaderLen(dst, loc.entry)
|
||||
if !ok {
|
||||
return fmt.Errorf("malformed ac-4 sample entry")
|
||||
}
|
||||
childStart := loc.entry.body() + hdrLen
|
||||
if _, has := findChildMP4(dst, childStart, loc.entry.end(), "dac4"); has {
|
||||
// Already has dac4; still persist any normalization changes.
|
||||
return writeAC4Sections(decryptedPath, ftypChanged, ftypBuf, ftypOffset, dst, moovOffset, moovLen)
|
||||
}
|
||||
|
||||
srcF, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srcInfo, err := srcF.Stat()
|
||||
if err != nil {
|
||||
srcF.Close()
|
||||
return err
|
||||
}
|
||||
srcMoovBuf, _, srcFound, err := loadTopLevelMP4Box(srcF, srcInfo.Size(), "moov")
|
||||
srcF.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !srcFound {
|
||||
return fmt.Errorf("source has no moov")
|
||||
}
|
||||
srcMoov, ok := readMP4Box(srcMoovBuf, 0)
|
||||
if !ok {
|
||||
return fmt.Errorf("source has no moov")
|
||||
}
|
||||
dac4Box, ok := findBoxBySignature(srcMoovBuf, srcMoov.body(), srcMoov.end(), "dac4")
|
||||
if !ok {
|
||||
return fmt.Errorf("dac4 not found in source")
|
||||
}
|
||||
dac4 := append([]byte{}, srcMoovBuf[dac4Box.offset:dac4Box.end()]...)
|
||||
|
||||
insertPos := childStart
|
||||
delta := int64(len(dac4))
|
||||
|
||||
shiftChunkOffsets(dst, loc.chain[0], moovOffset+insertPos, delta)
|
||||
for _, b := range loc.chain {
|
||||
growBoxSize(dst, b, delta)
|
||||
}
|
||||
growBoxSize(dst, loc.entry, delta)
|
||||
|
||||
out := make([]byte, 0, len(dst)+len(dac4))
|
||||
out = append(out, dst[:insertPos]...)
|
||||
out = append(out, dac4...)
|
||||
out = append(out, dst[insertPos:]...)
|
||||
|
||||
return writeAC4Sections(decryptedPath, ftypChanged, ftypBuf, ftypOffset, out, moovOffset, moovLen)
|
||||
}
|
||||
|
||||
// writeAC4Sections streams the edited moov (and, when changed, ftyp) back into
|
||||
// the file.
|
||||
func writeAC4Sections(path string, ftypChanged bool, ftypBuf []byte, ftypOffset int64, moovBuf []byte, moovOffset, origMoovLen int64) error {
|
||||
sections := []fileSection{
|
||||
{start: moovOffset, end: moovOffset + origMoovLen, data: moovBuf},
|
||||
}
|
||||
if ftypChanged {
|
||||
sections = append(sections, fileSection{
|
||||
start: ftypOffset,
|
||||
end: ftypOffset + int64(len(ftypBuf)),
|
||||
data: ftypBuf,
|
||||
})
|
||||
}
|
||||
return replaceFileSectionsStreaming(path, sections)
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func mp4TestBox(typ string, body []byte) []byte {
|
||||
out := make([]byte, 8+len(body))
|
||||
binary.BigEndian.PutUint32(out[:4], uint32(len(out)))
|
||||
copy(out[4:8], typ)
|
||||
copy(out[8:], body)
|
||||
return out
|
||||
}
|
||||
|
||||
func mp4TestAC4Tree(entryBody []byte) []byte {
|
||||
entry := mp4TestBox("ac-4", entryBody)
|
||||
stsdBody := append([]byte{
|
||||
0, 0, 0, 0, // version/flags
|
||||
0, 0, 0, 1, // entry_count
|
||||
}, entry...)
|
||||
stsd := mp4TestBox("stsd", stsdBody)
|
||||
stbl := mp4TestBox("stbl", stsd)
|
||||
minf := mp4TestBox("minf", stbl)
|
||||
mdia := mp4TestBox("mdia", minf)
|
||||
trak := mp4TestBox("trak", mdia)
|
||||
moov := mp4TestBox("moov", trak)
|
||||
return moov
|
||||
}
|
||||
|
||||
func shortAC4SampleEntryBody(version uint16) []byte {
|
||||
body := make([]byte, 10)
|
||||
binary.BigEndian.PutUint16(body[8:10], version)
|
||||
return body
|
||||
}
|
||||
|
||||
func TestNormalizeQuickTimeAudioToMP4IgnoresTruncatedAC4Entry(t *testing.T) {
|
||||
input := mp4TestAC4Tree(shortAC4SampleEntryBody(1))
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("normalizeQuickTimeAudioToMP4 panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
got := normalizeQuickTimeAudioToMP4(append([]byte{}, input...))
|
||||
if !bytes.Equal(got, input) {
|
||||
t.Fatal("truncated QuickTime AC-4 entry should be left unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureAC4ConfigBoxRejectsTruncatedAC4Entry(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
decryptedPath := filepath.Join(dir, "decrypted.mp4")
|
||||
sourcePath := filepath.Join(dir, "source.mp4")
|
||||
|
||||
if err := os.WriteFile(decryptedPath, mp4TestAC4Tree(shortAC4SampleEntryBody(2)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(sourcePath, mp4TestBox("moov", mp4TestBox("dac4", []byte{1, 2, 3, 4})), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("EnsureAC4ConfigBox panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := EnsureAC4ConfigBox(decryptedPath, sourcePath); err == nil {
|
||||
t.Fatal("expected malformed AC-4 sample entry error")
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ac4Metadata mirrors the tag fields the app embeds for other formats. Numeric
|
||||
// fields are strings because they arrive as a JSON-encoded map of strings.
|
||||
type ac4Metadata struct {
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
Album string `json:"album"`
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
Date string `json:"date"`
|
||||
Genre string `json:"genre"`
|
||||
Composer string `json:"composer"`
|
||||
TrackNumber string `json:"trackNumber"`
|
||||
TotalTracks string `json:"totalTracks"`
|
||||
DiscNumber string `json:"discNumber"`
|
||||
TotalDiscs string `json:"totalDiscs"`
|
||||
ISRC string `json:"isrc"`
|
||||
Label string `json:"label"`
|
||||
Copyright string `json:"copyright"`
|
||||
Lyrics string `json:"lyrics"`
|
||||
}
|
||||
|
||||
func atoiSafe(s string) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func itunesTextTag(atomType, value string) []byte {
|
||||
data := make([]byte, 8+len(value))
|
||||
binary.BigEndian.PutUint32(data[0:4], 1) // well-known type 1 = UTF-8
|
||||
copy(data[8:], []byte(value))
|
||||
return buildM4AAtom(atomType, buildM4AAtom("data", data))
|
||||
}
|
||||
|
||||
func itunesNumberPairTag(atomType string, number, total int) []byte {
|
||||
payload := make([]byte, 8)
|
||||
binary.BigEndian.PutUint16(payload[2:4], uint16(number))
|
||||
binary.BigEndian.PutUint16(payload[4:6], uint16(total))
|
||||
data := make([]byte, 8+len(payload))
|
||||
binary.BigEndian.PutUint32(data[0:4], 0) // type 0 = implicit/binary
|
||||
copy(data[8:], payload)
|
||||
return buildM4AAtom(atomType, buildM4AAtom("data", data))
|
||||
}
|
||||
|
||||
func itunesCoverTag(image []byte) []byte {
|
||||
typeCode := uint32(13) // JPEG
|
||||
if len(image) >= 8 &&
|
||||
image[0] == 0x89 && image[1] == 0x50 && image[2] == 0x4E && image[3] == 0x47 {
|
||||
typeCode = 14 // PNG
|
||||
}
|
||||
data := make([]byte, 8+len(image))
|
||||
binary.BigEndian.PutUint32(data[0:4], typeCode)
|
||||
copy(data[8:], image)
|
||||
return buildM4AAtom("covr", buildM4AAtom("data", data))
|
||||
}
|
||||
|
||||
func itunesMetadataHandler() []byte {
|
||||
payload := make([]byte, 0, 25)
|
||||
payload = append(payload, 0, 0, 0, 0) // version + flags
|
||||
payload = append(payload, 0, 0, 0, 0) // pre_defined
|
||||
payload = append(payload, []byte("mdir")...) // handler type
|
||||
payload = append(payload, []byte("appl")...) // reserved[0]
|
||||
payload = append(payload, 0, 0, 0, 0, 0, 0, 0, 0) // reserved[1..2]
|
||||
payload = append(payload, 0) // empty name
|
||||
return buildM4AAtom("hdlr", payload)
|
||||
}
|
||||
|
||||
// buildITunesUdta assembles a fresh udta>meta>(hdlr+ilst) box from metadata.
|
||||
func buildITunesUdta(md ac4Metadata, cover []byte) []byte {
|
||||
ilst := make([]byte, 0, 256)
|
||||
add := func(atomType, value string) {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
ilst = append(ilst, itunesTextTag(atomType, value)...)
|
||||
}
|
||||
}
|
||||
add("\xa9nam", md.Title)
|
||||
add("\xa9ART", md.Artist)
|
||||
add("\xa9alb", md.Album)
|
||||
add("aART", md.AlbumArtist)
|
||||
add("\xa9day", md.Date)
|
||||
add("\xa9gen", md.Genre)
|
||||
add("\xa9wrt", md.Composer)
|
||||
if tn := atoiSafe(md.TrackNumber); tn > 0 {
|
||||
ilst = append(ilst, itunesNumberPairTag("trkn", tn, atoiSafe(md.TotalTracks))...)
|
||||
}
|
||||
if dn := atoiSafe(md.DiscNumber); dn > 0 {
|
||||
ilst = append(ilst, itunesNumberPairTag("disk", dn, atoiSafe(md.TotalDiscs))...)
|
||||
}
|
||||
if strings.TrimSpace(md.ISRC) != "" {
|
||||
ilst = append(ilst, buildM4AFreeformAtom("ISRC", strings.TrimSpace(md.ISRC))...)
|
||||
}
|
||||
if strings.TrimSpace(md.Label) != "" {
|
||||
ilst = append(ilst, buildM4AFreeformAtom("LABEL", strings.TrimSpace(md.Label))...)
|
||||
}
|
||||
if strings.TrimSpace(md.Copyright) != "" {
|
||||
add("cprt", md.Copyright)
|
||||
}
|
||||
if strings.TrimSpace(md.Lyrics) != "" {
|
||||
add("\xa9lyr", md.Lyrics)
|
||||
}
|
||||
if len(cover) > 0 {
|
||||
ilst = append(ilst, itunesCoverTag(cover)...)
|
||||
}
|
||||
|
||||
ilstBox := buildM4AAtom("ilst", ilst)
|
||||
metaPayload := append([]byte{0, 0, 0, 0}, itunesMetadataHandler()...)
|
||||
metaPayload = append(metaPayload, ilstBox...)
|
||||
meta := buildM4AAtom("meta", metaPayload)
|
||||
return buildM4AAtom("udta", meta)
|
||||
}
|
||||
|
||||
// writeMP4iTunesMetadata replaces (or inserts) a udta>meta>ilst metadata box in
|
||||
// the moov of an MP4 buffer and returns the rewritten bytes. base is the
|
||||
// buffer's absolute file offset (0 for a whole-file buffer) so stco/co64
|
||||
// shifts compare against the absolute positions the entries hold.
|
||||
func writeMP4iTunesMetadata(data []byte, base int64, md ac4Metadata, cover []byte) []byte {
|
||||
moov, ok := findChildMP4(data, 0, int64(len(data)), "moov")
|
||||
if !ok {
|
||||
return data
|
||||
}
|
||||
newUdta := buildITunesUdta(md, cover)
|
||||
|
||||
if udta, ok := findChildMP4(data, moov.body(), moov.end(), "udta"); ok {
|
||||
delta := int64(len(newUdta)) - udta.size
|
||||
shiftChunkOffsets(data, moov, base+udta.offset, delta)
|
||||
growBoxSize(data, moov, delta)
|
||||
out := make([]byte, 0, len(data)+len(newUdta))
|
||||
out = append(out, data[:udta.offset]...)
|
||||
out = append(out, newUdta...)
|
||||
out = append(out, data[udta.end():]...)
|
||||
return out
|
||||
}
|
||||
|
||||
delta := int64(len(newUdta))
|
||||
insertPos := moov.end()
|
||||
shiftChunkOffsets(data, moov, base+insertPos, delta)
|
||||
growBoxSize(data, moov, delta)
|
||||
out := make([]byte, 0, len(data)+len(newUdta))
|
||||
out = append(out, data[:insertPos]...)
|
||||
out = append(out, newUdta...)
|
||||
out = append(out, data[insertPos:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// WriteAC4MetadataIfApplicable writes iTunes metadata into an AC-4 MP4. Returns
|
||||
// true when the file was an AC-4 track and metadata was written; false when the
|
||||
// file is not AC-4 (the caller should fall back to its normal metadata path).
|
||||
// Only the moov box is held in memory; the audio bulk is streamed.
|
||||
func WriteAC4MetadataIfApplicable(decryptedPath, metadataJSON, coverPath string) (bool, error) {
|
||||
f, err := os.Open(decryptedPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return false, err
|
||||
}
|
||||
moovBuf, moovOffset, found, err := loadTopLevelMP4Box(f, info.Size(), "moov")
|
||||
f.Close()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !found {
|
||||
return false, nil
|
||||
}
|
||||
moovLen := int64(len(moovBuf))
|
||||
if _, ok := locateAC4Entry(moovBuf); !ok {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var md ac4Metadata
|
||||
if strings.TrimSpace(metadataJSON) != "" {
|
||||
_ = json.Unmarshal([]byte(metadataJSON), &md)
|
||||
}
|
||||
var cover []byte
|
||||
if strings.TrimSpace(coverPath) != "" {
|
||||
if b, err := os.ReadFile(coverPath); err == nil {
|
||||
cover = b
|
||||
}
|
||||
}
|
||||
|
||||
out := writeMP4iTunesMetadata(moovBuf, moovOffset, md, cover)
|
||||
if err := replaceFileSectionsStreaming(decryptedPath, []fileSection{
|
||||
{start: moovOffset, end: moovOffset + moovLen, data: out},
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC)
|
||||
type AmazonDownloader struct {
|
||||
client *http.Client
|
||||
regions []string // us, eu regions for DoubleDouble service
|
||||
}
|
||||
|
||||
var (
|
||||
// Global Amazon downloader instance for connection reuse
|
||||
globalAmazonDownloader *AmazonDownloader
|
||||
amazonDownloaderOnce sync.Once
|
||||
)
|
||||
|
||||
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
|
||||
type DoubleDoubleSubmitResponse struct {
|
||||
Success bool `json:"success"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// DoubleDoubleStatusResponse is the response from DoubleDouble status endpoint
|
||||
type DoubleDoubleStatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
FriendlyStatus string `json:"friendlyStatus"`
|
||||
URL string `json:"url"`
|
||||
Current struct {
|
||||
Name string `json:"name"`
|
||||
Artist string `json:"artist"`
|
||||
} `json:"current"`
|
||||
}
|
||||
|
||||
// amazonArtistsMatch checks if the artist names are similar enough
|
||||
func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||
|
||||
// Exact match
|
||||
if normExpected == normFound {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if one contains the other
|
||||
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check first artist (before comma or feat)
|
||||
expectedFirst := strings.Split(normExpected, ",")[0]
|
||||
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
||||
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
||||
expectedFirst = strings.TrimSpace(expectedFirst)
|
||||
|
||||
foundFirst := strings.Split(normFound, ",")[0]
|
||||
foundFirst = strings.Split(foundFirst, " feat")[0]
|
||||
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
||||
foundFirst = strings.TrimSpace(foundFirst)
|
||||
|
||||
if expectedFirst == foundFirst {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if first artist is contained in the other
|
||||
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
||||
return true
|
||||
}
|
||||
|
||||
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
||||
// assume they're the same artist with different transliteration
|
||||
expectedASCII := amazonIsASCIIString(expectedArtist)
|
||||
foundASCII := amazonIsASCIIString(foundArtist)
|
||||
if expectedASCII != foundASCII {
|
||||
fmt.Printf("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// amazonIsASCIIString checks if a string contains only ASCII characters
|
||||
func amazonIsASCIIString(s string) bool {
|
||||
for _, r := range s {
|
||||
if r > 127 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// NewAmazonDownloader creates a new Amazon downloader (returns singleton for connection reuse)
|
||||
func NewAmazonDownloader() *AmazonDownloader {
|
||||
amazonDownloaderOnce.Do(func() {
|
||||
globalAmazonDownloader = &AmazonDownloader{
|
||||
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
|
||||
regions: []string{"us", "eu"}, // Same regions as PC
|
||||
}
|
||||
})
|
||||
return globalAmazonDownloader
|
||||
}
|
||||
|
||||
// GetAvailableAPIs returns list of available DoubleDouble regions
|
||||
// Uses same service as PC version (doubledouble.top)
|
||||
func (a *AmazonDownloader) GetAvailableAPIs() []string {
|
||||
// DoubleDouble service regions (same as PC)
|
||||
// Format: https://{region}.doubledouble.top
|
||||
var apis []string
|
||||
for _, region := range a.regions {
|
||||
apis = append(apis, fmt.Sprintf("https://%s.doubledouble.top", region))
|
||||
}
|
||||
return apis
|
||||
}
|
||||
|
||||
|
||||
// downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC)
|
||||
// This uses submit → poll → download mechanism
|
||||
// Internal function - not exported to gomobile
|
||||
func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir string) (string, string, string, error) {
|
||||
var lastError error
|
||||
|
||||
for _, region := range a.regions {
|
||||
fmt.Printf("[Amazon] Trying region: %s...\n", region)
|
||||
|
||||
// Build base URL for DoubleDouble service
|
||||
// Decode base64 service URL (same as PC)
|
||||
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https://
|
||||
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
|
||||
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
||||
|
||||
// Step 1: Submit download request
|
||||
encodedURL := url.QueryEscape(amazonURL)
|
||||
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
|
||||
|
||||
req, err := http.NewRequest("GET", submitURL, nil)
|
||||
if err != nil {
|
||||
lastError = fmt.Errorf("failed to create request: %w", err)
|
||||
continue
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
fmt.Println("[Amazon] Submitting download request...")
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
lastError = fmt.Errorf("failed to submit request: %w", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
lastError = fmt.Errorf("submit failed with status %d", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
var submitResp DoubleDoubleSubmitResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&submitResp); err != nil {
|
||||
resp.Body.Close()
|
||||
lastError = fmt.Errorf("failed to decode submit response: %w", err)
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if !submitResp.Success || submitResp.ID == "" {
|
||||
lastError = fmt.Errorf("submit request failed")
|
||||
continue
|
||||
}
|
||||
|
||||
downloadID := submitResp.ID
|
||||
fmt.Printf("[Amazon] Download ID: %s\n", downloadID)
|
||||
|
||||
// Step 2: Poll for completion
|
||||
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
|
||||
fmt.Println("[Amazon] Waiting for download to complete...")
|
||||
|
||||
maxWait := 300 * time.Second // 5 minutes max wait
|
||||
elapsed := time.Duration(0)
|
||||
pollInterval := 3 * time.Second
|
||||
|
||||
for elapsed < maxWait {
|
||||
time.Sleep(pollInterval)
|
||||
elapsed += pollInterval
|
||||
|
||||
statusReq, err := http.NewRequest("GET", statusURL, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
statusReq.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
statusResp, err := a.client.Do(statusReq)
|
||||
if err != nil {
|
||||
fmt.Printf("\r[Amazon] Status check failed, retrying...")
|
||||
continue
|
||||
}
|
||||
|
||||
if statusResp.StatusCode != 200 {
|
||||
statusResp.Body.Close()
|
||||
fmt.Printf("\r[Amazon] Status check failed (status %d), retrying...", statusResp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
var status DoubleDoubleStatusResponse
|
||||
if err := json.NewDecoder(statusResp.Body).Decode(&status); err != nil {
|
||||
statusResp.Body.Close()
|
||||
fmt.Printf("\r[Amazon] Invalid JSON response, retrying...")
|
||||
continue
|
||||
}
|
||||
statusResp.Body.Close()
|
||||
|
||||
if status.Status == "done" {
|
||||
fmt.Println("\n[Amazon] Download ready!")
|
||||
|
||||
// Build download URL
|
||||
fileURL := status.URL
|
||||
if strings.HasPrefix(fileURL, "./") {
|
||||
fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:])
|
||||
} else if strings.HasPrefix(fileURL, "/") {
|
||||
fileURL = fmt.Sprintf("%s%s", baseURL, fileURL)
|
||||
}
|
||||
|
||||
trackName := status.Current.Name
|
||||
artist := status.Current.Artist
|
||||
|
||||
fmt.Printf("[Amazon] Downloading: %s - %s\n", artist, trackName)
|
||||
return fileURL, trackName, artist, nil
|
||||
|
||||
} else if status.Status == "error" {
|
||||
errorMsg := status.FriendlyStatus
|
||||
if errorMsg == "" {
|
||||
errorMsg = "Unknown error"
|
||||
}
|
||||
lastError = fmt.Errorf("processing failed: %s", errorMsg)
|
||||
break
|
||||
} else {
|
||||
// Still processing
|
||||
friendlyStatus := status.FriendlyStatus
|
||||
if friendlyStatus == "" {
|
||||
friendlyStatus = status.Status
|
||||
}
|
||||
fmt.Printf("\r[Amazon] %s...", friendlyStatus)
|
||||
}
|
||||
}
|
||||
|
||||
if elapsed >= maxWait {
|
||||
lastError = fmt.Errorf("download timeout")
|
||||
fmt.Printf("\n[Amazon] Error with %s region: %v\n", region, lastError)
|
||||
continue
|
||||
}
|
||||
|
||||
if lastError != nil {
|
||||
fmt.Printf("\n[Amazon] Error with %s region: %v\n", region, lastError)
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError)
|
||||
}
|
||||
|
||||
|
||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||
// Initialize item progress (required for all downloads)
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Set total bytes if available
|
||||
if resp.ContentLength > 0 && itemID != "" {
|
||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Use buffered writer for better performance (256KB buffer)
|
||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||
defer bufWriter.Flush()
|
||||
|
||||
// Use item progress writer with buffered output
|
||||
var bytesWritten int64
|
||||
if itemID != "" {
|
||||
pw := NewItemProgressWriter(bufWriter, itemID)
|
||||
bytesWritten, err = io.Copy(pw, resp.Body)
|
||||
} else {
|
||||
// Fallback: direct copy without progress tracking
|
||||
bytesWritten, 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))
|
||||
return nil
|
||||
}
|
||||
|
||||
// AmazonDownloadResult contains download result with quality info
|
||||
type AmazonDownloadResult struct {
|
||||
FilePath string
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
}
|
||||
|
||||
// downloadFromAmazon downloads a track using the request parameters
|
||||
// Uses DoubleDouble service (same as PC version)
|
||||
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
downloader := NewAmazonDownloader()
|
||||
|
||||
// Check for existing file first
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
|
||||
// Get Amazon URL from SongLink
|
||||
songlink := NewSongLinkClient()
|
||||
availability, err := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||
if err != nil {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
||||
}
|
||||
|
||||
if !availability.Amazon || availability.AmazonURL == "" {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
||||
}
|
||||
|
||||
// Create output directory if needed
|
||||
if req.OutputDir != "." {
|
||||
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Download using DoubleDouble service (same as PC)
|
||||
downloadURL, trackName, artistName, err := downloader.downloadFromDoubleDoubleService(availability.AmazonURL, req.OutputDir)
|
||||
if err != nil {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
|
||||
// Verify artist matches
|
||||
if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) {
|
||||
fmt.Printf("[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)
|
||||
}
|
||||
|
||||
// Log match found
|
||||
fmt.Printf("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
|
||||
|
||||
// Build filename using Spotify metadata (more accurate)
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||
"title": req.TrackName,
|
||||
"artist": req.ArtistName,
|
||||
"album": req.AlbumName,
|
||||
"track": req.TrackNumber,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath := filepath.Join(req.OutputDir, filename)
|
||||
|
||||
// Check if file already exists
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
|
||||
// START PARALLEL: Fetch cover and lyrics while downloading audio
|
||||
var parallelResult *ParallelDownloadResult
|
||||
parallelDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(parallelDone)
|
||||
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 {
|
||||
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)
|
||||
// This makes the UI show "Finalizing..." while embedding happens
|
||||
if req.ItemID != "" {
|
||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||
SetItemFinalizing(req.ItemID)
|
||||
}
|
||||
|
||||
// Log track info from DoubleDouble (for debugging)
|
||||
if trackName != "" && artistName != "" {
|
||||
fmt.Printf("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
|
||||
}
|
||||
|
||||
// Embed metadata using Spotify data (more accurate than DoubleDouble)
|
||||
metadata := Metadata{
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
Date: req.ReleaseDate,
|
||||
TrackNumber: req.TrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: req.DiscNumber,
|
||||
ISRC: req.ISRC,
|
||||
}
|
||||
|
||||
// Use cover data from parallel fetch
|
||||
var coverData []byte
|
||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||
coverData = parallelResult.CoverData
|
||||
fmt.Printf("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||
}
|
||||
|
||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
|
||||
// Embed lyrics from parallel fetch
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
fmt.Printf("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); 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")
|
||||
|
||||
// Read actual quality from the downloaded FLAC file
|
||||
// Amazon API doesn't provide quality info, but we can read it from the file itself
|
||||
quality, err := GetAudioQuality(outputPath)
|
||||
if err != nil {
|
||||
fmt.Printf("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||
// Return 0 to indicate unknown quality
|
||||
return AmazonDownloadResult{
|
||||
FilePath: outputPath,
|
||||
BitDepth: 0,
|
||||
SampleRate: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
fmt.Printf("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
return AmazonDownloadResult{
|
||||
FilePath: outputPath,
|
||||
BitDepth: quality.BitDepth,
|
||||
SampleRate: quality.SampleRate,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,605 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// APEv2 tag format constants.
|
||||
const (
|
||||
apeTagPreamble = "APETAGEX"
|
||||
apeTagHeaderSize = 32
|
||||
apeTagVersion2 = 2000
|
||||
apeTagFlagHeader = 1 << 29 // bit 29: this is the header, not the footer
|
||||
apeTagFlagReadOnly = 1 << 0
|
||||
// Item flags: bits 1-2 encode content type
|
||||
// (00: UTF-8 text, 01: binary data, 10: external link)
|
||||
apeItemFlagBinary = 1 << 1
|
||||
)
|
||||
|
||||
// APETagItem represents a single key-value item in an APEv2 tag.
|
||||
type APETagItem struct {
|
||||
Key string
|
||||
Value string
|
||||
Flags uint32
|
||||
}
|
||||
|
||||
// APETag represents a complete APEv2 tag block.
|
||||
type APETag struct {
|
||||
Version uint32
|
||||
Items []APETagItem
|
||||
ReadOnly bool
|
||||
}
|
||||
|
||||
// ReadAPETags reads APEv2 tags from a file.
|
||||
// APEv2 tags are typically appended at the end of the file.
|
||||
// The layout is: [audio data] [APEv2 header (optional)] [items...] [APEv2 footer]
|
||||
// We locate the footer first (last 32 bytes), then read the tag block.
|
||||
func ReadAPETags(filePath string) (*APETag, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to stat file: %w", err)
|
||||
}
|
||||
fileSize := fi.Size()
|
||||
|
||||
if fileSize < apeTagHeaderSize {
|
||||
return nil, fmt.Errorf("file too small for APE tag")
|
||||
}
|
||||
|
||||
// The footer is the last 32 bytes before any ID3v1 tag (128 bytes).
|
||||
tag, err := readAPETagAtOffset(f, fileSize, fileSize-apeTagHeaderSize)
|
||||
if err == nil {
|
||||
return tag, nil
|
||||
}
|
||||
|
||||
// Retry: skip ID3v1 tag (128 bytes) if present
|
||||
if fileSize > apeTagHeaderSize+128 {
|
||||
tag, err = readAPETagAtOffset(f, fileSize, fileSize-apeTagHeaderSize-128)
|
||||
if err == nil {
|
||||
return tag, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no APEv2 tag found")
|
||||
}
|
||||
|
||||
func readAPETagAtOffset(f *os.File, fileSize, footerOffset int64) (*APETag, error) {
|
||||
if footerOffset < 0 || footerOffset+apeTagHeaderSize > fileSize {
|
||||
return nil, fmt.Errorf("invalid footer offset")
|
||||
}
|
||||
|
||||
footer := make([]byte, apeTagHeaderSize)
|
||||
if _, err := f.ReadAt(footer, footerOffset); err != nil {
|
||||
return nil, fmt.Errorf("failed to read APE footer: %w", err)
|
||||
}
|
||||
|
||||
if string(footer[0:8]) != apeTagPreamble {
|
||||
return nil, fmt.Errorf("APE preamble not found")
|
||||
}
|
||||
|
||||
version := binary.LittleEndian.Uint32(footer[8:12])
|
||||
tagSize := binary.LittleEndian.Uint32(footer[12:16]) // size of items + footer (32 bytes)
|
||||
itemCount := binary.LittleEndian.Uint32(footer[16:20])
|
||||
flags := binary.LittleEndian.Uint32(footer[20:24])
|
||||
|
||||
if version != apeTagVersion2 && version != 1000 {
|
||||
return nil, fmt.Errorf("unsupported APE tag version: %d", version)
|
||||
}
|
||||
if tagSize < apeTagHeaderSize {
|
||||
return nil, fmt.Errorf("APE tag size too small: %d", tagSize)
|
||||
}
|
||||
if itemCount > 1000 {
|
||||
return nil, fmt.Errorf("APE tag item count too large: %d", itemCount)
|
||||
}
|
||||
|
||||
// This should be the footer (bit 29 clear)
|
||||
isHeader := (flags & apeTagFlagHeader) != 0
|
||||
if isHeader {
|
||||
return nil, fmt.Errorf("expected APE footer but found header")
|
||||
}
|
||||
|
||||
// tagSize includes items + footer (32 bytes), but NOT the header.
|
||||
itemsSize := int64(tagSize) - apeTagHeaderSize
|
||||
if itemsSize < 0 {
|
||||
return nil, fmt.Errorf("invalid APE tag: items size negative")
|
||||
}
|
||||
|
||||
itemsOffset := footerOffset - itemsSize
|
||||
if itemsOffset < 0 {
|
||||
return nil, fmt.Errorf("APE tag items extend before file start")
|
||||
}
|
||||
|
||||
itemsData := make([]byte, itemsSize)
|
||||
if _, err := f.ReadAt(itemsData, itemsOffset); err != nil {
|
||||
return nil, fmt.Errorf("failed to read APE items: %w", err)
|
||||
}
|
||||
|
||||
items, err := parseAPEItems(itemsData, int(itemCount))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse APE items: %w", err)
|
||||
}
|
||||
|
||||
return &APETag{
|
||||
Version: version,
|
||||
Items: items,
|
||||
ReadOnly: (flags & apeTagFlagReadOnly) != 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseAPEItems(data []byte, count int) ([]APETagItem, error) {
|
||||
items := make([]APETagItem, 0, count)
|
||||
pos := 0
|
||||
|
||||
for i := 0; i < count && pos < len(data); i++ {
|
||||
if pos+8 > len(data) {
|
||||
break
|
||||
}
|
||||
|
||||
valueSize := int(binary.LittleEndian.Uint32(data[pos : pos+4]))
|
||||
itemFlags := binary.LittleEndian.Uint32(data[pos+4 : pos+8])
|
||||
pos += 8
|
||||
|
||||
// Key is null-terminated ASCII (2-255 bytes, case-insensitive)
|
||||
keyEnd := pos
|
||||
for keyEnd < len(data) && data[keyEnd] != 0 {
|
||||
keyEnd++
|
||||
}
|
||||
if keyEnd >= len(data) {
|
||||
break
|
||||
}
|
||||
|
||||
key := string(data[pos:keyEnd])
|
||||
pos = keyEnd + 1
|
||||
|
||||
if pos+valueSize > len(data) {
|
||||
break
|
||||
}
|
||||
value := string(data[pos : pos+valueSize])
|
||||
pos += valueSize
|
||||
|
||||
items = append(items, APETagItem{
|
||||
Key: key,
|
||||
Value: value,
|
||||
Flags: itemFlags,
|
||||
})
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// WriteAPETags writes APEv2 tags to the end of a file.
|
||||
// If the file already has APEv2 tags, they are replaced.
|
||||
// The tag is written with both header and footer.
|
||||
func WriteAPETags(filePath string, tag *APETag) error {
|
||||
existingSize, err := findExistingAPETagSize(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check existing APE tag: %w", err)
|
||||
}
|
||||
|
||||
tagData, err := marshalAPETag(tag)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal APE tag: %w", err)
|
||||
}
|
||||
|
||||
if existingSize > 0 {
|
||||
fi, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stat file: %w", err)
|
||||
}
|
||||
newSize := fi.Size() - int64(existingSize)
|
||||
if err := os.Truncate(filePath, newSize); err != nil {
|
||||
return fmt.Errorf("failed to truncate existing APE tag: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(filePath, os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open file for writing: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := f.Write(tagData); err != nil {
|
||||
return fmt.Errorf("failed to write APE tag: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// findExistingAPETagSize returns the total size of an existing APE tag
|
||||
// (header + items + footer) at the end of the file, or 0 if none exists.
|
||||
func findExistingAPETagSize(filePath string) (int64, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
fileSize := fi.Size()
|
||||
|
||||
offsets := []int64{fileSize - apeTagHeaderSize}
|
||||
if fileSize > apeTagHeaderSize+128 {
|
||||
offsets = append(offsets, fileSize-apeTagHeaderSize-128)
|
||||
}
|
||||
|
||||
for _, offset := range offsets {
|
||||
if offset < 0 {
|
||||
continue
|
||||
}
|
||||
footer := make([]byte, apeTagHeaderSize)
|
||||
if _, err := f.ReadAt(footer, offset); err != nil {
|
||||
continue
|
||||
}
|
||||
if string(footer[0:8]) != apeTagPreamble {
|
||||
continue
|
||||
}
|
||||
|
||||
flags := binary.LittleEndian.Uint32(footer[20:24])
|
||||
if (flags & apeTagFlagHeader) != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
tagSize := int64(binary.LittleEndian.Uint32(footer[12:16]))
|
||||
|
||||
hasHeader := (flags & (1 << 31)) != 0 // bit 31 = tag contains header
|
||||
totalSize := tagSize
|
||||
if hasHeader {
|
||||
totalSize += apeTagHeaderSize
|
||||
}
|
||||
|
||||
// Include any trailing data after the footer (e.g. ID3v1 128-byte tag).
|
||||
// When truncating, we must remove the APE tag AND everything after it.
|
||||
trailingBytes := fileSize - (offset + apeTagHeaderSize)
|
||||
totalSize += trailingBytes
|
||||
|
||||
return totalSize, nil
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// marshalAPETag serializes an APETag into bytes (header + items + footer).
|
||||
func marshalAPETag(tag *APETag) ([]byte, error) {
|
||||
if tag == nil || len(tag.Items) == 0 {
|
||||
return nil, fmt.Errorf("empty APE tag")
|
||||
}
|
||||
|
||||
var itemsData []byte
|
||||
for _, item := range tag.Items {
|
||||
keyBytes := []byte(item.Key)
|
||||
valueBytes := []byte(item.Value)
|
||||
|
||||
// 4 bytes: value size (LE)
|
||||
sizeBuf := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(sizeBuf, uint32(len(valueBytes)))
|
||||
|
||||
// 4 bytes: item flags (LE)
|
||||
flagsBuf := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(flagsBuf, item.Flags)
|
||||
|
||||
itemsData = append(itemsData, sizeBuf...)
|
||||
itemsData = append(itemsData, flagsBuf...)
|
||||
itemsData = append(itemsData, keyBytes...)
|
||||
itemsData = append(itemsData, 0)
|
||||
itemsData = append(itemsData, valueBytes...)
|
||||
}
|
||||
|
||||
// tagSize = items data + footer (32 bytes)
|
||||
tagSize := uint32(len(itemsData) + apeTagHeaderSize)
|
||||
itemCount := uint32(len(tag.Items))
|
||||
|
||||
version := uint32(apeTagVersion2)
|
||||
if tag.Version != 0 {
|
||||
version = tag.Version
|
||||
}
|
||||
|
||||
// flags: bit 29 = 1 (is header), bit 31 = 1 (contains header)
|
||||
headerFlags := uint32(apeTagFlagHeader | (1 << 31))
|
||||
header := buildAPEHeaderFooter(version, tagSize, itemCount, headerFlags)
|
||||
|
||||
// flags: bit 29 = 0 (is footer), bit 31 = 1 (contains header)
|
||||
footerFlags := uint32(1 << 31)
|
||||
footer := buildAPEHeaderFooter(version, tagSize, itemCount, footerFlags)
|
||||
|
||||
result := make([]byte, 0, len(header)+len(itemsData)+len(footer))
|
||||
result = append(result, header...)
|
||||
result = append(result, itemsData...)
|
||||
result = append(result, footer...)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func buildAPEHeaderFooter(version, tagSize, itemCount, flags uint32) []byte {
|
||||
buf := make([]byte, apeTagHeaderSize)
|
||||
copy(buf[0:8], apeTagPreamble)
|
||||
binary.LittleEndian.PutUint32(buf[8:12], version)
|
||||
binary.LittleEndian.PutUint32(buf[12:16], tagSize)
|
||||
binary.LittleEndian.PutUint32(buf[16:20], itemCount)
|
||||
binary.LittleEndian.PutUint32(buf[20:24], flags)
|
||||
// bytes 24-31 are reserved (zeros)
|
||||
return buf
|
||||
}
|
||||
|
||||
// APETagToAudioMetadata converts an APETag to our unified AudioMetadata struct.
|
||||
func APETagToAudioMetadata(tag *APETag) *AudioMetadata {
|
||||
if tag == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
metadata := &AudioMetadata{}
|
||||
for _, item := range tag.Items {
|
||||
key := strings.ToUpper(strings.TrimSpace(item.Key))
|
||||
value := strings.TrimSpace(item.Value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
switch key {
|
||||
case "TITLE":
|
||||
metadata.Title = value
|
||||
case "ARTIST":
|
||||
metadata.Artist = value
|
||||
case "ALBUM":
|
||||
metadata.Album = value
|
||||
case "ALBUMARTIST", "ALBUM ARTIST":
|
||||
metadata.AlbumArtist = value
|
||||
case "GENRE":
|
||||
metadata.Genre = value
|
||||
case "YEAR":
|
||||
metadata.Year = value
|
||||
case "DATE":
|
||||
metadata.Date = value
|
||||
case "TRACK", "TRACKNUMBER":
|
||||
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||
case "DISC", "DISCNUMBER":
|
||||
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||
case "ISRC":
|
||||
metadata.ISRC = value
|
||||
case "LYRICS", "UNSYNCEDLYRICS":
|
||||
if metadata.Lyrics == "" {
|
||||
metadata.Lyrics = value
|
||||
}
|
||||
case "LABEL", "PUBLISHER":
|
||||
metadata.Label = value
|
||||
case "COPYRIGHT":
|
||||
metadata.Copyright = value
|
||||
case "COMPOSER":
|
||||
metadata.Composer = value
|
||||
case "COMMENT":
|
||||
metadata.Comment = value
|
||||
case "REPLAYGAIN_TRACK_GAIN":
|
||||
metadata.ReplayGainTrackGain = value
|
||||
case "REPLAYGAIN_TRACK_PEAK":
|
||||
metadata.ReplayGainTrackPeak = value
|
||||
case "REPLAYGAIN_ALBUM_GAIN":
|
||||
metadata.ReplayGainAlbumGain = value
|
||||
case "REPLAYGAIN_ALBUM_PEAK":
|
||||
metadata.ReplayGainAlbumPeak = value
|
||||
}
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
// AudioMetadataToAPEItems converts metadata fields to APE tag items.
|
||||
func AudioMetadataToAPEItems(metadata *AudioMetadata) []APETagItem {
|
||||
if metadata == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var items []APETagItem
|
||||
addItem := func(key, value string) {
|
||||
if value != "" {
|
||||
items = append(items, APETagItem{Key: key, Value: value})
|
||||
}
|
||||
}
|
||||
|
||||
addItem("Title", metadata.Title)
|
||||
addItem("Artist", metadata.Artist)
|
||||
addItem("Album", metadata.Album)
|
||||
addItem("Album Artist", metadata.AlbumArtist)
|
||||
addItem("Genre", metadata.Genre)
|
||||
if metadata.Date != "" {
|
||||
addItem("Year", metadata.Date)
|
||||
} else if metadata.Year != "" {
|
||||
addItem("Year", metadata.Year)
|
||||
}
|
||||
if metadata.TrackNumber > 0 {
|
||||
addItem("Track", formatIndexValue(metadata.TrackNumber, metadata.TotalTracks))
|
||||
}
|
||||
if metadata.DiscNumber > 0 {
|
||||
addItem("Disc", formatIndexValue(metadata.DiscNumber, metadata.TotalDiscs))
|
||||
}
|
||||
addItem("ISRC", metadata.ISRC)
|
||||
addItem("Lyrics", metadata.Lyrics)
|
||||
addItem("Label", metadata.Label)
|
||||
addItem("Copyright", metadata.Copyright)
|
||||
addItem("Composer", metadata.Composer)
|
||||
addItem("Comment", metadata.Comment)
|
||||
addItem("REPLAYGAIN_TRACK_GAIN", metadata.ReplayGainTrackGain)
|
||||
addItem("REPLAYGAIN_TRACK_PEAK", metadata.ReplayGainTrackPeak)
|
||||
addItem("REPLAYGAIN_ALBUM_GAIN", metadata.ReplayGainAlbumGain)
|
||||
addItem("REPLAYGAIN_ALBUM_PEAK", metadata.ReplayGainAlbumPeak)
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// apeKeysFromFields builds a set of upper-case APE tag keys corresponding to
|
||||
// the metadata fields map sent by the editor. This is used during merge to
|
||||
// ensure that even empty (cleared) fields override old values.
|
||||
func apeKeysFromFields(fields map[string]string) map[string]struct{} {
|
||||
mapping := map[string]string{
|
||||
"title": "TITLE",
|
||||
"artist": "ARTIST",
|
||||
"album": "ALBUM",
|
||||
"album_artist": "ALBUM ARTIST",
|
||||
"date": "DATE",
|
||||
"genre": "GENRE",
|
||||
"track_number": "TRACK",
|
||||
"disc_number": "DISC",
|
||||
"isrc": "ISRC",
|
||||
"lyrics": "LYRICS",
|
||||
"label": "LABEL",
|
||||
"copyright": "COPYRIGHT",
|
||||
"composer": "COMPOSER",
|
||||
"comment": "COMMENT",
|
||||
"replaygain_track_gain": "REPLAYGAIN_TRACK_GAIN",
|
||||
"replaygain_track_peak": "REPLAYGAIN_TRACK_PEAK",
|
||||
"replaygain_album_gain": "REPLAYGAIN_ALBUM_GAIN",
|
||||
"replaygain_album_peak": "REPLAYGAIN_ALBUM_PEAK",
|
||||
}
|
||||
result := make(map[string]struct{})
|
||||
for fk, apeKey := range mapping {
|
||||
if _, present := fields[fk]; present {
|
||||
result[strings.ToUpper(apeKey)] = struct{}{}
|
||||
}
|
||||
}
|
||||
// Some fields have reader aliases that must also be cleared when the
|
||||
// canonical key is updated (e.g. DATE writer ↔ DATE/YEAR reader,
|
||||
// DISC ↔ DISCNUMBER, TRACK ↔ TRACKNUMBER, "ALBUM ARTIST" ↔ ALBUMARTIST,
|
||||
// LABEL ↔ PUBLISHER, LYRICS ↔ UNSYNCEDLYRICS).
|
||||
if _, present := fields["date"]; present {
|
||||
result["DATE"] = struct{}{}
|
||||
}
|
||||
if _, present := fields["disc_number"]; present {
|
||||
result["DISCNUMBER"] = struct{}{}
|
||||
}
|
||||
if _, present := fields["disc_total"]; present {
|
||||
result["DISCNUMBER"] = struct{}{}
|
||||
}
|
||||
if _, present := fields["track_number"]; present {
|
||||
result["TRACKNUMBER"] = struct{}{}
|
||||
}
|
||||
if _, present := fields["track_total"]; present {
|
||||
result["TRACKNUMBER"] = struct{}{}
|
||||
}
|
||||
if _, present := fields["album_artist"]; present {
|
||||
result["ALBUMARTIST"] = struct{}{}
|
||||
}
|
||||
if _, present := fields["label"]; present {
|
||||
result["PUBLISHER"] = struct{}{}
|
||||
}
|
||||
if _, present := fields["lyrics"]; present {
|
||||
result["UNSYNCEDLYRICS"] = struct{}{}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MergeAPEItems overlays newItems on top of existing items.
|
||||
// For each new item, if a matching key exists (case-insensitive) in existing,
|
||||
// it is replaced. New keys are appended. Existing items whose keys are NOT
|
||||
// in newItems are preserved (cover art, ReplayGain, custom tags, etc.).
|
||||
//
|
||||
// overrideKeys is an optional set of upper-case keys that should be removed
|
||||
// from existing even if they do not appear in newItems. This handles field
|
||||
// deletion: the caller sends an empty value which is not serialized into
|
||||
// newItems, but the old value must still be dropped.
|
||||
func MergeAPEItems(existing, newItems []APETagItem, overrideKeys map[string]struct{}) []APETagItem {
|
||||
combined := make(map[string]struct{}, len(newItems)+len(overrideKeys))
|
||||
for k := range overrideKeys {
|
||||
combined[strings.ToUpper(k)] = struct{}{}
|
||||
}
|
||||
for _, item := range newItems {
|
||||
combined[strings.ToUpper(item.Key)] = struct{}{}
|
||||
}
|
||||
|
||||
var merged []APETagItem
|
||||
for _, item := range existing {
|
||||
if _, overwritten := combined[strings.ToUpper(item.Key)]; !overwritten {
|
||||
merged = append(merged, item)
|
||||
}
|
||||
}
|
||||
|
||||
merged = append(merged, newItems...)
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// ReadAPETagsFromReader reads APEv2 tags from an io.ReaderAt + size.
|
||||
// This is useful for reading APE tags from files opened via SAF or other abstractions.
|
||||
func ReadAPETagsFromReader(r io.ReaderAt, fileSize int64) (*APETag, error) {
|
||||
if fileSize < apeTagHeaderSize {
|
||||
return nil, fmt.Errorf("file too small for APE tag")
|
||||
}
|
||||
|
||||
footer := make([]byte, apeTagHeaderSize)
|
||||
if _, err := r.ReadAt(footer, fileSize-apeTagHeaderSize); err != nil {
|
||||
return nil, fmt.Errorf("failed to read APE footer: %w", err)
|
||||
}
|
||||
|
||||
if string(footer[0:8]) == apeTagPreamble {
|
||||
tag, err := parseAPETagFromFooter(r, fileSize, fileSize-apeTagHeaderSize, footer)
|
||||
if err == nil {
|
||||
return tag, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Retry: skip ID3v1 tag (128 bytes)
|
||||
if fileSize > apeTagHeaderSize+128 {
|
||||
offset := fileSize - apeTagHeaderSize - 128
|
||||
if _, err := r.ReadAt(footer, offset); err == nil {
|
||||
if string(footer[0:8]) == apeTagPreamble {
|
||||
tag, err := parseAPETagFromFooter(r, fileSize, offset, footer)
|
||||
if err == nil {
|
||||
return tag, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no APEv2 tag found")
|
||||
}
|
||||
|
||||
func parseAPETagFromFooter(r io.ReaderAt, _, footerOffset int64, footer []byte) (*APETag, error) {
|
||||
version := binary.LittleEndian.Uint32(footer[8:12])
|
||||
tagSize := binary.LittleEndian.Uint32(footer[12:16])
|
||||
itemCount := binary.LittleEndian.Uint32(footer[16:20])
|
||||
flags := binary.LittleEndian.Uint32(footer[20:24])
|
||||
|
||||
if version != apeTagVersion2 && version != 1000 {
|
||||
return nil, fmt.Errorf("unsupported APE tag version: %d", version)
|
||||
}
|
||||
if tagSize < apeTagHeaderSize {
|
||||
return nil, fmt.Errorf("APE tag size too small: %d", tagSize)
|
||||
}
|
||||
if itemCount > 1000 {
|
||||
return nil, fmt.Errorf("APE tag item count too large: %d", itemCount)
|
||||
}
|
||||
if (flags & apeTagFlagHeader) != 0 {
|
||||
return nil, fmt.Errorf("expected footer, found header")
|
||||
}
|
||||
|
||||
itemsSize := int64(tagSize) - apeTagHeaderSize
|
||||
itemsOffset := footerOffset - itemsSize
|
||||
if itemsOffset < 0 {
|
||||
return nil, fmt.Errorf("APE items extend before file start")
|
||||
}
|
||||
|
||||
itemsData := make([]byte, itemsSize)
|
||||
if _, err := r.ReadAt(itemsData, itemsOffset); err != nil {
|
||||
return nil, fmt.Errorf("failed to read APE items: %w", err)
|
||||
}
|
||||
|
||||
items, err := parseAPEItems(itemsData, int(itemCount))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse APE items: %w", err)
|
||||
}
|
||||
|
||||
return &APETag{
|
||||
Version: version,
|
||||
Items: items,
|
||||
ReadOnly: (flags & apeTagFlagReadOnly) != 0,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAPETagReadWriteMergeAndMetadataConversion(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sample.ape")
|
||||
if err := os.WriteFile(path, []byte("audio-data"), 0600); err != nil {
|
||||
t.Fatalf("write sample: %v", err)
|
||||
}
|
||||
|
||||
metadata := &AudioMetadata{
|
||||
Title: "Song",
|
||||
Artist: "Artist",
|
||||
Album: "Album",
|
||||
AlbumArtist: "Album Artist",
|
||||
Genre: "Pop",
|
||||
Date: "2026",
|
||||
TrackNumber: 3,
|
||||
TotalTracks: 12,
|
||||
DiscNumber: 1,
|
||||
TotalDiscs: 2,
|
||||
ISRC: "USRC17607839",
|
||||
Lyrics: "lyrics",
|
||||
Label: "Label",
|
||||
Copyright: "Copyright",
|
||||
Composer: "Composer",
|
||||
Comment: "Comment",
|
||||
ReplayGainTrackGain: "-6.50 dB",
|
||||
ReplayGainTrackPeak: "0.98",
|
||||
ReplayGainAlbumGain: "-5.00 dB",
|
||||
ReplayGainAlbumPeak: "0.99",
|
||||
}
|
||||
items := AudioMetadataToAPEItems(metadata)
|
||||
if len(items) == 0 {
|
||||
t.Fatal("expected APE items")
|
||||
}
|
||||
|
||||
tag := &APETag{Items: append(items, APETagItem{Key: "Custom", Value: "Keep"})}
|
||||
if err := WriteAPETags(path, tag); err != nil {
|
||||
t.Fatalf("WriteAPETags: %v", err)
|
||||
}
|
||||
|
||||
readTag, err := ReadAPETags(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAPETags: %v", err)
|
||||
}
|
||||
if readTag.Version != apeTagVersion2 {
|
||||
t.Fatalf("version = %d", readTag.Version)
|
||||
}
|
||||
readMetadata := APETagToAudioMetadata(readTag)
|
||||
if readMetadata.Title != "Song" || readMetadata.TrackNumber != 3 || readMetadata.TotalTracks != 12 {
|
||||
t.Fatalf("metadata = %#v", readMetadata)
|
||||
}
|
||||
|
||||
readerTag, err := ReadAPETagsFromReader(bytes.NewReader(mustReadFile(t, path)), int64(len(mustReadFile(t, path))))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAPETagsFromReader: %v", err)
|
||||
}
|
||||
if len(readerTag.Items) != len(readTag.Items) {
|
||||
t.Fatalf("reader items = %d, file items = %d", len(readerTag.Items), len(readTag.Items))
|
||||
}
|
||||
|
||||
override := apeKeysFromFields(map[string]string{"title": "", "lyrics": "", "disc_total": ""})
|
||||
merged := MergeAPEItems(readTag.Items, []APETagItem{{Key: "Title", Value: "New Song"}}, override)
|
||||
mergedMeta := APETagToAudioMetadata(&APETag{Items: merged})
|
||||
if mergedMeta.Title != "New Song" {
|
||||
t.Fatalf("merged title = %q", mergedMeta.Title)
|
||||
}
|
||||
if mergedMeta.Lyrics != "" {
|
||||
t.Fatalf("expected lyrics cleared, got %q", mergedMeta.Lyrics)
|
||||
}
|
||||
|
||||
if err := WriteAPETags(path, &APETag{Items: []APETagItem{{Key: "Title", Value: "Replacement"}}}); err != nil {
|
||||
t.Fatalf("replace APE tags: %v", err)
|
||||
}
|
||||
replaced, err := ReadAPETags(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read replacement: %v", err)
|
||||
}
|
||||
if got := APETagToAudioMetadata(replaced).Title; got != "Replacement" {
|
||||
t.Fatalf("replacement title = %q", got)
|
||||
}
|
||||
|
||||
if _, err := marshalAPETag(nil); err == nil {
|
||||
t.Fatal("expected empty tag error")
|
||||
}
|
||||
if _, err := ReadAPETags(filepath.Join(dir, "missing.ape")); err == nil {
|
||||
t.Fatal("expected missing file error")
|
||||
}
|
||||
if _, err := ReadAPETagsFromReader(bytes.NewReader([]byte("short")), 5); err == nil {
|
||||
t.Fatal("expected small reader error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPETagInvalidFooterBranches(t *testing.T) {
|
||||
footer := buildAPEHeaderFooter(9999, apeTagHeaderSize, 1, 0)
|
||||
if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil {
|
||||
t.Fatal("expected unsupported version")
|
||||
}
|
||||
|
||||
footer = buildAPEHeaderFooter(apeTagVersion2, apeTagHeaderSize-1, 1, 0)
|
||||
if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil {
|
||||
t.Fatal("expected small tag size")
|
||||
}
|
||||
|
||||
footer = buildAPEHeaderFooter(apeTagVersion2, apeTagHeaderSize, 1001, 0)
|
||||
if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil {
|
||||
t.Fatal("expected too many items")
|
||||
}
|
||||
|
||||
footer = buildAPEHeaderFooter(apeTagVersion2, apeTagHeaderSize, 1, apeTagFlagHeader)
|
||||
if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil {
|
||||
t.Fatal("expected header flag error")
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveLibraryCoverCacheKeyUsesExplicitKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const explicitKey = "content://media/external/audio/media/42|123456"
|
||||
got := resolveLibraryCoverCacheKey("/tmp/saf_random.flac", explicitKey)
|
||||
if got != explicitKey {
|
||||
t.Fatalf("expected explicit cache key %q, got %q", explicitKey, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLibraryCoverCacheKeyUsesFilePathAndStatWhenNoExplicitKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempFile, err := os.CreateTemp("", "cover-cache-*.flac")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp failed: %v", err)
|
||||
}
|
||||
tempPath := tempFile.Name()
|
||||
tempFile.Close()
|
||||
defer os.Remove(tempPath)
|
||||
|
||||
got := resolveLibraryCoverCacheKey(tempPath, "")
|
||||
if !strings.HasPrefix(got, tempPath+"|") {
|
||||
t.Fatalf("expected stat-based cache key to start with %q, got %q", tempPath+"|", got)
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func ffmpegCommand(args ...string) *exec.Cmd {
|
||||
if ffmpegPath, err := exec.LookPath("ffmpeg"); err == nil {
|
||||
return exec.Command(ffmpegPath, args...)
|
||||
}
|
||||
return exec.Command("ffmpeg", args...)
|
||||
}
|
||||
|
||||
func runFFmpegTestCommand(t *testing.T, args ...string) {
|
||||
t.Helper()
|
||||
cmd := ffmpegCommand(args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("ffmpeg failed: %v\n%s", err, string(output))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLyricsReadsMp3AfterCoverEmbed(t *testing.T) {
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
t.Skip("ffmpeg not available")
|
||||
}
|
||||
|
||||
tempDir := t.TempDir()
|
||||
sourceFlac := filepath.Join(tempDir, "source.flac")
|
||||
baseMp3 := filepath.Join(tempDir, "base.mp3")
|
||||
finalMp3 := filepath.Join(tempDir, "final.mp3")
|
||||
coverPath := filepath.Join(tempDir, "cover.jpg")
|
||||
lyrics := "[ti:Test Song]\n[ar:Test Artist]\n[00:00.00]Hello from embedded lyrics"
|
||||
|
||||
runFFmpegTestCommand(
|
||||
t,
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=440:duration=1",
|
||||
"-c:a",
|
||||
"flac",
|
||||
sourceFlac,
|
||||
)
|
||||
|
||||
runFFmpegTestCommand(
|
||||
t,
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"color=c=red:s=32x32:d=1",
|
||||
"-frames:v",
|
||||
"1",
|
||||
coverPath,
|
||||
)
|
||||
|
||||
runFFmpegTestCommand(
|
||||
t,
|
||||
"-y",
|
||||
"-i",
|
||||
sourceFlac,
|
||||
"-b:a",
|
||||
"320k",
|
||||
"-metadata",
|
||||
"title=Test Song",
|
||||
"-metadata",
|
||||
"artist=Test Artist",
|
||||
"-metadata",
|
||||
"lyrics="+lyrics,
|
||||
baseMp3,
|
||||
)
|
||||
|
||||
runFFmpegTestCommand(
|
||||
t,
|
||||
"-y",
|
||||
"-i",
|
||||
baseMp3,
|
||||
"-i",
|
||||
coverPath,
|
||||
"-map",
|
||||
"0:a",
|
||||
"-map_metadata",
|
||||
"-1",
|
||||
"-map",
|
||||
"1:0",
|
||||
"-c:v:0",
|
||||
"copy",
|
||||
"-id3v2_version",
|
||||
"3",
|
||||
"-metadata",
|
||||
"title=Test Song",
|
||||
"-metadata",
|
||||
"artist=Test Artist",
|
||||
"-metadata",
|
||||
"lyrics="+lyrics,
|
||||
"-metadata:s:v",
|
||||
"title=Album cover",
|
||||
"-metadata:s:v",
|
||||
"comment=Cover (front)",
|
||||
"-c:a",
|
||||
"copy",
|
||||
finalMp3,
|
||||
)
|
||||
|
||||
meta, err := ReadID3Tags(finalMp3)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadID3Tags failed: %v", err)
|
||||
}
|
||||
if meta == nil {
|
||||
t.Fatalf("ReadID3Tags returned nil metadata")
|
||||
}
|
||||
|
||||
embeddedLyrics, err := ExtractLyrics(finalMp3)
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractLyrics failed: %v (metadata=%+v)", err, meta)
|
||||
}
|
||||
if !strings.Contains(embeddedLyrics, "Hello from embedded lyrics") {
|
||||
t.Fatalf("embedded lyrics missing, got %q (metadata=%+v)", embeddedLyrics, meta)
|
||||
}
|
||||
if !strings.Contains(meta.Lyrics, "Hello from embedded lyrics") {
|
||||
t.Fatalf("ReadID3Tags lyrics missing, got %+v", meta)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(finalMp3); err != nil {
|
||||
t.Fatalf("expected final mp3 to exist: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,507 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAudioMetadataID3ParsingBranches(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "tagged.mp3")
|
||||
tag := buildID3v23Tag(
|
||||
id3TextFrame("TIT2", "Title"),
|
||||
id3TextFrame("TPE1", "Artist"),
|
||||
id3TextFrame("TPE2", "Album Artist"),
|
||||
id3TextFrame("TALB", "Album"),
|
||||
id3TextFrame("TDRC", "2026-05-04"),
|
||||
id3TextFrame("TCON", "(13)Pop"),
|
||||
id3TextFrame("TRCK", "4/12"),
|
||||
id3TextFrame("TPOS", "1/2"),
|
||||
id3TextFrame("TSRC", "USRC17607839"),
|
||||
id3TextFrame("TCOM", "Composer"),
|
||||
id3TextFrame("TPUB", "Label"),
|
||||
id3TextFrame("TCOP", "Copyright"),
|
||||
id3CommentFrame("COMM", "Comment"),
|
||||
id3CommentFrame("USLT", "Lyrics"),
|
||||
id3UserTextFrame("TXXX", "REPLAYGAIN_TRACK_GAIN", "-6.50 dB"),
|
||||
id3UserTextFrame("TXXX", "REPLAYGAIN_TRACK_PEAK", "0.98"),
|
||||
)
|
||||
if err := os.WriteFile(path, append(tag, []byte("audio")...), 0600); err != nil {
|
||||
t.Fatalf("write ID3v2: %v", err)
|
||||
}
|
||||
|
||||
meta, err := ReadID3Tags(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadID3Tags: %v", err)
|
||||
}
|
||||
if meta.Title != "Title" || meta.TrackNumber != 4 || meta.TotalTracks != 12 || meta.Genre != "Pop" {
|
||||
t.Fatalf("metadata = %#v", meta)
|
||||
}
|
||||
if meta.Comment != "Comment" || meta.Lyrics != "Lyrics" || meta.ReplayGainTrackGain == "" {
|
||||
t.Fatalf("metadata comments/lyrics/replaygain = %#v", meta)
|
||||
}
|
||||
|
||||
id3v1Path := filepath.Join(dir, "id3v1.mp3")
|
||||
if err := os.WriteFile(id3v1Path, append([]byte("audio"), buildID3v1Tag("V1 Title", "V1 Artist", "V1 Album", "1999", 7, 13)...), 0600); err != nil {
|
||||
t.Fatalf("write ID3v1: %v", err)
|
||||
}
|
||||
v1, err := ReadID3Tags(id3v1Path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadID3Tags v1: %v", err)
|
||||
}
|
||||
if v1.Title != "V1 Title" || v1.Artist != "V1 Artist" || v1.Genre == "" {
|
||||
t.Fatalf("v1 = %#v", v1)
|
||||
}
|
||||
|
||||
v22Path := filepath.Join(dir, "id3v22.mp3")
|
||||
v22 := buildID3v22Tag(
|
||||
id3v22TextFrame("TT2", "V22 Title"),
|
||||
id3v22TextFrame("TP1", "V22 Artist"),
|
||||
id3v22TextFrame("TRK", "2/5"),
|
||||
id3v22CommentFrame("ULT", "V22 Lyrics"),
|
||||
)
|
||||
if err := os.WriteFile(v22Path, append(v22, []byte("audio")...), 0600); err != nil {
|
||||
t.Fatalf("write ID3v2.2: %v", err)
|
||||
}
|
||||
v22Meta, err := ReadID3Tags(v22Path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadID3Tags v2.2: %v", err)
|
||||
}
|
||||
if v22Meta.Title != "V22 Title" || v22Meta.Artist != "V22 Artist" || v22Meta.Lyrics != "V22 Lyrics" {
|
||||
t.Fatalf("v22 = %#v", v22Meta)
|
||||
}
|
||||
|
||||
if got := decodeUTF16([]byte{0xff, 0xfe, 'H', 0, 'i', 0}); got != "Hi" {
|
||||
t.Fatalf("decodeUTF16 = %q", got)
|
||||
}
|
||||
if got := decodeUTF16BE([]byte{0, 'O', 0, 'K'}); got != "OK" {
|
||||
t.Fatalf("decodeUTF16BE = %q", got)
|
||||
}
|
||||
if n, total := parseIndexPair(" 8 / 10 "); n != 8 || total != 10 {
|
||||
t.Fatalf("parseIndexPair = %d/%d", n, total)
|
||||
}
|
||||
if got := removeUnsync([]byte{0xff, 0x00, 0xe0}); !bytes.Equal(got, []byte{0xff, 0xe0}) {
|
||||
t.Fatalf("removeUnsync = %#v", got)
|
||||
}
|
||||
if got := extendedHeaderSize([]byte{0, 0, 0, 6, 0, 0, 0, 0, 0, 0}, 3); got != 10 {
|
||||
t.Fatalf("extendedHeaderSize = %d", got)
|
||||
}
|
||||
if got := syncsafeToInt([]byte{0, 0, 2, 0}); got != 256 {
|
||||
t.Fatalf("syncsafe = %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudioMetadataCoverAndQualityHelpers(t *testing.T) {
|
||||
png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0}
|
||||
if detectCoverMIME("cover.jpg", png) != "image/png" || detectCoverMIME("cover.webp", []byte("RIFFxxxxWEBPdata")) != "image/webp" {
|
||||
t.Fatal("cover MIME detection mismatch")
|
||||
}
|
||||
if _, err := buildPictureBlock("", nil); err == nil {
|
||||
t.Fatal("expected empty picture block error")
|
||||
}
|
||||
|
||||
apic := append([]byte{3}, []byte("image/png\x00")...)
|
||||
apic = append(apic, 3, 0)
|
||||
apic = append(apic, png...)
|
||||
image, mime := parseAPICFrame(apic, 3)
|
||||
if mime != "image/png" || !bytes.Equal(image, png) {
|
||||
t.Fatalf("APIC = %s/%v", mime, image)
|
||||
}
|
||||
pic := append([]byte{0}, []byte("PNG")...)
|
||||
pic = append(pic, 3, 0)
|
||||
pic = append(pic, png...)
|
||||
image, mime = parseAPICFrame(pic, 2)
|
||||
if mime != "image/png" || !bytes.Equal(image, png) {
|
||||
t.Fatalf("PIC = %s/%v", mime, image)
|
||||
}
|
||||
|
||||
frame := make([]byte, 10)
|
||||
copy(frame[:4], "APIC")
|
||||
binary.BigEndian.PutUint32(frame[4:8], uint32(len(apic)))
|
||||
tag := append(frame, apic...)
|
||||
header := []byte{'I', 'D', '3', 3, 0, 0, byte(len(tag) >> 21), byte(len(tag) >> 14), byte(len(tag) >> 7), byte(len(tag))}
|
||||
mp3CoverPath := filepath.Join(t.TempDir(), "cover.mp3")
|
||||
if err := os.WriteFile(mp3CoverPath, append(append(header, tag...), []byte("audio")...), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
extracted, extractedMIME, err := extractMP3CoverArt(mp3CoverPath)
|
||||
if err != nil || extractedMIME != "image/png" || !bytes.Equal(extracted, png) {
|
||||
t.Fatalf("extractMP3CoverArt = %s/%v/%v", extractedMIME, extracted, err)
|
||||
}
|
||||
|
||||
var picture bytes.Buffer
|
||||
binary.Write(&picture, binary.BigEndian, uint32(3))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(len("image/png")))
|
||||
picture.WriteString("image/png")
|
||||
binary.Write(&picture, binary.BigEndian, uint32(0))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(1))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(1))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(32))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(0))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(len(png)))
|
||||
picture.Write(png)
|
||||
flacImage, flacMIME := parseFLACPictureBlock(picture.Bytes())
|
||||
if flacMIME != "image/png" || !bytes.Equal(flacImage, png) {
|
||||
t.Fatalf("FLAC picture = %s/%v", flacMIME, flacImage)
|
||||
}
|
||||
|
||||
comment := "METADATA_BLOCK_PICTURE=" + base64.StdEncoding.EncodeToString(picture.Bytes())
|
||||
var vorbis bytes.Buffer
|
||||
binary.Write(&vorbis, binary.LittleEndian, uint32(6))
|
||||
vorbis.WriteString("vendor")
|
||||
binary.Write(&vorbis, binary.LittleEndian, uint32(1))
|
||||
binary.Write(&vorbis, binary.LittleEndian, uint32(len(comment)))
|
||||
vorbis.WriteString(comment)
|
||||
commentImage, commentMIME := extractPictureFromVorbisComments(vorbis.Bytes())
|
||||
if commentMIME != "image/png" || !bytes.Equal(commentImage, png) {
|
||||
t.Fatalf("vorbis picture = %s/%v", commentMIME, commentImage)
|
||||
}
|
||||
if detectOggStreamType([][]byte{[]byte("OpusHeadxxxx")}) != oggStreamOpus {
|
||||
t.Fatal("expected opus stream")
|
||||
}
|
||||
if detectOggStreamType([][]byte{append([]byte{1}, []byte("vorbisxxxx")...)}) != oggStreamVorbis {
|
||||
t.Fatal("expected vorbis stream")
|
||||
}
|
||||
|
||||
mp3Path := filepath.Join(t.TempDir(), "quality.mp3")
|
||||
audio := append([]byte{0xFF, 0xFB, 0x90, 0x64}, bytes.Repeat([]byte{0}, 2000)...)
|
||||
if err := os.WriteFile(mp3Path, audio, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
quality, err := GetMP3Quality(mp3Path)
|
||||
if err != nil || quality.SampleRate != 44100 || quality.Bitrate != 128000 {
|
||||
t.Fatalf("MP3 quality = %#v/%v", quality, err)
|
||||
}
|
||||
if _, _, err := extractMP3CoverArt(filepath.Join(t.TempDir(), "missing.mp3")); err == nil {
|
||||
t.Fatal("expected missing MP3 cover error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestM4AMetadataAtomHelpers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "tagged.m4a")
|
||||
cover := []byte{0xFF, 0xD8, 0xFF, 0x00}
|
||||
ilstPayload := []byte{}
|
||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9nam", "M4A Title")...)
|
||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9ART", "M4A Artist")...)
|
||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9alb", "M4A Album")...)
|
||||
ilstPayload = append(ilstPayload, buildM4ATextTag("aART", "Album Artist")...)
|
||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9day", "2026")...)
|
||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9gen", "Pop")...)
|
||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9wrt", "Composer")...)
|
||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9cmt", "[ti:Comment Lyrics]")...)
|
||||
ilstPayload = append(ilstPayload, buildM4ATextTag("cprt", "Copyright")...)
|
||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9lyr", "[00:00.00]M4A Lyrics")...)
|
||||
ilstPayload = append(ilstPayload, buildM4AIndexTag("trkn", 3, 12)...)
|
||||
ilstPayload = append(ilstPayload, buildM4AIndexTag("disk", 1, 2)...)
|
||||
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("ISRC", "USRC17607839")...)
|
||||
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("LABEL", "Label")...)
|
||||
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("REPLAYGAIN_TRACK_GAIN", "-6.50 dB")...)
|
||||
ilstPayload = append(ilstPayload, buildM4AAtom("covr", buildM4AAtom("data", append([]byte{0, 0, 0, 13, 0, 0, 0, 0}, cover...)))...)
|
||||
fileData := buildM4AFileWithIlst(ilstPayload, true)
|
||||
if err := os.WriteFile(path, fileData, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
meta, err := ReadM4ATags(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadM4ATags: %v", err)
|
||||
}
|
||||
if meta.Title != "M4A Title" || meta.Artist != "M4A Artist" || meta.TrackNumber != 3 || meta.TotalTracks != 12 || meta.ISRC != "USRC17607839" {
|
||||
t.Fatalf("M4A metadata = %#v", meta)
|
||||
}
|
||||
if lyrics, err := extractLyricsFromM4A(path); err != nil || !strings.Contains(lyrics, "M4A Lyrics") {
|
||||
t.Fatalf("extractLyricsFromM4A = %q/%v", lyrics, err)
|
||||
}
|
||||
if image, err := extractCoverFromM4A(path); err != nil || !bytes.Equal(image, cover) {
|
||||
t.Fatalf("extractCoverFromM4A = %#v/%v", image, err)
|
||||
}
|
||||
if pathInfo, err := func() (m4aMetadataPath, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return m4aMetadataPath{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
info, _ := f.Stat()
|
||||
return findM4AMetadataPath(f, info.Size())
|
||||
}(); err != nil || pathInfo.udta == nil {
|
||||
t.Fatalf("findM4AMetadataPath = %#v/%v", pathInfo, err)
|
||||
}
|
||||
if err := EditM4AReplayGain(path, map[string]string{"replaygain_track_gain": "-5.00 dB", "replaygain_track_peak": "0.98"}); err != nil {
|
||||
t.Fatalf("EditM4AReplayGain: %v", err)
|
||||
}
|
||||
edited, err := ReadM4ATags(path)
|
||||
if err != nil || edited.ReplayGainTrackGain != "-5.00 dB" || edited.ReplayGainTrackPeak != "0.98" {
|
||||
t.Fatalf("edited M4A = %#v/%v", edited, err)
|
||||
}
|
||||
|
||||
noUdtaPath := filepath.Join(dir, "noudta.m4a")
|
||||
if err := os.WriteFile(noUdtaPath, buildM4AFileWithIlst(buildM4ATextTag("\xa9nam", "No Udta"), false), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if meta, err := ReadM4ATags(noUdtaPath); err != nil || meta.Title != "No Udta" {
|
||||
t.Fatalf("ReadM4ATags no udta = %#v/%v", meta, err)
|
||||
}
|
||||
if _, err := ReadM4ATags(filepath.Join(dir, "missing.m4a")); err == nil {
|
||||
t.Fatal("expected missing M4A error")
|
||||
}
|
||||
emptyM4A := filepath.Join(dir, "empty.m4a")
|
||||
if err := os.WriteFile(emptyM4A, buildM4AFileWithIlst(nil, true), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadM4ATags(emptyM4A); err == nil {
|
||||
t.Fatal("expected empty M4A tags error")
|
||||
}
|
||||
if _, err := extractCoverFromM4A(emptyM4A); err == nil {
|
||||
t.Fatal("expected missing M4A cover error")
|
||||
}
|
||||
if _, err := extractLyricsFromM4A(emptyM4A); err == nil {
|
||||
t.Fatal("expected missing M4A lyrics error")
|
||||
}
|
||||
|
||||
sidecarAudio := filepath.Join(dir, "sidecar.mp3")
|
||||
if err := os.WriteFile(sidecarAudio, []byte("audio"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "sidecar.lrc"), []byte(" [00:00.00]Sidecar "), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lyrics, err := extractLyricsFromSidecarLRC(sidecarAudio); err != nil || !strings.Contains(lyrics, "Sidecar") {
|
||||
t.Fatalf("sidecar lyrics = %q/%v", lyrics, err)
|
||||
}
|
||||
if !looksLikeEmbeddedLyrics("[ti:Song]") || !looksLikeEmbeddedLyrics("[00:00.00]Line\n[00:01.00]Next") || looksLikeEmbeddedLyrics("plain") {
|
||||
t.Fatal("embedded lyric heuristic mismatch")
|
||||
}
|
||||
if formatIndexValue(3, 12) != "3/12" || formatIndexValue(3, 0) != "3" || formatIndexValue(0, 12) != "" {
|
||||
t.Fatal("formatIndexValue mismatch")
|
||||
}
|
||||
if parsePositiveInt(" 42 ") != 42 || parsePositiveInt("bad") != 0 {
|
||||
t.Fatal("parsePositiveInt mismatch")
|
||||
}
|
||||
if !hasMapKey(map[string]string{"x": "y"}, "x") {
|
||||
t.Fatal("expected map key")
|
||||
}
|
||||
if _, ok := parseReplayGainDb("-6.50 dB"); !ok {
|
||||
t.Fatal("expected ReplayGain dB parse")
|
||||
}
|
||||
if _, ok := parseReplayGainPeak("0.98"); !ok {
|
||||
t.Fatal("expected ReplayGain peak parse")
|
||||
}
|
||||
if norm := buildITunNORMTag("-6.50 dB", "0.98"); norm == "" {
|
||||
t.Fatal("expected iTunNORM")
|
||||
}
|
||||
if fields := collectM4AReplayGainFields(map[string]string{"replaygain_track_gain": "-6 dB", "replaygain_track_peak": "0.9"}); fields["iTunNORM"] == "" {
|
||||
t.Fatalf("ReplayGain fields = %#v", fields)
|
||||
}
|
||||
|
||||
qualityPath := filepath.Join(dir, "quality-alac.m4a")
|
||||
mvhd := make([]byte, 20)
|
||||
binary.BigEndian.PutUint32(mvhd[12:16], 1000)
|
||||
binary.BigEndian.PutUint32(mvhd[16:20], 180000)
|
||||
sampleEntry := make([]byte, 32)
|
||||
copy(sampleEntry[0:4], "alac")
|
||||
binary.BigEndian.PutUint16(sampleEntry[22:24], 24)
|
||||
sampleEntry[28] = 0xAC
|
||||
sampleEntry[29] = 0x44
|
||||
alacConfig := make([]byte, 24)
|
||||
alacConfig[5] = 24
|
||||
binary.BigEndian.PutUint32(alacConfig[20:24], 44100)
|
||||
alacEntryPayload := append(append([]byte{}, sampleEntry[4:]...), buildM4AAtom("alac", alacConfig)...)
|
||||
qualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), buildM4AAtom("alac", alacEntryPayload)...))...)
|
||||
if err := os.WriteFile(qualityPath, qualityFile, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if quality, err := GetM4AQuality(qualityPath); err != nil || quality.BitDepth != 24 || quality.SampleRate != 44100 || quality.Duration != 180 {
|
||||
t.Fatalf("GetM4AQuality = %#v/%v", quality, err)
|
||||
}
|
||||
if quality, err := GetAudioQuality(qualityPath); err != nil || quality.SampleRate != 44100 {
|
||||
t.Fatalf("GetAudioQuality M4A = %#v/%v", quality, err)
|
||||
}
|
||||
aacQualityPath := filepath.Join(dir, "quality-aac.m4a")
|
||||
copy(sampleEntry[0:4], "mp4a")
|
||||
aacQualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), sampleEntry...))...)
|
||||
if err := os.WriteFile(aacQualityPath, aacQualityFile, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if quality, err := GetM4AQuality(aacQualityPath); err != nil || quality.BitDepth != 0 || quality.SampleRate != 44100 || quality.Duration != 180 {
|
||||
t.Fatalf("GetM4AQuality AAC = %#v/%v", quality, err)
|
||||
}
|
||||
eac3QualityPath := filepath.Join(dir, "quality-eac3.m4a")
|
||||
zeroMvhd := make([]byte, 20)
|
||||
eac3SampleEntry := make([]byte, 32)
|
||||
copy(eac3SampleEntry[0:4], "ec-3")
|
||||
eac3SampleEntry[28] = 0xBB
|
||||
eac3SampleEntry[29] = 0x80
|
||||
mdhd := make([]byte, 20)
|
||||
binary.BigEndian.PutUint32(mdhd[12:16], 48000)
|
||||
binary.BigEndian.PutUint32(mdhd[16:20], 48000*123)
|
||||
eac3QualityFile := append(
|
||||
buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")),
|
||||
buildM4AAtom("moov", append(
|
||||
append(buildM4AAtom("mvhd", zeroMvhd), buildM4AAtom("trak", buildM4AAtom("mdia", buildM4AAtom("mdhd", mdhd)))...),
|
||||
eac3SampleEntry...,
|
||||
))...,
|
||||
)
|
||||
if err := os.WriteFile(eac3QualityPath, eac3QualityFile, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if quality, err := GetM4AQuality(eac3QualityPath); err != nil || quality.Codec != "eac3" || quality.Duration != 123 {
|
||||
t.Fatalf("GetM4AQuality EAC3 mdhd fallback = %#v/%v", quality, err)
|
||||
}
|
||||
if _, _, ok := parseALACSpecificConfig(make([]byte, 4)); ok {
|
||||
t.Fatal("short ALAC config should not parse")
|
||||
}
|
||||
alac := make([]byte, 24)
|
||||
alac[5] = 16
|
||||
binary.BigEndian.PutUint32(alac[20:24], 48000)
|
||||
if depth, rate, ok := parseALACSpecificConfig(alac); !ok || depth != 16 || rate != 48000 {
|
||||
t.Fatalf("ALAC config = %d/%d/%v", depth, rate, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOggMetadataQualityAndCoverHelpers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opusHead := make([]byte, 19)
|
||||
copy(opusHead[0:8], "OpusHead")
|
||||
binary.LittleEndian.PutUint16(opusHead[10:12], 312)
|
||||
binary.LittleEndian.PutUint32(opusHead[12:16], 48000)
|
||||
|
||||
var comments bytes.Buffer
|
||||
binary.Write(&comments, binary.LittleEndian, uint32(6))
|
||||
comments.WriteString("vendor")
|
||||
entries := []string{
|
||||
"TITLE=Ogg Title",
|
||||
"ARTIST=Artist",
|
||||
"ALBUMARTIST=Album Artist",
|
||||
"TRACKNUMBER=2/9",
|
||||
"DISCNUMBER=1/2",
|
||||
"LYRICS=[00:00.00]Ogg Lyrics",
|
||||
}
|
||||
binary.Write(&comments, binary.LittleEndian, uint32(len(entries)))
|
||||
for _, entry := range entries {
|
||||
binary.Write(&comments, binary.LittleEndian, uint32(len(entry)))
|
||||
comments.WriteString(entry)
|
||||
}
|
||||
opusTags := append([]byte("OpusTags"), comments.Bytes()...)
|
||||
oggPath := filepath.Join(dir, "tagged.opus")
|
||||
oggData := append(buildOggPage(0x02, 0, opusHead), buildOggPage(0x00, 48000+312, opusTags)...)
|
||||
if err := os.WriteFile(oggPath, oggData, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
quality, err := GetOggQuality(oggPath)
|
||||
if err != nil || quality.SampleRate != 48000 || quality.Duration != 1 {
|
||||
t.Fatalf("GetOggQuality = %#v/%v", quality, err)
|
||||
}
|
||||
meta, err := ReadOggVorbisComments(oggPath)
|
||||
if err != nil || meta.Title != "Ogg Title" || meta.TrackNumber != 2 || meta.TotalTracks != 9 {
|
||||
t.Fatalf("ReadOggVorbisComments = %#v/%v", meta, err)
|
||||
}
|
||||
|
||||
picture := buildTestFLACPictureBlock([]byte{0x89, 0x50, 0x4E, 0x47}, "image/png")
|
||||
pictureComment := "METADATA_BLOCK_PICTURE=" + base64.StdEncoding.EncodeToString(picture)
|
||||
var coverComments bytes.Buffer
|
||||
binary.Write(&coverComments, binary.LittleEndian, uint32(6))
|
||||
coverComments.WriteString("vendor")
|
||||
binary.Write(&coverComments, binary.LittleEndian, uint32(1))
|
||||
binary.Write(&coverComments, binary.LittleEndian, uint32(len(pictureComment)))
|
||||
coverComments.WriteString(pictureComment)
|
||||
coverPath := filepath.Join(dir, "cover.opus")
|
||||
coverData := append(buildOggPage(0x02, 0, opusHead), buildOggPage(0x00, 48000+312, append([]byte("OpusTags"), coverComments.Bytes()...))...)
|
||||
if err := os.WriteFile(coverPath, coverData, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if image, mime, err := extractOggCoverArt(coverPath); err != nil || mime != "image/png" || len(image) == 0 {
|
||||
t.Fatalf("extractOggCoverArt = %s/%#v/%v", mime, image, err)
|
||||
}
|
||||
if image, mime, err := extractAnyCoverArtWithHint(coverPath, "cover.opus"); err != nil || mime != "image/png" || len(image) == 0 {
|
||||
t.Fatalf("extractAnyCoverArtWithHint = %s/%#v/%v", mime, image, err)
|
||||
}
|
||||
extractedCoverPath := filepath.Join(dir, "extracted.png")
|
||||
if err := ExtractCoverToFile(coverPath, extractedCoverPath); err != nil {
|
||||
t.Fatalf("ExtractCoverToFile = %v", err)
|
||||
}
|
||||
if data := mustReadFile(t, extractedCoverPath); len(data) == 0 {
|
||||
t.Fatal("expected extracted cover data")
|
||||
}
|
||||
cachePath, err := SaveCoverToCacheWithHintAndKey(coverPath, "cover.opus", dir, "key")
|
||||
if err != nil || cachePath == "" {
|
||||
t.Fatalf("SaveCoverToCacheWithHintAndKey = %q/%v", cachePath, err)
|
||||
}
|
||||
if _, err := SaveCoverToCacheWithHintAndKey(filepath.Join(dir, "missing.opus"), "missing.opus", dir, "missing"); err == nil {
|
||||
t.Fatal("expected missing cover cache error")
|
||||
}
|
||||
|
||||
badPath := filepath.Join(dir, "bad.ogg")
|
||||
if err := os.WriteFile(badPath, []byte("bad"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := GetOggQuality(badPath); err == nil {
|
||||
t.Fatal("expected invalid Ogg quality error")
|
||||
}
|
||||
}
|
||||
|
||||
func buildM4ADataPayload(payload []byte) []byte {
|
||||
return append([]byte{0, 0, 0, 1, 0, 0, 0, 0}, payload...)
|
||||
}
|
||||
|
||||
func buildM4ATextTag(atomType, value string) []byte {
|
||||
return buildM4AAtom(atomType, buildM4AAtom("data", buildM4ADataPayload([]byte(value))))
|
||||
}
|
||||
|
||||
func buildM4AIndexTag(atomType string, number, total int) []byte {
|
||||
payload := []byte{0, 0, 0, byte(number), 0, byte(total), 0, 0}
|
||||
return buildM4AAtom(atomType, buildM4AAtom("data", buildM4ADataPayload(payload)))
|
||||
}
|
||||
|
||||
func buildM4AFileWithIlst(ilstPayload []byte, withUdta bool) []byte {
|
||||
ilst := buildM4AAtom("ilst", ilstPayload)
|
||||
meta := buildM4AAtom("meta", append([]byte{0, 0, 0, 0}, ilst...))
|
||||
moovPayload := meta
|
||||
if withUdta {
|
||||
moovPayload = buildM4AAtom("udta", meta)
|
||||
}
|
||||
return append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", moovPayload)...)
|
||||
}
|
||||
|
||||
func buildOggPage(headerType byte, granule uint64, packet []byte) []byte {
|
||||
header := make([]byte, 27)
|
||||
copy(header[0:4], "OggS")
|
||||
header[4] = 0
|
||||
header[5] = headerType
|
||||
binary.LittleEndian.PutUint64(header[6:14], granule)
|
||||
header[26] = 1
|
||||
return append(append(header, byte(len(packet))), packet...)
|
||||
}
|
||||
|
||||
func buildTestFLACPictureBlock(image []byte, mime string) []byte {
|
||||
var picture bytes.Buffer
|
||||
binary.Write(&picture, binary.BigEndian, uint32(3))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(len(mime)))
|
||||
picture.WriteString(mime)
|
||||
binary.Write(&picture, binary.BigEndian, uint32(0))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(1))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(1))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(32))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(0))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(len(image)))
|
||||
picture.Write(image)
|
||||
return picture.Bytes()
|
||||
}
|
||||
|
||||
func TestR128ToReplayGainDb(t *testing.T) {
|
||||
// -1280/256 = -5 dB on the R128 (-23 LUFS) scale -> 0 dB ReplayGain (-18).
|
||||
if db, ok := r128ToReplayGainDb("-1280"); !ok || db != "0.00 dB" {
|
||||
t.Fatalf("got %q ok=%v", db, ok)
|
||||
}
|
||||
if db, ok := r128ToReplayGainDb(" -2944 "); !ok || db != "-6.50 dB" {
|
||||
t.Fatalf("got %q ok=%v", db, ok)
|
||||
}
|
||||
if _, ok := r128ToReplayGainDb("abc"); ok {
|
||||
t.Fatal("expected failure for non-numeric input")
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ErrDownloadCancelled is returned when a download is cancelled by the user.
|
||||
var ErrDownloadCancelled = errors.New("download cancelled")
|
||||
|
||||
// ErrExtensionRequestCancelled is returned when a UI-driven extension request
|
||||
// is superseded by a newer home/search request.
|
||||
var ErrExtensionRequestCancelled = errors.New("extension request cancelled")
|
||||
|
||||
type cancelEntry struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
canceled bool
|
||||
refs int
|
||||
}
|
||||
|
||||
var (
|
||||
cancelMu sync.Mutex
|
||||
cancelMap = make(map[string]*cancelEntry)
|
||||
|
||||
extensionRequestCancelMu sync.Mutex
|
||||
extensionRequestCancelMap = make(map[string]*cancelEntry)
|
||||
)
|
||||
|
||||
func initDownloadCancel(itemID string) context.Context {
|
||||
if itemID == "" {
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
cancelMu.Lock()
|
||||
defer cancelMu.Unlock()
|
||||
|
||||
if entry, ok := cancelMap[itemID]; ok {
|
||||
if entry.ctx == nil {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
entry.ctx = ctx
|
||||
entry.cancel = cancel
|
||||
if entry.canceled && entry.cancel != nil {
|
||||
entry.cancel()
|
||||
}
|
||||
}
|
||||
entry.refs++
|
||||
return entry.ctx
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancelMap[itemID] = &cancelEntry{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
canceled: false,
|
||||
refs: 1,
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func downloadCancelContext(itemID string) context.Context {
|
||||
if itemID == "" {
|
||||
return context.Background()
|
||||
}
|
||||
cancelMu.Lock()
|
||||
defer cancelMu.Unlock()
|
||||
if entry, ok := cancelMap[itemID]; ok && entry.ctx != nil {
|
||||
return entry.ctx
|
||||
}
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
func cancelDownload(itemID string) {
|
||||
if itemID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
cancelMu.Lock()
|
||||
entry, ok := cancelMap[itemID]
|
||||
if ok {
|
||||
entry.canceled = true
|
||||
if entry.cancel != nil {
|
||||
entry.cancel()
|
||||
}
|
||||
} else {
|
||||
cancelMap[itemID] = &cancelEntry{canceled: true}
|
||||
}
|
||||
cancelMu.Unlock()
|
||||
|
||||
RemoveItemProgress(itemID)
|
||||
}
|
||||
|
||||
func isDownloadCancelled(itemID string) bool {
|
||||
if itemID == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
cancelMu.Lock()
|
||||
entry, ok := cancelMap[itemID]
|
||||
canceled := ok && entry.canceled
|
||||
cancelMu.Unlock()
|
||||
return canceled
|
||||
}
|
||||
|
||||
// resetDownloadCancel removes a cancellation entry that has no active
|
||||
// download attached (refs <= 0). Such entries exist to catch an item that is
|
||||
// just about to start, but if the item never starts the flag lingers and the
|
||||
// next explicit retry would consume it and abort immediately.
|
||||
func resetDownloadCancel(itemID string) {
|
||||
if itemID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
cancelMu.Lock()
|
||||
if entry, ok := cancelMap[itemID]; ok && entry.refs <= 0 {
|
||||
delete(cancelMap, itemID)
|
||||
}
|
||||
cancelMu.Unlock()
|
||||
}
|
||||
|
||||
func clearDownloadCancel(itemID string) {
|
||||
if itemID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
cancelMu.Lock()
|
||||
if entry, ok := cancelMap[itemID]; ok {
|
||||
entry.refs--
|
||||
if entry.refs <= 0 {
|
||||
delete(cancelMap, itemID)
|
||||
}
|
||||
}
|
||||
cancelMu.Unlock()
|
||||
}
|
||||
|
||||
func initExtensionRequestCancel(requestID string) context.Context {
|
||||
if requestID == "" {
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
extensionRequestCancelMu.Lock()
|
||||
defer extensionRequestCancelMu.Unlock()
|
||||
|
||||
if entry, ok := extensionRequestCancelMap[requestID]; ok {
|
||||
if entry.ctx == nil {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
entry.ctx = ctx
|
||||
entry.cancel = cancel
|
||||
if entry.canceled && entry.cancel != nil {
|
||||
entry.cancel()
|
||||
}
|
||||
}
|
||||
entry.refs++
|
||||
return entry.ctx
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
extensionRequestCancelMap[requestID] = &cancelEntry{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
canceled: false,
|
||||
refs: 1,
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func extensionRequestCancelContext(requestID string) context.Context {
|
||||
if requestID == "" {
|
||||
return context.Background()
|
||||
}
|
||||
extensionRequestCancelMu.Lock()
|
||||
defer extensionRequestCancelMu.Unlock()
|
||||
if entry, ok := extensionRequestCancelMap[requestID]; ok && entry.ctx != nil {
|
||||
return entry.ctx
|
||||
}
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
func cancelExtensionRequest(requestID string) {
|
||||
if requestID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
extensionRequestCancelMu.Lock()
|
||||
if entry, ok := extensionRequestCancelMap[requestID]; ok {
|
||||
entry.canceled = true
|
||||
if entry.cancel != nil {
|
||||
entry.cancel()
|
||||
}
|
||||
} else {
|
||||
extensionRequestCancelMap[requestID] = &cancelEntry{canceled: true}
|
||||
}
|
||||
extensionRequestCancelMu.Unlock()
|
||||
}
|
||||
|
||||
func isExtensionRequestCancelled(requestID string) bool {
|
||||
if requestID == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
extensionRequestCancelMu.Lock()
|
||||
entry, ok := extensionRequestCancelMap[requestID]
|
||||
canceled := ok && entry.canceled
|
||||
extensionRequestCancelMu.Unlock()
|
||||
return canceled
|
||||
}
|
||||
|
||||
func clearExtensionRequestCancel(requestID string) {
|
||||
if requestID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
extensionRequestCancelMu.Lock()
|
||||
if entry, ok := extensionRequestCancelMap[requestID]; ok {
|
||||
entry.refs--
|
||||
if entry.refs <= 0 {
|
||||
delete(extensionRequestCancelMap, requestID)
|
||||
}
|
||||
}
|
||||
extensionRequestCancelMu.Unlock()
|
||||
}
|
||||
@@ -4,167 +4,36 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Spotify image size codes (same as PC version)
|
||||
const (
|
||||
spotifySize300 = "ab67616d00001e02"
|
||||
spotifySize640 = "ab67616d0000b273"
|
||||
spotifySizeMax = "ab67616d000082c1"
|
||||
spotifySize640 = "ab67616d0000b273" // 640x640
|
||||
spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000)
|
||||
)
|
||||
|
||||
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
|
||||
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
||||
|
||||
var tidalSizeRegex = regexp.MustCompile(`/\d+x\d+\.jpg$`)
|
||||
|
||||
var qobuzSizeRegex = regexp.MustCompile(`_\d+\.jpg$`)
|
||||
|
||||
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)
|
||||
// This avoids file permission issues on Android
|
||||
func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||
if coverURL == "" {
|
||||
return nil, fmt.Errorf("no cover URL provided")
|
||||
}
|
||||
|
||||
GoLog("[Cover] Original URL: %s", coverURL)
|
||||
|
||||
downloadURL := convertSmallToMedium(coverURL)
|
||||
if downloadURL != coverURL {
|
||||
GoLog("[Cover] Upgraded 300x300 → 640x640")
|
||||
}
|
||||
fmt.Printf("[Cover] Downloading cover from: %s\n", coverURL)
|
||||
|
||||
// Upgrade to max quality if requested
|
||||
downloadURL := coverURL
|
||||
if maxQuality {
|
||||
maxURL := upgradeToMaxQuality(downloadURL)
|
||||
if maxURL != downloadURL {
|
||||
downloadURL = maxURL
|
||||
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
|
||||
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
|
||||
}
|
||||
downloadURL = upgradeToMaxQuality(coverURL)
|
||||
if downloadURL != coverURL {
|
||||
fmt.Printf("[Cover] Upgraded to max quality URL: %s\n", downloadURL)
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Cover] Final URL: %s", downloadURL)
|
||||
|
||||
data, err := fetchCoverCached(downloadURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Cached bytes are shared across goroutines and must never be mutated;
|
||||
// hand callers their own copy.
|
||||
return append([]byte(nil), data...), nil
|
||||
}
|
||||
|
||||
const (
|
||||
coverCacheMaxBytes = 24 * 1024 * 1024
|
||||
coverCacheTTL = 15 * time.Minute
|
||||
)
|
||||
|
||||
type coverCacheEntry struct {
|
||||
data []byte
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type coverInflightCall struct {
|
||||
wg sync.WaitGroup
|
||||
data []byte
|
||||
err error
|
||||
}
|
||||
|
||||
var (
|
||||
coverMu sync.Mutex
|
||||
coverCache = map[string]*coverCacheEntry{}
|
||||
coverCacheBytes int
|
||||
coverInflight = map[string]*coverInflightCall{}
|
||||
coverFetch = fetchCoverBytes
|
||||
)
|
||||
|
||||
func clearCoverMemoryCache() {
|
||||
coverMu.Lock()
|
||||
coverCache = map[string]*coverCacheEntry{}
|
||||
coverCacheBytes = 0
|
||||
coverMu.Unlock()
|
||||
}
|
||||
|
||||
// fetchCoverCached returns cover bytes for a final URL, collapsing concurrent
|
||||
// requests for the same URL into a single fetch (singleflight) and caching
|
||||
// results in memory for the duration of an album batch. The returned slice is
|
||||
// shared; callers must copy before mutating.
|
||||
func fetchCoverCached(downloadURL string) ([]byte, error) {
|
||||
coverMu.Lock()
|
||||
if e, ok := coverCache[downloadURL]; ok {
|
||||
if time.Now().Before(e.expiresAt) {
|
||||
data := e.data
|
||||
coverMu.Unlock()
|
||||
return data, nil
|
||||
}
|
||||
delete(coverCache, downloadURL)
|
||||
coverCacheBytes -= len(e.data)
|
||||
}
|
||||
if call, ok := coverInflight[downloadURL]; ok {
|
||||
coverMu.Unlock()
|
||||
call.wg.Wait()
|
||||
return call.data, call.err
|
||||
}
|
||||
call := &coverInflightCall{}
|
||||
// Default error so a panicking fetch never strands waiters with a
|
||||
// (nil, nil) "success"; overwritten on normal completion.
|
||||
call.err = fmt.Errorf("cover fetch aborted")
|
||||
call.wg.Add(1)
|
||||
coverInflight[downloadURL] = call
|
||||
coverMu.Unlock()
|
||||
|
||||
defer func() {
|
||||
call.wg.Done()
|
||||
coverMu.Lock()
|
||||
delete(coverInflight, downloadURL)
|
||||
coverMu.Unlock()
|
||||
}()
|
||||
|
||||
data, err := coverFetch(downloadURL)
|
||||
call.data, call.err = data, err
|
||||
if err == nil {
|
||||
coverCachePut(downloadURL, data)
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
func coverCachePut(downloadURL string, data []byte) {
|
||||
if len(data) == 0 || len(data) > coverCacheMaxBytes {
|
||||
return
|
||||
}
|
||||
coverMu.Lock()
|
||||
defer coverMu.Unlock()
|
||||
if e, ok := coverCache[downloadURL]; ok {
|
||||
coverCacheBytes -= len(e.data)
|
||||
}
|
||||
coverCache[downloadURL] = &coverCacheEntry{data: data, expiresAt: time.Now().Add(coverCacheTTL)}
|
||||
coverCacheBytes += len(data)
|
||||
for coverCacheBytes > coverCacheMaxBytes && len(coverCache) > 1 {
|
||||
var oldestKey string
|
||||
var oldest time.Time
|
||||
first := true
|
||||
for k, e := range coverCache {
|
||||
if first || e.expiresAt.Before(oldest) {
|
||||
oldest, oldestKey, first = e.expiresAt, k, false
|
||||
}
|
||||
}
|
||||
coverCacheBytes -= len(coverCache[oldestKey].data)
|
||||
delete(coverCache, oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchCoverBytes(downloadURL string) ([]byte, error) {
|
||||
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
||||
|
||||
// Create request with User-Agent (required by Spotify CDN)
|
||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
@@ -185,72 +54,48 @@ func fetchCoverBytes(downloadURL string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("failed to read cover data: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
fmt.Printf("[Cover] Downloaded %d bytes\n", len(data))
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// upgradeToMaxQuality upgrades Spotify cover URL to maximum quality
|
||||
// Uses same logic as PC version - replaces 640x640 size code with max resolution
|
||||
func upgradeToMaxQuality(coverURL string) string {
|
||||
// Spotify image URLs can be upgraded by changing the size parameter
|
||||
// Format: https://i.scdn.co/image/ab67616d0000b273...
|
||||
// ab67616d0000b273 = 640x640
|
||||
// ab67616d000082c1 = Max resolution (~2000x2000)
|
||||
|
||||
if strings.Contains(coverURL, spotifySize640) {
|
||||
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||
}
|
||||
// Try max resolution first
|
||||
maxURL := strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||
|
||||
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||
return upgradeDeezerCover(coverURL)
|
||||
}
|
||||
|
||||
if strings.Contains(coverURL, "resources.tidal.com") {
|
||||
return upgradeTidalCover(coverURL)
|
||||
}
|
||||
|
||||
if strings.Contains(coverURL, "static.qobuz.com") {
|
||||
return upgradeQobuzCover(coverURL)
|
||||
// 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
|
||||
}
|
||||
|
||||
func upgradeDeezerCover(coverURL string) string {
|
||||
if !strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||
return coverURL
|
||||
// GetCoverFromSpotify gets cover URL from Spotify metadata
|
||||
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
||||
if imageURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
|
||||
if upgraded != coverURL {
|
||||
GoLog("[Cover] Deezer: upgraded to 1800x1800")
|
||||
if maxQuality {
|
||||
return upgradeToMaxQuality(imageURL)
|
||||
}
|
||||
return upgraded
|
||||
}
|
||||
|
||||
func upgradeTidalCover(coverURL string) string {
|
||||
if !strings.Contains(coverURL, "resources.tidal.com") {
|
||||
return coverURL
|
||||
}
|
||||
|
||||
upgraded := tidalSizeRegex.ReplaceAllString(coverURL, "/origin.jpg")
|
||||
if upgraded != coverURL {
|
||||
GoLog("[Cover] Tidal: upgraded to origin resolution")
|
||||
}
|
||||
return upgraded
|
||||
}
|
||||
|
||||
func upgradeQobuzCover(coverURL string) string {
|
||||
if !strings.Contains(coverURL, "static.qobuz.com") {
|
||||
return coverURL
|
||||
}
|
||||
|
||||
upgraded := qobuzSizeRegex.ReplaceAllString(coverURL, "_max.jpg")
|
||||
if upgraded != coverURL {
|
||||
GoLog("[Cover] Qobuz: upgraded to max resolution")
|
||||
}
|
||||
return upgraded
|
||||
return imageURL
|
||||
}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func resetCoverCache() {
|
||||
coverMu.Lock()
|
||||
coverCache = map[string]*coverCacheEntry{}
|
||||
coverInflight = map[string]*coverInflightCall{}
|
||||
coverCacheBytes = 0
|
||||
coverMu.Unlock()
|
||||
}
|
||||
|
||||
func TestFetchCoverCachedSingleflight(t *testing.T) {
|
||||
orig := coverFetch
|
||||
defer func() { coverFetch = orig }()
|
||||
resetCoverCache()
|
||||
|
||||
var calls int32
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
coverFetch = func(string) ([]byte, error) {
|
||||
if atomic.AddInt32(&calls, 1) == 1 {
|
||||
close(entered)
|
||||
}
|
||||
<-release
|
||||
return []byte("coverbytes"), nil
|
||||
}
|
||||
|
||||
const url = "https://cdn.example/cover_max.jpg"
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := fetchCoverCached(url); err != nil {
|
||||
t.Errorf("leader fetch error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
<-entered // leader has registered inflight and is blocked in coverFetch
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := fetchCoverCached(url); err != nil {
|
||||
t.Errorf("follower fetch error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
close(release)
|
||||
wg.Wait()
|
||||
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("expected 1 fetch for concurrent requests, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchCoverCachedTTLExpiry(t *testing.T) {
|
||||
orig := coverFetch
|
||||
defer func() { coverFetch = orig }()
|
||||
resetCoverCache()
|
||||
|
||||
var calls int32
|
||||
coverFetch = func(string) ([]byte, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
return []byte("data"), nil
|
||||
}
|
||||
|
||||
const url = "https://cdn.example/ttl.jpg"
|
||||
if _, err := fetchCoverCached(url); err != nil {
|
||||
t.Fatalf("first fetch error: %v", err)
|
||||
}
|
||||
// second call served from cache
|
||||
if _, err := fetchCoverCached(url); err != nil {
|
||||
t.Fatalf("second fetch error: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("expected cache hit, got %d fetches", got)
|
||||
}
|
||||
|
||||
// expire the entry and confirm a refetch
|
||||
coverMu.Lock()
|
||||
coverCache[url].expiresAt = time.Now().Add(-time.Minute)
|
||||
coverMu.Unlock()
|
||||
|
||||
if _, err := fetchCoverCached(url); err != nil {
|
||||
t.Fatalf("third fetch error: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 2 {
|
||||
t.Fatalf("expected refetch after TTL expiry, got %d fetches", got)
|
||||
}
|
||||
}
|
||||
@@ -1,402 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestLoadedExtension(t *testing.T, types ...ExtensionType) *loadedExtension {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.js"), []byte(testExtensionJS), 0600); err != nil {
|
||||
t.Fatalf("write index.js: %v", err)
|
||||
}
|
||||
return &loadedExtension{
|
||||
ID: "coverage-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "coverage-ext",
|
||||
Description: "Coverage extension",
|
||||
Version: "1.0.0",
|
||||
Types: types,
|
||||
Permissions: ExtensionPermissions{File: true, Network: []string{"example.test"}},
|
||||
SearchBehavior: &SearchBehaviorConfig{
|
||||
Enabled: true,
|
||||
Placeholder: "Search coverage",
|
||||
Primary: true,
|
||||
Icon: "search",
|
||||
},
|
||||
URLHandler: &URLHandlerConfig{Enabled: true, Patterns: []string{"https://example.test/"}},
|
||||
TrackMatching: &TrackMatchingConfig{CustomMatching: true},
|
||||
PostProcessing: &PostProcessingConfig{
|
||||
Enabled: true,
|
||||
Hooks: []PostProcessingHook{{ID: "hook", Name: "Hook", DefaultEnabled: true, SupportedFormats: []string{"flac"}}},
|
||||
},
|
||||
},
|
||||
Enabled: true,
|
||||
SourceDir: dir,
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
}
|
||||
|
||||
const testExtensionJS = `
|
||||
function track(id) {
|
||||
return {
|
||||
id: id,
|
||||
name: "Track " + id,
|
||||
artists: "Artist",
|
||||
albumName: "Album",
|
||||
albumArtist: "Album Artist",
|
||||
durationMs: 180000,
|
||||
coverUrl: "https://example.test/cover.jpg",
|
||||
releaseDate: "2026-05-04",
|
||||
trackNumber: 1,
|
||||
totalTracks: 10,
|
||||
discNumber: 1,
|
||||
totalDiscs: 1,
|
||||
isrc: "USRC17607839",
|
||||
itemType: "track",
|
||||
albumType: "album",
|
||||
tidalId: "tidal-1",
|
||||
qobuzId: "qobuz-1",
|
||||
deezerId: "deezer-1",
|
||||
spotifyId: "spotify:track:1",
|
||||
externalLinks: { tidal: "https://tidal.example/1" },
|
||||
label: "Label",
|
||||
copyright: "Copyright",
|
||||
genre: "Pop",
|
||||
composer: "Composer",
|
||||
audioQuality: "FLAC 24-bit",
|
||||
audioModes: "DOLBY_ATMOS",
|
||||
explicit: true
|
||||
};
|
||||
}
|
||||
|
||||
registerExtension({
|
||||
searchTracks: function(query, limit) {
|
||||
return { tracks: [track("search-1")], total: 1 };
|
||||
},
|
||||
customSearch: function(query, options) {
|
||||
var t = track("custom-1");
|
||||
t.name = "Custom " + query;
|
||||
return [t];
|
||||
},
|
||||
getHomeFeed: function() {
|
||||
return [{ id: "home-1", title: "Home", tracks: [track("home-track")] }];
|
||||
},
|
||||
getBrowseCategories: function() {
|
||||
return [{ id: "cat-1", title: "Category" }];
|
||||
},
|
||||
getTrack: function(id) {
|
||||
return track(id);
|
||||
},
|
||||
getAlbum: function(id) {
|
||||
return {
|
||||
id: id,
|
||||
name: "Album " + id,
|
||||
artists: "Artist",
|
||||
artistId: "artist-1",
|
||||
coverUrl: "https://example.test/album.jpg",
|
||||
releaseDate: "2026-05-04",
|
||||
totalTracks: 1,
|
||||
albumType: "album",
|
||||
tracks: [track("album-track")]
|
||||
};
|
||||
},
|
||||
getPlaylist: function(id) {
|
||||
return {
|
||||
id: id,
|
||||
name: "Playlist " + id,
|
||||
artists: "Owner",
|
||||
coverUrl: "https://example.test/playlist.jpg",
|
||||
totalTracks: 1,
|
||||
tracks: [track("playlist-track")]
|
||||
};
|
||||
},
|
||||
getArtist: function(id) {
|
||||
return {
|
||||
id: id,
|
||||
name: "Artist",
|
||||
imageUrl: "https://example.test/artist.jpg",
|
||||
headerImage: "https://example.test/header.jpg",
|
||||
listeners: 123,
|
||||
albums: [{ id: "album-1", name: "Album", artists: "Artist", totalTracks: 1 }],
|
||||
releases: [{ id: "release-1", name: "Release", artists: "Artist", totalTracks: 1, tracks: [track("release-track")] }],
|
||||
topTracks: [track("top-track")]
|
||||
};
|
||||
},
|
||||
enrichTrack: function(input) {
|
||||
var t = track(input.id || "enriched");
|
||||
t.name = "Enriched";
|
||||
return t;
|
||||
},
|
||||
checkAvailability: function(isrc, name, artist, ids) {
|
||||
return { available: true, reason: "ok", trackId: "download-track", skipFallback: true };
|
||||
},
|
||||
getDownloadUrl: function(id, quality) {
|
||||
return { url: "https://example.test/audio.flac", format: "flac", bitDepth: 24, sampleRate: 96000 };
|
||||
},
|
||||
download: function(id, quality, outputPath, onProgress) {
|
||||
if (onProgress) onProgress(100);
|
||||
return {
|
||||
success: true,
|
||||
filePath: "EXISTS:" + outputPath,
|
||||
alreadyExists: false,
|
||||
bitDepth: 24,
|
||||
sampleRate: 96000,
|
||||
title: "Downloaded",
|
||||
artist: "Artist",
|
||||
album: "Album",
|
||||
albumArtist: "Album Artist",
|
||||
trackNumber: 1,
|
||||
totalTracks: 10,
|
||||
discNumber: 1,
|
||||
totalDiscs: 1,
|
||||
releaseDate: "2026-05-04",
|
||||
coverUrl: "https://example.test/cover.jpg",
|
||||
isrc: "USRC17607839",
|
||||
genre: "Pop",
|
||||
label: "Label",
|
||||
copyright: "Copyright",
|
||||
composer: "Composer",
|
||||
lyricsLrc: "[00:00.00]Hello",
|
||||
decryptionKey: "001122",
|
||||
decryption: { strategy: "mp4_decryption_key", options: { kid: "1" } }
|
||||
};
|
||||
},
|
||||
fetchLyrics: function(name, artist, album, duration) {
|
||||
return { syncType: "LINE_SYNCED", provider: "coverage-ext", lines: [{ startTimeMs: 0, endTimeMs: 1000, words: "Hello" }] };
|
||||
},
|
||||
handleUrl: function(url) {
|
||||
return { type: "track", name: "Handled", coverUrl: "https://example.test/cover.jpg", track: track("url-track"), tracks: [track("url-track")], album: this.getAlbum("url-album"), artist: this.getArtist("url-artist") };
|
||||
},
|
||||
matchTrack: function(req) {
|
||||
return { matched: true, trackId: "download-track", confidence: 0.95, reason: "exact" };
|
||||
},
|
||||
postProcess: function(path, req) {
|
||||
return { success: true, newFilePath: path, bitDepth: 24, sampleRate: 96000 };
|
||||
},
|
||||
postProcessV2: function(input, metadata, hookId) {
|
||||
return { success: true, newFilePath: input.path || input.uri, newFileUri: input.uri || "", bitDepth: 24, sampleRate: 96000 };
|
||||
}
|
||||
});
|
||||
`
|
||||
|
||||
func mustReadFile(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read file: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func buildID3v23Tag(frames ...[]byte) []byte {
|
||||
body := bytes.Join(frames, nil)
|
||||
header := []byte{'I', 'D', '3', 3, 0, 0, 0, 0, 0, 0}
|
||||
copy(header[6:10], syncsafeBytes(len(body)))
|
||||
return append(header, body...)
|
||||
}
|
||||
|
||||
func id3TextFrame(id, value string) []byte {
|
||||
return id3v23Frame(id, append([]byte{3}, []byte(value)...))
|
||||
}
|
||||
|
||||
func id3CommentFrame(id, value string) []byte {
|
||||
payload := append([]byte{3, 'e', 'n', 'g', 0}, []byte(value)...)
|
||||
return id3v23Frame(id, payload)
|
||||
}
|
||||
|
||||
func id3UserTextFrame(id, desc, value string) []byte {
|
||||
payload := append([]byte{3}, []byte(desc)...)
|
||||
payload = append(payload, 0)
|
||||
payload = append(payload, []byte(value)...)
|
||||
return id3v23Frame(id, payload)
|
||||
}
|
||||
|
||||
func id3v23Frame(id string, payload []byte) []byte {
|
||||
frame := make([]byte, 10+len(payload))
|
||||
copy(frame[0:4], id)
|
||||
binary.BigEndian.PutUint32(frame[4:8], uint32(len(payload)))
|
||||
copy(frame[10:], payload)
|
||||
return frame
|
||||
}
|
||||
|
||||
func buildID3v22Tag(frames ...[]byte) []byte {
|
||||
body := bytes.Join(frames, nil)
|
||||
header := []byte{'I', 'D', '3', 2, 0, 0, 0, 0, 0, 0}
|
||||
copy(header[6:10], syncsafeBytes(len(body)))
|
||||
return append(header, body...)
|
||||
}
|
||||
|
||||
func id3v22TextFrame(id, value string) []byte {
|
||||
return id3v22Frame(id, append([]byte{3}, []byte(value)...))
|
||||
}
|
||||
|
||||
func id3v22CommentFrame(id, value string) []byte {
|
||||
payload := append([]byte{3, 'e', 'n', 'g', 0}, []byte(value)...)
|
||||
return id3v22Frame(id, payload)
|
||||
}
|
||||
|
||||
func id3v22Frame(id string, payload []byte) []byte {
|
||||
frame := make([]byte, 6+len(payload))
|
||||
copy(frame[0:3], id)
|
||||
size := len(payload)
|
||||
frame[3] = byte(size >> 16)
|
||||
frame[4] = byte(size >> 8)
|
||||
frame[5] = byte(size)
|
||||
copy(frame[6:], payload)
|
||||
return frame
|
||||
}
|
||||
|
||||
func syncsafeBytes(size int) []byte {
|
||||
return []byte{
|
||||
byte((size >> 21) & 0x7f),
|
||||
byte((size >> 14) & 0x7f),
|
||||
byte((size >> 7) & 0x7f),
|
||||
byte(size & 0x7f),
|
||||
}
|
||||
}
|
||||
|
||||
func buildID3v1Tag(title, artist, album, year string, track, genre byte) []byte {
|
||||
tag := make([]byte, 128)
|
||||
copy(tag[0:3], "TAG")
|
||||
copyPadded(tag[3:33], title)
|
||||
copyPadded(tag[33:63], artist)
|
||||
copyPadded(tag[63:93], album)
|
||||
copyPadded(tag[93:97], year)
|
||||
tag[125] = 0
|
||||
tag[126] = track
|
||||
tag[127] = genre
|
||||
return tag
|
||||
}
|
||||
|
||||
func copyPadded(dst []byte, value string) {
|
||||
for i := range dst {
|
||||
dst[i] = ' '
|
||||
}
|
||||
copy(dst, value)
|
||||
}
|
||||
|
||||
func writeExportCueFixture(t *testing.T, dir string) (string, string) {
|
||||
t.Helper()
|
||||
audioPath := filepath.Join(dir, "exports.wav")
|
||||
if err := os.WriteFile(audioPath, []byte("audio"), 0600); err != nil {
|
||||
t.Fatalf("write export audio: %v", err)
|
||||
}
|
||||
cuePath := filepath.Join(dir, "exports.cue")
|
||||
cue := "PERFORMER \"Artist\"\nTITLE \"Album\"\nFILE \"exports.wav\" WAVE\n TRACK 01 AUDIO\n TITLE \"Song\"\n INDEX 01 00:00:00\n"
|
||||
if err := os.WriteFile(cuePath, []byte(cue), 0600); err != nil {
|
||||
t.Fatalf("write export cue: %v", err)
|
||||
}
|
||||
return cuePath, audioPath
|
||||
}
|
||||
|
||||
func escapeJSONPath(path string) string {
|
||||
data, _ := json.Marshal(path)
|
||||
return strings.Trim(string(data), `"`)
|
||||
}
|
||||
|
||||
func fakeDeezerResponse(path, rawQuery string) string {
|
||||
switch {
|
||||
case path == "/2.0/search/track":
|
||||
if strings.Contains(rawQuery, "MISSING") {
|
||||
return `{"data":[]}`
|
||||
}
|
||||
return `{"data":[` + fakeDeezerTrackJSON(101, true) + `]}`
|
||||
case path == "/2.0/search/artist":
|
||||
return `{"data":[{"id":301,"name":"Artist","picture_xl":"artist-xl","nb_fan":123}]}`
|
||||
case path == "/2.0/search/album":
|
||||
return `{"data":[{"id":201,"title":"Album","cover_xl":"album-xl","nb_tracks":2,"release_date":"2026-05-04","record_type":"compile","artist":{"id":301,"name":"Artist"}}]}`
|
||||
case path == "/2.0/search/playlist":
|
||||
return `{"data":[{"id":401,"title":"Playlist","picture_xl":"playlist-xl","nb_tracks":2,"user":{"name":"Owner"}}]}`
|
||||
case path == "/2.0/track/101", path == "/2.0/track/isrc:USRC17607839":
|
||||
return fakeDeezerTrackJSON(101, true)
|
||||
case path == "/2.0/track/102":
|
||||
return fakeDeezerTrackJSON(102, true)
|
||||
case path == "/2.0/track/isrc:MISSING":
|
||||
return `{"id":0}`
|
||||
case path == "/2.0/album/201":
|
||||
return `{"id":201,"title":"Album","cover_xl":"album-xl","release_date":"2026-05-04","nb_tracks":2,"record_type":"compile","label":"Label","copyright":"Copyright","genres":{"data":[{"name":"Pop"},{"name":"Dance"}]},"artist":{"id":301,"name":"Album Artist"},"contributors":[{"name":"Contributor A"},{"name":"Contributor B"}],"tracks":{"data":[` + fakeDeezerTrackJSON(101, true) + `,` + fakeDeezerTrackJSON(102, false) + `]}}`
|
||||
case path == "/2.0/artist/301":
|
||||
return `{"id":301,"name":"Artist","picture_xl":"artist-xl","nb_fan":123,"nb_album":1}`
|
||||
case path == "/2.0/artist/301/albums":
|
||||
return `{"data":[{"id":201,"title":"Album","release_date":"2026-05-04","nb_tracks":0,"cover_xl":"album-xl","record_type":"compile"}]}`
|
||||
case path == "/2.0/artist/301/related":
|
||||
return `{"data":[{"id":302,"name":"Related","picture_xl":"related-xl","nb_fan":10}]}`
|
||||
case path == "/2.0/playlist/401":
|
||||
return `{"id":401,"title":"Playlist","picture_xl":"playlist-xl","nb_tracks":2,"creator":{"name":"Owner"},"tracks":{"data":[` + fakeDeezerTrackJSON(101, true) + `,` + fakeDeezerTrackJSON(102, false) + `]}}`
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func fakeDeezerTrackJSON(id int, withISRC bool) string {
|
||||
isrc := ""
|
||||
if withISRC {
|
||||
isrc = `,"isrc":"USRC17607839"`
|
||||
if id == 102 {
|
||||
isrc = `,"isrc":"USRC17607840"`
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf(`{"id":%d,"title":"Track %d","duration":180,"track_position":%d,"disk_number":1%s,"link":"https://deezer.test/track/%d","release_date":"2026-05-04","artist":{"id":301,"name":"Artist"},"contributors":[{"name":"Contributor A"},{"name":"Contributor B"}],"album":{"id":201,"title":"Album","cover_xl":"album-xl","release_date":"2026-05-04","record_type":"album"}}`, id, id, id-100, isrc, id)
|
||||
}
|
||||
|
||||
func createTestExtensionPackage(t *testing.T, path, name, version, js string, extraFiles map[string]string) {
|
||||
t.Helper()
|
||||
out, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create extension package: %v", err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
zw := zip.NewWriter(out)
|
||||
defer zw.Close()
|
||||
|
||||
manifest := fmt.Sprintf(`{
|
||||
"name": %q,
|
||||
"displayName": %q,
|
||||
"version": %q,
|
||||
"description": "Packaged test extension",
|
||||
"type": ["metadata_provider", "download_provider", "lyrics_provider"],
|
||||
"permissions": {"network": ["example.test"], "storage": true, "file": true},
|
||||
"icon": "icon.png",
|
||||
"settings": [{"key":"quality","type":"string","label":"Quality"}],
|
||||
"qualityOptions": [{"id":"lossless","label":"Lossless","description":"Lossless"}],
|
||||
"searchBehavior": {"enabled": true, "placeholder": "Search", "primary": true},
|
||||
"urlHandler": {"enabled": true, "patterns": ["https://example.test/"]},
|
||||
"trackMatching": {"customMatching": true},
|
||||
"postProcessing": {"enabled": true, "hooks": [{"id":"hook","name":"Hook"}]},
|
||||
"serviceHealth": [{"id":"main","url":"https://example.test/health"}],
|
||||
"capabilities": {"homeFeed": true}
|
||||
}`, name, name, version)
|
||||
|
||||
for fileName, content := range map[string]string{
|
||||
"manifest.json": manifest,
|
||||
"index.js": js,
|
||||
"icon.png": "png",
|
||||
} {
|
||||
writer, err := zw.Create(fileName)
|
||||
if err != nil {
|
||||
t.Fatalf("zip create %s: %v", fileName, err)
|
||||
}
|
||||
if _, err := writer.Write([]byte(content)); err != nil {
|
||||
t.Fatalf("zip write %s: %v", fileName, err)
|
||||
}
|
||||
}
|
||||
for fileName, content := range extraFiles {
|
||||
writer, err := zw.Create(fileName)
|
||||
if err != nil {
|
||||
t.Fatalf("zip create extra %s: %v", fileName, err)
|
||||
}
|
||||
if _, err := writer.Write([]byte(content)); err != nil {
|
||||
t.Fatalf("zip write extra %s: %v", fileName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,442 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type CrossExtensionShareResult struct {
|
||||
ExtensionID string `json:"extension_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Found bool `json:"found"`
|
||||
URL string `json:"url,omitempty"`
|
||||
ItemName string `json:"item_name,omitempty"`
|
||||
ItemArtists string `json:"item_artists,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
var crossExtensionShareResultCache = struct {
|
||||
sync.RWMutex
|
||||
entries map[string]string
|
||||
order []string
|
||||
}{
|
||||
entries: make(map[string]string),
|
||||
}
|
||||
|
||||
const crossExtensionShareResultCacheLimit = 128
|
||||
|
||||
func FindCollectionAcrossExtensionsJSON(requestJSON string) (string, error) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Artists string `json:"artists"`
|
||||
Type string `json:"type"`
|
||||
SourceExtensionID string `json:"source_extension_id"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
req.Artists = strings.TrimSpace(req.Artists)
|
||||
req.Type = strings.ToLower(strings.TrimSpace(req.Type))
|
||||
req.SourceExtensionID = strings.TrimSpace(req.SourceExtensionID)
|
||||
if req.Name == "" {
|
||||
return "[]", nil
|
||||
}
|
||||
if req.Type == "" {
|
||||
req.Type = "album"
|
||||
}
|
||||
|
||||
providers := getExtensionManager().GetMetadataProviders()
|
||||
work := make([]*extensionProviderWrapper, 0, len(providers))
|
||||
for _, provider := range providers {
|
||||
if provider == nil || provider.extension == nil {
|
||||
continue
|
||||
}
|
||||
if provider.extension.ID == req.SourceExtensionID {
|
||||
continue
|
||||
}
|
||||
work = append(work, provider)
|
||||
}
|
||||
cacheKey := crossExtensionShareCacheKey(req.Name, req.Artists, req.Type, req.SourceExtensionID, work)
|
||||
if cached := getCrossExtensionShareCache(cacheKey); cached != "" {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
query := req.Name
|
||||
if req.Artists != "" {
|
||||
query += " " + req.Artists
|
||||
}
|
||||
|
||||
results := make([]CrossExtensionShareResult, len(work))
|
||||
var wg sync.WaitGroup
|
||||
for i, provider := range work {
|
||||
wg.Add(1)
|
||||
go func(index int, p *extensionProviderWrapper) {
|
||||
defer wg.Done()
|
||||
results[index] = findCollectionForExtension(
|
||||
p,
|
||||
req.Type,
|
||||
req.Name,
|
||||
req.Artists,
|
||||
query,
|
||||
)
|
||||
}(i, provider)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
data, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
return "[]", err
|
||||
}
|
||||
response := string(data)
|
||||
if crossExtensionShareResultsCacheable(results) {
|
||||
setCrossExtensionShareCache(cacheKey, response)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func crossExtensionShareCacheKey(name string, artists string, itemType string, sourceExtensionID string, providers []*extensionProviderWrapper) string {
|
||||
providerKeys := make([]string, 0, len(providers))
|
||||
for _, provider := range providers {
|
||||
if provider == nil || provider.extension == nil {
|
||||
continue
|
||||
}
|
||||
ext := provider.extension
|
||||
displayName := ""
|
||||
if ext.Manifest != nil {
|
||||
displayName = ext.Manifest.DisplayName
|
||||
}
|
||||
providerKeys = append(providerKeys, strings.Join([]string{
|
||||
strings.TrimSpace(ext.ID),
|
||||
strings.TrimSpace(displayName),
|
||||
strings.TrimSpace(ext.SourceDir),
|
||||
}, "\x1f"))
|
||||
}
|
||||
sort.Strings(providerKeys)
|
||||
|
||||
return strings.Join([]string{
|
||||
normalizeLooseTitle(itemType),
|
||||
normalizeLooseTitle(name),
|
||||
normalizeLooseArtistName(artists),
|
||||
strings.TrimSpace(sourceExtensionID),
|
||||
strings.Join(providerKeys, "\x1e"),
|
||||
}, "\x1d")
|
||||
}
|
||||
|
||||
func getCrossExtensionShareCache(key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
crossExtensionShareResultCache.RLock()
|
||||
defer crossExtensionShareResultCache.RUnlock()
|
||||
return crossExtensionShareResultCache.entries[key]
|
||||
}
|
||||
|
||||
func setCrossExtensionShareCache(key string, value string) {
|
||||
if key == "" || value == "" {
|
||||
return
|
||||
}
|
||||
crossExtensionShareResultCache.Lock()
|
||||
defer crossExtensionShareResultCache.Unlock()
|
||||
|
||||
if _, exists := crossExtensionShareResultCache.entries[key]; !exists {
|
||||
crossExtensionShareResultCache.order = append(crossExtensionShareResultCache.order, key)
|
||||
}
|
||||
crossExtensionShareResultCache.entries[key] = value
|
||||
|
||||
for len(crossExtensionShareResultCache.order) > crossExtensionShareResultCacheLimit {
|
||||
oldest := crossExtensionShareResultCache.order[0]
|
||||
crossExtensionShareResultCache.order = crossExtensionShareResultCache.order[1:]
|
||||
delete(crossExtensionShareResultCache.entries, oldest)
|
||||
}
|
||||
}
|
||||
|
||||
func crossExtensionShareResultsCacheable(results []CrossExtensionShareResult) bool {
|
||||
for _, result := range results {
|
||||
if result.Found {
|
||||
continue
|
||||
}
|
||||
errText := strings.ToLower(strings.TrimSpace(result.Error))
|
||||
if errText == "" ||
|
||||
errText == "no results" ||
|
||||
errText == "unsupported collection type" ||
|
||||
strings.HasSuffix(errText, " not found") ||
|
||||
strings.Contains(errText, "found without shareable link") {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func findCollectionForExtension(
|
||||
provider *extensionProviderWrapper,
|
||||
itemType string,
|
||||
name string,
|
||||
artists string,
|
||||
query string,
|
||||
) CrossExtensionShareResult {
|
||||
result := CrossExtensionShareResult{
|
||||
ExtensionID: provider.extension.ID,
|
||||
}
|
||||
if provider.extension.Manifest != nil {
|
||||
result.DisplayName = provider.extension.Manifest.DisplayName
|
||||
}
|
||||
if result.DisplayName == "" {
|
||||
result.DisplayName = provider.extension.ID
|
||||
}
|
||||
|
||||
searchResult, err := searchCollectionCandidates(provider, itemType, query)
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
return result
|
||||
}
|
||||
if searchResult == nil || len(searchResult.Tracks) == 0 {
|
||||
result.Error = "no results"
|
||||
return result
|
||||
}
|
||||
|
||||
var best *ExtTrackMetadata
|
||||
switch itemType {
|
||||
case "artist":
|
||||
best = bestArtistTrack(searchResult.Tracks, name)
|
||||
case "album":
|
||||
best = bestAlbumTrack(searchResult.Tracks, name, artists)
|
||||
default:
|
||||
result.Error = "unsupported collection type"
|
||||
return result
|
||||
}
|
||||
if best == nil {
|
||||
result.Error = itemType + " not found"
|
||||
return result
|
||||
}
|
||||
|
||||
url := resolveCollectionShareURL(provider.extension, itemType, best)
|
||||
if url == "" {
|
||||
result.Error = itemType + " found without shareable link"
|
||||
return result
|
||||
}
|
||||
|
||||
result.Found = true
|
||||
result.URL = url
|
||||
if itemType == "artist" {
|
||||
result.ItemName = collectionArtistName(*best)
|
||||
} else {
|
||||
result.ItemName = collectionAlbumName(*best)
|
||||
result.ItemArtists = best.Artists
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func searchCollectionCandidates(provider *extensionProviderWrapper, itemType string, query string) (*ExtSearchResult, error) {
|
||||
filter := ""
|
||||
switch itemType {
|
||||
case "album":
|
||||
filter = "albums"
|
||||
case "artist":
|
||||
filter = "artists"
|
||||
}
|
||||
|
||||
if filter != "" {
|
||||
tracks, err := provider.CustomSearch(query, map[string]any{
|
||||
"filter": filter,
|
||||
"limit": 10,
|
||||
})
|
||||
if err == nil && len(tracks) > 0 {
|
||||
return &ExtSearchResult{Tracks: tracks, Total: len(tracks)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return provider.SearchTracks(query, 10)
|
||||
}
|
||||
|
||||
func bestAlbumTrack(tracks []ExtTrackMetadata, albumName string, artists string) *ExtTrackMetadata {
|
||||
targetAlbum := normalizeLooseTitle(albumName)
|
||||
targetArtists := normalizeLooseArtistName(artists)
|
||||
bestScore := 0
|
||||
bestIndex := -1
|
||||
|
||||
for i := range tracks {
|
||||
track := tracks[i]
|
||||
album := normalizeLooseTitle(collectionAlbumName(track))
|
||||
trackArtists := normalizeLooseArtistName(track.Artists + " " + track.AlbumArtist)
|
||||
|
||||
score := 0
|
||||
if isCollectionItemType(track, "album") {
|
||||
score += 25
|
||||
}
|
||||
if album == targetAlbum {
|
||||
score += 100
|
||||
} else if album != "" && targetAlbum != "" && (strings.Contains(album, targetAlbum) || strings.Contains(targetAlbum, album)) {
|
||||
score += 50
|
||||
}
|
||||
if targetArtists != "" && (strings.Contains(trackArtists, targetArtists) || strings.Contains(targetArtists, trackArtists)) {
|
||||
score += 30
|
||||
}
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
if bestIndex < 0 || bestScore < 50 {
|
||||
return nil
|
||||
}
|
||||
return &tracks[bestIndex]
|
||||
}
|
||||
|
||||
func bestArtistTrack(tracks []ExtTrackMetadata, artistName string) *ExtTrackMetadata {
|
||||
targetArtist := normalizeLooseArtistName(artistName)
|
||||
bestScore := 0
|
||||
bestIndex := -1
|
||||
|
||||
for i := range tracks {
|
||||
artist := normalizeLooseArtistName(collectionArtistName(tracks[i]))
|
||||
score := 0
|
||||
if isCollectionItemType(tracks[i], "artist") {
|
||||
score += 25
|
||||
}
|
||||
if artist == targetArtist {
|
||||
score += 100
|
||||
} else if artist != "" && targetArtist != "" && (strings.Contains(artist, targetArtist) || strings.Contains(targetArtist, artist)) {
|
||||
score += 60
|
||||
}
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
if bestIndex < 0 || bestScore < 60 {
|
||||
return nil
|
||||
}
|
||||
return &tracks[bestIndex]
|
||||
}
|
||||
|
||||
func resolveCollectionShareURL(ext *loadedExtension, itemType string, track *ExtTrackMetadata) string {
|
||||
if track == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if itemType == "album" {
|
||||
if isCollectionItemType(*track, "album") {
|
||||
if url := normalizeShareURL(track.ExternalURL); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
if url := normalizeShareURL(track.AlbumURL); url != "" {
|
||||
return url
|
||||
}
|
||||
if url := urlFromExternalLinks(track.ExternalLinks, "album"); url != "" {
|
||||
return url
|
||||
}
|
||||
if url := templateShareURL(ext, "album", firstNonEmptyString(track.AlbumID, collectionID(*track, "album"), track.AlbumURL)); url != "" {
|
||||
return url
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
if isCollectionItemType(*track, "artist") {
|
||||
if url := normalizeShareURL(track.ExternalURL); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
if url := normalizeShareURL(track.ArtistURL); url != "" {
|
||||
return url
|
||||
}
|
||||
if url := urlFromExternalLinks(track.ExternalLinks, "artist"); url != "" {
|
||||
return url
|
||||
}
|
||||
if url := templateShareURL(ext, "artist", firstNonEmptyString(track.ArtistID, collectionID(*track, "artist"))); url != "" {
|
||||
return url
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func collectionAlbumName(track ExtTrackMetadata) string {
|
||||
if isCollectionItemType(track, "album") {
|
||||
return track.Name
|
||||
}
|
||||
return track.AlbumName
|
||||
}
|
||||
|
||||
func collectionArtistName(track ExtTrackMetadata) string {
|
||||
if isCollectionItemType(track, "artist") {
|
||||
return track.Name
|
||||
}
|
||||
return track.Artists
|
||||
}
|
||||
|
||||
func collectionID(track ExtTrackMetadata, itemType string) string {
|
||||
if isCollectionItemType(track, itemType) {
|
||||
return track.ID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isCollectionItemType(track ExtTrackMetadata, itemType string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(track.ItemType), itemType)
|
||||
}
|
||||
|
||||
func normalizeShareURL(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
|
||||
return trimmed
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func urlFromExternalLinks(links map[string]string, preferredKey string) string {
|
||||
for key, value := range links {
|
||||
if strings.Contains(strings.ToLower(key), preferredKey) {
|
||||
if url := normalizeShareURL(value); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func templateShareURL(ext *loadedExtension, itemType string, id string) string {
|
||||
if ext == nil || ext.Manifest == nil || ext.Manifest.Capabilities == nil {
|
||||
return ""
|
||||
}
|
||||
id = stripProviderPrefix(strings.TrimSpace(id))
|
||||
if id == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
templates, ok := ext.Manifest.Capabilities["shareUrlTemplates"].(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
rawTemplate, ok := templates[itemType].(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
rawTemplate = strings.TrimSpace(rawTemplate)
|
||||
if rawTemplate == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ReplaceAll(rawTemplate, "{id}", id)
|
||||
}
|
||||
|
||||
func stripProviderPrefix(id string) string {
|
||||
if index := strings.Index(id, ":"); index > 0 && index < len(id)-1 {
|
||||
return id[index+1:]
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCrossExtensionShareUsesAlbumCollectionItems(t *testing.T) {
|
||||
ext := &loadedExtension{
|
||||
Manifest: &ExtensionManifest{
|
||||
Capabilities: map[string]any{
|
||||
"shareUrlTemplates": map[string]any{
|
||||
"album": "https://music.apple.com/us/album/{id}",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
tracks := []ExtTrackMetadata{
|
||||
{
|
||||
ID: "1440783617",
|
||||
Name: "Nevermind",
|
||||
Artists: "Nirvana",
|
||||
ItemType: "album",
|
||||
},
|
||||
}
|
||||
|
||||
best := bestAlbumTrack(tracks, "Nevermind", "Nirvana")
|
||||
if best == nil {
|
||||
t.Fatal("expected album collection item to match")
|
||||
}
|
||||
if url := resolveCollectionShareURL(ext, "album", best); url != "https://music.apple.com/us/album/1440783617" {
|
||||
t.Fatalf("album share URL = %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossExtensionShareUsesArtistCollectionItems(t *testing.T) {
|
||||
ext := &loadedExtension{
|
||||
Manifest: &ExtensionManifest{
|
||||
Capabilities: map[string]any{
|
||||
"shareUrlTemplates": map[string]any{
|
||||
"artist": "https://music.youtube.com/browse/{id}",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
tracks := []ExtTrackMetadata{
|
||||
{
|
||||
ID: "UCrPe3hLA51968GwxHSZ1llw",
|
||||
Name: "Nirvana",
|
||||
ItemType: "artist",
|
||||
},
|
||||
}
|
||||
|
||||
best := bestArtistTrack(tracks, "Nirvana")
|
||||
if best == nil {
|
||||
t.Fatal("expected artist collection item to match")
|
||||
}
|
||||
if url := resolveCollectionShareURL(ext, "artist", best); url != "https://music.youtube.com/browse/UCrPe3hLA51968GwxHSZ1llw" {
|
||||
t.Fatalf("artist share URL = %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossExtensionShareCacheKeyIsProviderOrderStable(t *testing.T) {
|
||||
apple := &extensionProviderWrapper{
|
||||
extension: &loadedExtension{
|
||||
ID: "apple",
|
||||
SourceDir: "/extensions/apple",
|
||||
Manifest: &ExtensionManifest{DisplayName: "Apple Music"},
|
||||
},
|
||||
}
|
||||
qobuz := &extensionProviderWrapper{
|
||||
extension: &loadedExtension{
|
||||
ID: "qobuz",
|
||||
SourceDir: "/extensions/qobuz",
|
||||
Manifest: &ExtensionManifest{DisplayName: "Qobuz"},
|
||||
},
|
||||
}
|
||||
|
||||
first := crossExtensionShareCacheKey("Nevermind", "Nirvana", "album", "spotify", []*extensionProviderWrapper{apple, qobuz})
|
||||
second := crossExtensionShareCacheKey("Nevermind", "Nirvana", "album", "spotify", []*extensionProviderWrapper{qobuz, apple})
|
||||
if first != second {
|
||||
t.Fatalf("cache key should not depend on provider order:\n%s\n%s", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossExtensionShareCacheableSkipsTransientErrors(t *testing.T) {
|
||||
cacheable := []CrossExtensionShareResult{
|
||||
{ExtensionID: "apple", Found: true, URL: "https://music.apple.com/us/album/1"},
|
||||
{ExtensionID: "qobuz", Error: "album not found"},
|
||||
{ExtensionID: "tidal", Error: "no results"},
|
||||
}
|
||||
if !crossExtensionShareResultsCacheable(cacheable) {
|
||||
t.Fatal("expected found and deterministic not-found results to be cacheable")
|
||||
}
|
||||
|
||||
transient := []CrossExtensionShareResult{
|
||||
{ExtensionID: "apple", Found: true, URL: "https://music.apple.com/us/album/1"},
|
||||
{ExtensionID: "qobuz", Error: "request failed: timeout"},
|
||||
}
|
||||
if crossExtensionShareResultsCacheable(transient) {
|
||||
t.Fatal("expected transient extension errors to skip cache")
|
||||
}
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCueParserEndToEnd(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
audioPath := filepath.Join(dir, "album.wav")
|
||||
if err := os.WriteFile(audioPath, []byte("audio"), 0600); err != nil {
|
||||
t.Fatalf("write audio: %v", err)
|
||||
}
|
||||
cuePath := filepath.Join(dir, "album.cue")
|
||||
cue := "\ufeffREM GENRE \"Pop\"\n" +
|
||||
"REM DATE 2026\n" +
|
||||
"REM COMMENT \"comment\"\n" +
|
||||
"REM COMPOSER \"Album Composer\"\n" +
|
||||
"PERFORMER \"Album Artist\"\n" +
|
||||
"TITLE \"Album Title\"\n" +
|
||||
"FILE \"album.wav\" WAVE\n" +
|
||||
" TRACK 01 AUDIO\n" +
|
||||
" TITLE \"First\"\n" +
|
||||
" PERFORMER \"Track Artist\"\n" +
|
||||
" ISRC USRC17607839\n" +
|
||||
" INDEX 01 00:00:00\n" +
|
||||
" TRACK 02 AUDIO\n" +
|
||||
" TITLE \"Second\"\n" +
|
||||
" SONGWRITER \"Track Composer\"\n" +
|
||||
" INDEX 00 03:00:00\n" +
|
||||
" INDEX 01 03:05:00\n"
|
||||
if err := os.WriteFile(cuePath, []byte(cue), 0600); err != nil {
|
||||
t.Fatalf("write cue: %v", err)
|
||||
}
|
||||
|
||||
sheet, err := ParseCueFile(cuePath)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCueFile: %v", err)
|
||||
}
|
||||
if sheet.Performer != "Album Artist" || sheet.Title != "Album Title" || len(sheet.Tracks) != 2 {
|
||||
t.Fatalf("sheet = %#v", sheet)
|
||||
}
|
||||
if got := parseCueTimestamp("01:02:37"); got <= 62 || got >= 63 {
|
||||
t.Fatalf("timestamp = %f", got)
|
||||
}
|
||||
if got := formatCueTimestamp(3723.5); got != "01:02:03.500" {
|
||||
t.Fatalf("format timestamp = %q", got)
|
||||
}
|
||||
if got := unquoteCue(" \"quoted\" "); got != "quoted" {
|
||||
t.Fatalf("unquote = %q", got)
|
||||
}
|
||||
fileName, fileType := parseCueFileLine("unquoted album.flac FLAC")
|
||||
if fileName != "unquoted album.flac" || fileType != "FLAC" {
|
||||
t.Fatalf("file line = %q/%q", fileName, fileType)
|
||||
}
|
||||
|
||||
if resolved := ResolveCueAudioPath(cuePath, "album.flac"); resolved != audioPath {
|
||||
t.Fatalf("resolved = %q want %q", resolved, audioPath)
|
||||
}
|
||||
info, err := BuildCueSplitInfo(cuePath, sheet, "")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCueSplitInfo: %v", err)
|
||||
}
|
||||
if info.Tracks[0].EndSec != 180 || info.Tracks[1].Composer != "Track Composer" {
|
||||
t.Fatalf("split info = %#v", info.Tracks)
|
||||
}
|
||||
|
||||
jsonText, err := ParseCueFileJSON(cuePath, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCueFileJSON: %v", err)
|
||||
}
|
||||
var decoded CueSplitInfo
|
||||
if err := json.Unmarshal([]byte(jsonText), &decoded); err != nil {
|
||||
t.Fatalf("decode cue json: %v", err)
|
||||
}
|
||||
if decoded.AudioPath != audioPath {
|
||||
t.Fatalf("decoded audio path = %q", decoded.AudioPath)
|
||||
}
|
||||
|
||||
results, err := ScanCueFileForLibraryExt(cuePath, "", "virtual/album.cue", 1234, "scan-time")
|
||||
if err != nil {
|
||||
t.Fatalf("ScanCueFileForLibraryExt: %v", err)
|
||||
}
|
||||
if len(results) != 2 || results[0].TrackName != "First" || results[0].Duration != 180 {
|
||||
t.Fatalf("scan results = %#v", results)
|
||||
}
|
||||
if results[0].FilePath != "virtual/album.cue#track01" || results[0].Format != "cue+wav" {
|
||||
t.Fatalf("scan path/format = %q/%q", results[0].FilePath, results[0].Format)
|
||||
}
|
||||
|
||||
if _, err := ParseCueFile(filepath.Join(dir, "missing.cue")); err == nil {
|
||||
t.Fatal("expected missing cue error")
|
||||
}
|
||||
emptyCue := filepath.Join(dir, "empty.cue")
|
||||
if err := os.WriteFile(emptyCue, []byte("TITLE \"No tracks\""), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ParseCueFile(emptyCue); err == nil {
|
||||
t.Fatal("expected no tracks error")
|
||||
}
|
||||
missingDir := t.TempDir()
|
||||
missingCuePath := filepath.Join(missingDir, "missing.cue")
|
||||
if err := os.WriteFile(missingCuePath, []byte(cue), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := BuildCueSplitInfo(missingCuePath, &CueSheet{FileName: "missing.wav"}, ""); err == nil {
|
||||
t.Fatal("expected missing audio error")
|
||||
}
|
||||
if _, err := resolveCueAudioPathForLibrary(cuePath, nil, ""); err == nil {
|
||||
t.Fatal("expected nil sheet error")
|
||||
}
|
||||
if _, err := scanCueSheetForLibrary(cuePath, nil, audioPath, "", 0, "", ""); err == nil {
|
||||
t.Fatal("expected nil scan sheet error")
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestFlacWithISRC(t *testing.T, path, isrc string) {
|
||||
t.Helper()
|
||||
le32 := func(v int) []byte {
|
||||
return []byte{byte(v), byte(v >> 8), byte(v >> 16), byte(v >> 24)}
|
||||
}
|
||||
vendor := "test-vendor"
|
||||
comments := [][]byte{
|
||||
[]byte("TITLE=Song"),
|
||||
[]byte("ISRC=" + isrc),
|
||||
}
|
||||
var vorbis []byte
|
||||
vorbis = append(vorbis, le32(len(vendor))...)
|
||||
vorbis = append(vorbis, vendor...)
|
||||
vorbis = append(vorbis, le32(len(comments))...)
|
||||
for _, comment := range comments {
|
||||
vorbis = append(vorbis, le32(len(comment))...)
|
||||
vorbis = append(vorbis, comment...)
|
||||
}
|
||||
|
||||
blockHeader := func(last bool, blockType byte, length int) []byte {
|
||||
first := blockType
|
||||
if last {
|
||||
first |= 0x80
|
||||
}
|
||||
return []byte{first, byte(length >> 16), byte(length >> 8), byte(length)}
|
||||
}
|
||||
|
||||
var data []byte
|
||||
data = append(data, "fLaC"...)
|
||||
data = append(data, blockHeader(false, 0, 34)...) // STREAMINFO
|
||||
data = append(data, make([]byte, 34)...)
|
||||
picture := make([]byte, 4096) // stands in for embedded cover art
|
||||
data = append(data, blockHeader(false, 6, len(picture))...)
|
||||
data = append(data, picture...)
|
||||
data = append(data, blockHeader(true, 4, len(vorbis))...)
|
||||
data = append(data, vorbis...)
|
||||
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestISRCIndexIncrementalRebuild(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
trackA := filepath.Join(dir, "a.flac")
|
||||
trackB := filepath.Join(dir, "b.flac")
|
||||
writeTestFlacWithISRC(t, trackA, "USAA00000001")
|
||||
writeTestFlacWithISRC(t, trackB, "USBB00000002")
|
||||
defer InvalidateISRCCache(dir)
|
||||
|
||||
if got := readFlacISRC(trackA); got != "USAA00000001" {
|
||||
t.Fatalf("readFlacISRC = %q", got)
|
||||
}
|
||||
if got := readFlacISRC(filepath.Join(dir, "missing.flac")); got != "" {
|
||||
t.Fatalf("expected empty ISRC for missing file, got %q", got)
|
||||
}
|
||||
|
||||
idx := buildISRCIndex(dir)
|
||||
if path, ok := idx.lookup("usaa00000001"); !ok || path != trackA {
|
||||
t.Fatalf("lookup A = %q/%v", path, ok)
|
||||
}
|
||||
if path, ok := idx.lookup("USBB00000002"); !ok || path != trackB {
|
||||
t.Fatalf("lookup B = %q/%v", path, ok)
|
||||
}
|
||||
|
||||
// Change one file's tag (and mtime); an incremental rebuild must pick up
|
||||
// the change while adopting the untouched file from the cache.
|
||||
writeTestFlacWithISRC(t, trackB, "USBB00000099")
|
||||
future := time.Now().Add(2 * time.Second)
|
||||
if err := os.Chtimes(trackB, future, future); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rebuilt := buildISRCIndex(dir)
|
||||
if _, ok := rebuilt.lookup("USBB00000002"); ok {
|
||||
t.Fatal("expected stale ISRC to disappear after rebuild")
|
||||
}
|
||||
if path, ok := rebuilt.lookup("USBB00000099"); !ok || path != trackB {
|
||||
t.Fatalf("rebuilt lookup = %q/%v", path, ok)
|
||||
}
|
||||
if path, ok := rebuilt.lookup("USAA00000001"); !ok || path != trackA {
|
||||
t.Fatalf("cached entry lost on rebuild: %q/%v", path, ok)
|
||||
}
|
||||
|
||||
// Add() keeps the index fresh so the TTL never forces a rebuild while
|
||||
// downloads are actively maintaining it.
|
||||
rebuilt.buildTime.Store(time.Now().Add(-isrcIndexTTL - time.Minute).UnixNano())
|
||||
if rebuilt.isFresh() {
|
||||
t.Fatal("expected stale index")
|
||||
}
|
||||
rebuilt.Add("USCC00000003", trackA)
|
||||
if !rebuilt.isFresh() {
|
||||
t.Fatal("expected Add to refresh the index timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateIndexAndParallelExistence(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
filePath := filepath.Join(dir, "song.flac")
|
||||
if err := os.WriteFile(filePath, []byte("audio"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
idx := &ISRCIndex{index: map[string]string{}, outputDir: dir}
|
||||
idx.buildTime.Store(time.Now().UnixNano())
|
||||
idx.Add("usrc17607839", filePath)
|
||||
if got, ok := idx.lookup("USRC17607839"); !ok || got != filePath {
|
||||
t.Fatalf("lookup = %q/%v", got, ok)
|
||||
}
|
||||
if got, err := idx.Lookup("usrc17607839"); err != nil || got != filePath {
|
||||
t.Fatalf("Lookup = %q/%v", got, err)
|
||||
}
|
||||
idx.remove("usrc17607839")
|
||||
if _, ok := idx.lookup("usrc17607839"); ok {
|
||||
t.Fatal("expected removed ISRC")
|
||||
}
|
||||
|
||||
isrcIndexCacheMu.Lock()
|
||||
isrcIndexCache[dir] = idx
|
||||
isrcIndexCacheMu.Unlock()
|
||||
defer InvalidateISRCCache(dir)
|
||||
|
||||
AddToISRCIndex(dir, "USRC17607839", filePath)
|
||||
if found, err := CheckISRCExists(dir, "USRC17607839"); err != nil || found != filePath {
|
||||
t.Fatalf("CheckISRCExists = %q/%v", found, err)
|
||||
}
|
||||
if !CheckFileExists(filePath) || CheckFileExists(dir) || CheckFileExists(filepath.Join(dir, "missing.flac")) {
|
||||
t.Fatal("unexpected file existence result")
|
||||
}
|
||||
|
||||
tracksJSON := `[{"isrc":"USRC17607839","track_name":"Song","artist_name":"Artist"},{"isrc":"MISSING","track_name":"Other","artist_name":"Artist"}]`
|
||||
resultJSON, err := CheckFilesExistParallel(dir, tracksJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckFilesExistParallel: %v", err)
|
||||
}
|
||||
var results []FileExistenceResult
|
||||
if err := json.Unmarshal([]byte(resultJSON), &results); err != nil {
|
||||
t.Fatalf("decode results: %v", err)
|
||||
}
|
||||
if !results[0].Exists || results[0].FilePath != filePath || results[1].Exists {
|
||||
t.Fatalf("results = %#v", results)
|
||||
}
|
||||
if _, err := CheckFilesExistParallel(dir, `not-json`); err == nil {
|
||||
t.Fatal("expected invalid json error")
|
||||
}
|
||||
if err := PreBuildISRCIndex(""); err == nil {
|
||||
t.Fatal("expected empty dir error")
|
||||
}
|
||||
}
|
||||
@@ -1,565 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type CueSheet struct {
|
||||
Performer string `json:"performer"`
|
||||
Title string `json:"title"`
|
||||
FileName string `json:"file_name"`
|
||||
FileType string `json:"file_type"` // WAVE, FLAC, MP3, AIFF, etc.
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Date string `json:"date,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
Tracks []CueTrack `json:"tracks"`
|
||||
}
|
||||
|
||||
type CueTrack struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Performer string `json:"performer"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
StartTime float64 `json:"start_time"` // INDEX 01 in seconds
|
||||
PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present)
|
||||
}
|
||||
|
||||
type CueSplitInfo struct {
|
||||
CuePath string `json:"cue_path"`
|
||||
AudioPath string `json:"audio_path"`
|
||||
Album string `json:"album"`
|
||||
Artist string `json:"artist"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Date string `json:"date,omitempty"`
|
||||
Tracks []CueSplitTrack `json:"tracks"`
|
||||
}
|
||||
|
||||
type CueSplitTrack struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
StartSec float64 `json:"start_sec"`
|
||||
EndSec float64 `json:"end_sec"` // -1 means until end of file
|
||||
}
|
||||
|
||||
var (
|
||||
reRemCommand = regexp.MustCompile(`^REM\s+(\S+)\s+(.+)$`)
|
||||
reQuoted = regexp.MustCompile(`"([^"]*)"`)
|
||||
)
|
||||
|
||||
func ParseCueFile(cuePath string) (*CueSheet, error) {
|
||||
f, err := os.Open(cuePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open cue file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
sheet := &CueSheet{}
|
||||
var currentTrack *CueTrack
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "\xef\xbb\xbf") {
|
||||
line = strings.TrimPrefix(line, "\xef\xbb\xbf")
|
||||
line = strings.TrimSpace(line)
|
||||
}
|
||||
|
||||
upper := strings.ToUpper(line)
|
||||
|
||||
if strings.HasPrefix(upper, "REM ") {
|
||||
matches := reRemCommand.FindStringSubmatch(line)
|
||||
if len(matches) == 3 {
|
||||
key := strings.ToUpper(matches[1])
|
||||
value := unquoteCue(matches[2])
|
||||
switch key {
|
||||
case "GENRE":
|
||||
sheet.Genre = value
|
||||
case "DATE":
|
||||
sheet.Date = value
|
||||
case "COMMENT":
|
||||
sheet.Comment = value
|
||||
case "COMPOSER":
|
||||
if currentTrack != nil {
|
||||
currentTrack.Composer = value
|
||||
} else {
|
||||
sheet.Composer = value
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(upper, "PERFORMER ") {
|
||||
value := unquoteCue(line[len("PERFORMER "):])
|
||||
if currentTrack != nil {
|
||||
currentTrack.Performer = value
|
||||
} else {
|
||||
sheet.Performer = value
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(upper, "TITLE ") {
|
||||
value := unquoteCue(line[len("TITLE "):])
|
||||
if currentTrack != nil {
|
||||
currentTrack.Title = value
|
||||
} else {
|
||||
sheet.Title = value
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(upper, "FILE ") {
|
||||
rest := line[len("FILE "):]
|
||||
fname, ftype := parseCueFileLine(rest)
|
||||
sheet.FileName = fname
|
||||
sheet.FileType = ftype
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(upper, "TRACK ") {
|
||||
if currentTrack != nil {
|
||||
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
||||
}
|
||||
|
||||
parts := strings.Fields(line)
|
||||
trackNum := 0
|
||||
if len(parts) >= 2 {
|
||||
trackNum, _ = strconv.Atoi(parts[1])
|
||||
}
|
||||
|
||||
currentTrack = &CueTrack{
|
||||
Number: trackNum,
|
||||
PreGap: -1,
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(upper, "INDEX ") && currentTrack != nil {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 3 {
|
||||
indexNum, _ := strconv.Atoi(parts[1])
|
||||
timeSec := parseCueTimestamp(parts[2])
|
||||
switch indexNum {
|
||||
case 0:
|
||||
currentTrack.PreGap = timeSec
|
||||
case 1:
|
||||
currentTrack.StartTime = timeSec
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(upper, "ISRC ") && currentTrack != nil {
|
||||
currentTrack.ISRC = strings.TrimSpace(line[len("ISRC "):])
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(upper, "SONGWRITER ") {
|
||||
value := unquoteCue(line[len("SONGWRITER "):])
|
||||
if currentTrack != nil {
|
||||
currentTrack.Composer = value
|
||||
} else {
|
||||
sheet.Composer = value
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if currentTrack != nil {
|
||||
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error reading cue file: %w", err)
|
||||
}
|
||||
|
||||
if len(sheet.Tracks) == 0 {
|
||||
return nil, fmt.Errorf("no tracks found in cue file")
|
||||
}
|
||||
|
||||
return sheet, nil
|
||||
}
|
||||
|
||||
func parseCueTimestamp(ts string) float64 {
|
||||
parts := strings.Split(ts, ":")
|
||||
if len(parts) != 3 {
|
||||
return 0
|
||||
}
|
||||
|
||||
minutes, _ := strconv.Atoi(parts[0])
|
||||
seconds, _ := strconv.Atoi(parts[1])
|
||||
frames, _ := strconv.Atoi(parts[2])
|
||||
|
||||
return float64(minutes)*60 + float64(seconds) + float64(frames)/75.0
|
||||
}
|
||||
|
||||
func formatCueTimestamp(seconds float64) string {
|
||||
if seconds < 0 {
|
||||
return "0"
|
||||
}
|
||||
hours := int(seconds) / 3600
|
||||
mins := (int(seconds) % 3600) / 60
|
||||
secs := seconds - float64(hours*3600) - float64(mins*60)
|
||||
return fmt.Sprintf("%02d:%02d:%06.3f", hours, mins, secs)
|
||||
}
|
||||
|
||||
func unquoteCue(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if matches := reQuoted.FindStringSubmatch(s); len(matches) == 2 {
|
||||
return matches[1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func parseCueFileLine(rest string) (string, string) {
|
||||
rest = strings.TrimSpace(rest)
|
||||
|
||||
var filename, ftype string
|
||||
|
||||
if strings.HasPrefix(rest, "\"") {
|
||||
endQuote := strings.Index(rest[1:], "\"")
|
||||
if endQuote >= 0 {
|
||||
filename = rest[1 : endQuote+1]
|
||||
remaining := strings.TrimSpace(rest[endQuote+2:])
|
||||
ftype = remaining
|
||||
} else {
|
||||
filename = rest
|
||||
}
|
||||
} else {
|
||||
parts := strings.Fields(rest)
|
||||
if len(parts) >= 2 {
|
||||
ftype = parts[len(parts)-1]
|
||||
filename = strings.Join(parts[:len(parts)-1], " ")
|
||||
} else if len(parts) == 1 {
|
||||
filename = parts[0]
|
||||
}
|
||||
}
|
||||
|
||||
return filename, strings.TrimSpace(ftype)
|
||||
}
|
||||
|
||||
func ResolveCueAudioPath(cuePath string, cueFileName string) string {
|
||||
cueDir := filepath.Dir(cuePath)
|
||||
|
||||
candidate := filepath.Join(cueDir, cueFileName)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
|
||||
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
|
||||
commonExts := []string{".flac", ".wav", ".aiff", ".aif", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
|
||||
for _, ext := range commonExts {
|
||||
candidate = filepath.Join(cueDir, baseName+ext)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
candidate = filepath.Join(cueDir, baseName+strings.ToUpper(ext))
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
cueBase := strings.TrimSuffix(filepath.Base(cuePath), filepath.Ext(cuePath))
|
||||
for _, ext := range commonExts {
|
||||
candidate = filepath.Join(cueDir, cueBase+ext)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(cueDir)
|
||||
if err == nil {
|
||||
audioExts := map[string]bool{
|
||||
".flac": true, ".wav": true, ".ape": true, ".mp3": true,
|
||||
".ogg": true, ".wv": true, ".m4a": true, ".aiff": true,
|
||||
}
|
||||
var audioFiles []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(entry.Name()))
|
||||
if audioExts[ext] {
|
||||
audioFiles = append(audioFiles, filepath.Join(cueDir, entry.Name()))
|
||||
}
|
||||
}
|
||||
if len(audioFiles) == 1 {
|
||||
return audioFiles[0]
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func BuildCueSplitInfo(cuePath string, sheet *CueSheet, audioDir string) (*CueSplitInfo, error) {
|
||||
resolveDir := cuePath
|
||||
if audioDir != "" {
|
||||
resolveDir = filepath.Join(audioDir, filepath.Base(cuePath))
|
||||
}
|
||||
audioPath := ResolveCueAudioPath(resolveDir, sheet.FileName)
|
||||
if audioPath == "" {
|
||||
return nil, fmt.Errorf("audio file not found for cue sheet: %s (referenced: %s)", cuePath, sheet.FileName)
|
||||
}
|
||||
|
||||
info := &CueSplitInfo{
|
||||
CuePath: cuePath,
|
||||
AudioPath: audioPath,
|
||||
Album: sheet.Title,
|
||||
Artist: sheet.Performer,
|
||||
Genre: sheet.Genre,
|
||||
Date: sheet.Date,
|
||||
}
|
||||
|
||||
for i, track := range sheet.Tracks {
|
||||
performer := track.Performer
|
||||
if performer == "" {
|
||||
performer = sheet.Performer
|
||||
}
|
||||
|
||||
composer := track.Composer
|
||||
if composer == "" {
|
||||
composer = sheet.Composer
|
||||
}
|
||||
|
||||
endSec := float64(-1)
|
||||
if i+1 < len(sheet.Tracks) {
|
||||
nextTrack := sheet.Tracks[i+1]
|
||||
if nextTrack.PreGap >= 0 {
|
||||
endSec = nextTrack.PreGap
|
||||
} else {
|
||||
endSec = nextTrack.StartTime
|
||||
}
|
||||
}
|
||||
|
||||
info.Tracks = append(info.Tracks, CueSplitTrack{
|
||||
Number: track.Number,
|
||||
Title: track.Title,
|
||||
Artist: performer,
|
||||
ISRC: track.ISRC,
|
||||
Composer: composer,
|
||||
StartSec: track.StartTime,
|
||||
EndSec: endSec,
|
||||
})
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func ParseCueFileJSON(cuePath string, audioDir string) (string, error) {
|
||||
sheet, err := ParseCueFile(cuePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse cue file: %w", err)
|
||||
}
|
||||
|
||||
info, err := BuildCueSplitInfo(cuePath, sheet, audioDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(info)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal cue split info: %w", err)
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult, error) {
|
||||
sheet, err := ParseCueFile(cuePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
audioPath, err := resolveCueAudioPathForLibrary(cuePath, sheet, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanCueSheetForLibrary(cuePath, sheet, audioPath, "", 0, "", scanTime)
|
||||
}
|
||||
|
||||
func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
|
||||
return ScanCueFileForLibraryExtWithCoverCacheKey(
|
||||
cuePath,
|
||||
audioDir,
|
||||
virtualPathPrefix,
|
||||
fileModTime,
|
||||
"",
|
||||
scanTime,
|
||||
)
|
||||
}
|
||||
|
||||
func ScanCueFileForLibraryExtWithCoverCacheKey(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, coverCacheKey, scanTime string) ([]LibraryScanResult, error) {
|
||||
sheet, err := ParseCueFile(cuePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
audioPath, err := resolveCueAudioPathForLibrary(cuePath, sheet, audioDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanCueSheetForLibrary(
|
||||
cuePath,
|
||||
sheet,
|
||||
audioPath,
|
||||
virtualPathPrefix,
|
||||
fileModTime,
|
||||
coverCacheKey,
|
||||
scanTime,
|
||||
)
|
||||
}
|
||||
|
||||
func resolveCueAudioPathForLibrary(cuePath string, sheet *CueSheet, audioDir string) (string, error) {
|
||||
if sheet == nil {
|
||||
return "", fmt.Errorf("cue sheet is nil for %s", cuePath)
|
||||
}
|
||||
resolveBase := cuePath
|
||||
if audioDir != "" {
|
||||
resolveBase = filepath.Join(audioDir, filepath.Base(cuePath))
|
||||
}
|
||||
audioPath := ResolveCueAudioPath(resolveBase, sheet.FileName)
|
||||
if audioPath == "" {
|
||||
return "", fmt.Errorf("audio file not found for cue: %s (referenced: %s)", cuePath, sheet.FileName)
|
||||
}
|
||||
return audioPath, nil
|
||||
}
|
||||
|
||||
func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualPathPrefix string, fileModTime int64, coverCacheKey, scanTime string) ([]LibraryScanResult, error) {
|
||||
if sheet == nil {
|
||||
return nil, fmt.Errorf("cue sheet is nil for %s", cuePath)
|
||||
}
|
||||
|
||||
var bitDepth, sampleRate int
|
||||
var totalDurationSec float64
|
||||
audioExt := strings.ToLower(filepath.Ext(audioPath))
|
||||
switch audioExt {
|
||||
case ".flac":
|
||||
quality, qErr := GetAudioQuality(audioPath)
|
||||
if qErr == nil {
|
||||
bitDepth = quality.BitDepth
|
||||
sampleRate = quality.SampleRate
|
||||
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
||||
totalDurationSec = float64(quality.TotalSamples) / float64(quality.SampleRate)
|
||||
}
|
||||
}
|
||||
case ".mp3":
|
||||
quality, qErr := GetMP3Quality(audioPath)
|
||||
if qErr == nil {
|
||||
sampleRate = quality.SampleRate
|
||||
totalDurationSec = float64(quality.Duration)
|
||||
}
|
||||
}
|
||||
|
||||
var coverPath string
|
||||
libraryCoverCacheMu.RLock()
|
||||
coverCacheDir := libraryCoverCacheDir
|
||||
libraryCoverCacheMu.RUnlock()
|
||||
if coverCacheDir != "" {
|
||||
cp, err := SaveCoverToCacheWithHintAndKey(
|
||||
audioPath,
|
||||
"",
|
||||
coverCacheDir,
|
||||
coverCacheKey,
|
||||
)
|
||||
if err == nil && cp != "" {
|
||||
coverPath = cp
|
||||
}
|
||||
}
|
||||
|
||||
pathBase := cuePath
|
||||
if virtualPathPrefix != "" {
|
||||
pathBase = virtualPathPrefix
|
||||
}
|
||||
|
||||
modTime := fileModTime
|
||||
if modTime <= 0 {
|
||||
if info, err := os.Stat(cuePath); err == nil {
|
||||
modTime = info.ModTime().UnixMilli()
|
||||
}
|
||||
}
|
||||
|
||||
var results []LibraryScanResult
|
||||
for i, track := range sheet.Tracks {
|
||||
performer := track.Performer
|
||||
if performer == "" {
|
||||
performer = sheet.Performer
|
||||
}
|
||||
if performer == "" {
|
||||
performer = "Unknown Artist"
|
||||
}
|
||||
|
||||
title := track.Title
|
||||
if title == "" {
|
||||
title = fmt.Sprintf("Track %02d", track.Number)
|
||||
}
|
||||
|
||||
album := sheet.Title
|
||||
if album == "" {
|
||||
album = "Unknown Album"
|
||||
}
|
||||
|
||||
composer := track.Composer
|
||||
if composer == "" {
|
||||
composer = sheet.Composer
|
||||
}
|
||||
|
||||
var duration int
|
||||
if i+1 < len(sheet.Tracks) {
|
||||
nextStart := sheet.Tracks[i+1].StartTime
|
||||
if sheet.Tracks[i+1].PreGap >= 0 {
|
||||
nextStart = sheet.Tracks[i+1].PreGap
|
||||
}
|
||||
duration = int(nextStart - track.StartTime)
|
||||
} else if totalDurationSec > 0 {
|
||||
duration = int(totalDurationSec - track.StartTime)
|
||||
}
|
||||
|
||||
id := generateLibraryID(fmt.Sprintf("%s#track%d", pathBase, track.Number))
|
||||
|
||||
virtualFilePath := fmt.Sprintf("%s#track%02d", pathBase, track.Number)
|
||||
|
||||
result := LibraryScanResult{
|
||||
ID: id,
|
||||
TrackName: title,
|
||||
ArtistName: performer,
|
||||
AlbumName: album,
|
||||
AlbumArtist: sheet.Performer,
|
||||
FilePath: virtualFilePath,
|
||||
CoverPath: coverPath,
|
||||
ScannedAt: scanTime,
|
||||
ISRC: track.ISRC,
|
||||
TrackNumber: track.Number,
|
||||
TotalTracks: len(sheet.Tracks),
|
||||
DiscNumber: 1,
|
||||
TotalDiscs: 1,
|
||||
Duration: duration,
|
||||
ReleaseDate: sheet.Date,
|
||||
BitDepth: bitDepth,
|
||||
SampleRate: sampleRate,
|
||||
Genre: sheet.Genre,
|
||||
Composer: composer,
|
||||
Format: "cue+" + strings.TrimPrefix(audioExt, "."),
|
||||
}
|
||||
|
||||
result.FileModTime = modTime
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDeezerClientWithFakeHTTP(t *testing.T) {
|
||||
client := &DeezerClient{
|
||||
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
body := fakeDeezerResponse(req.URL.Path, req.URL.RawQuery)
|
||||
status := http.StatusOK
|
||||
if body == "" {
|
||||
status = http.StatusNotFound
|
||||
body = `{"error":"missing"}`
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})},
|
||||
searchCache: map[string]*cacheEntry{},
|
||||
albumCache: map[string]*cacheEntry{},
|
||||
artistCache: map[string]*cacheEntry{},
|
||||
isrcCache: map[string]string{},
|
||||
cacheCleanupInterval: time.Millisecond,
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
search, err := client.SearchAll(ctx, "artist song", 2, 2, "")
|
||||
if err != nil {
|
||||
t.Fatalf("SearchAll: %v", err)
|
||||
}
|
||||
if len(search.Tracks) != 1 || len(search.Artists) != 1 || len(search.Albums) != 1 || len(search.Playlists) != 1 {
|
||||
t.Fatalf("search = %#v", search)
|
||||
}
|
||||
cached, err := client.SearchAll(ctx, "artist song", 2, 2, "")
|
||||
if err != nil || cached != search {
|
||||
t.Fatalf("cached SearchAll = %#v/%v", cached, err)
|
||||
}
|
||||
if filtered, err := client.SearchAll(ctx, "artist song", 1, 1, "track"); err != nil || len(filtered.Tracks) != 1 || len(filtered.Artists) != 0 {
|
||||
t.Fatalf("filtered search = %#v/%v", filtered, err)
|
||||
}
|
||||
|
||||
track, err := client.GetTrack(ctx, "101")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTrack: %v", err)
|
||||
}
|
||||
if track.Track.SpotifyID != "deezer:101" || track.Track.Artists != "Contributor A, Contributor B" {
|
||||
t.Fatalf("track = %#v", track)
|
||||
}
|
||||
|
||||
album, err := client.GetAlbum(ctx, "201")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAlbum: %v", err)
|
||||
}
|
||||
if album.AlbumInfo.Name != "Album" || len(album.TrackList) != 2 || album.TrackList[1].ISRC == "" {
|
||||
t.Fatalf("album = %#v", album)
|
||||
}
|
||||
if cachedAlbum, err := client.GetAlbum(ctx, "201"); err != nil || cachedAlbum != album {
|
||||
t.Fatalf("cached album = %#v/%v", cachedAlbum, err)
|
||||
}
|
||||
|
||||
artist, err := client.GetArtist(ctx, "301")
|
||||
if err != nil {
|
||||
t.Fatalf("GetArtist: %v", err)
|
||||
}
|
||||
if artist.ArtistInfo.Name != "Artist" || len(artist.Albums) != 1 || artist.Albums[0].TotalTracks == 0 {
|
||||
t.Fatalf("artist = %#v", artist)
|
||||
}
|
||||
if cachedArtist, err := client.GetArtist(ctx, "301"); err != nil || cachedArtist != artist {
|
||||
t.Fatalf("cached artist = %#v/%v", cachedArtist, err)
|
||||
}
|
||||
|
||||
related, err := client.GetRelatedArtists(ctx, "deezer:301", 3)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRelatedArtists: %v", err)
|
||||
}
|
||||
if len(related) != 1 || related[0].ID != "deezer:302" {
|
||||
t.Fatalf("related = %#v", related)
|
||||
}
|
||||
if _, err := client.GetRelatedArtists(ctx, "", 0); err == nil {
|
||||
t.Fatal("expected invalid related artist ID")
|
||||
}
|
||||
|
||||
playlist, err := client.GetPlaylist(ctx, "401")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPlaylist: %v", err)
|
||||
}
|
||||
if playlist.PlaylistInfo.Tracks.Total != 2 || len(playlist.TrackList) != 2 {
|
||||
t.Fatalf("playlist = %#v", playlist)
|
||||
}
|
||||
|
||||
byISRC, err := client.SearchByISRC(ctx, "USRC17607839")
|
||||
if err != nil {
|
||||
t.Fatalf("SearchByISRC: %v", err)
|
||||
}
|
||||
if byISRC.SpotifyID != "deezer:101" {
|
||||
t.Fatalf("by ISRC = %#v", byISRC)
|
||||
}
|
||||
if _, err := client.SearchByISRC(ctx, "MISSING"); err == nil {
|
||||
t.Fatal("expected missing ISRC error")
|
||||
}
|
||||
|
||||
isrc, err := client.GetTrackISRC(ctx, "102")
|
||||
if err != nil || isrc != "USRC17607840" {
|
||||
t.Fatalf("GetTrackISRC = %q/%v", isrc, err)
|
||||
}
|
||||
albumID, err := client.GetTrackAlbumID(ctx, "101")
|
||||
if err != nil || albumID != "201" {
|
||||
t.Fatalf("GetTrackAlbumID = %q/%v", albumID, err)
|
||||
}
|
||||
extended, err := client.GetAlbumExtendedMetadata(ctx, "201")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAlbumExtendedMetadata: %v", err)
|
||||
}
|
||||
if extended.Genre != "Pop, Dance" || extended.Label != "Label" {
|
||||
t.Fatalf("extended = %#v", extended)
|
||||
}
|
||||
if byTrack, err := client.GetExtendedMetadataByTrackID(ctx, "101"); err != nil || byTrack.Label != "Label" {
|
||||
t.Fatalf("metadata by track = %#v/%v", byTrack, err)
|
||||
}
|
||||
if byISRCMeta, err := client.GetExtendedMetadataByISRC(ctx, "USRC17607839"); err != nil || byISRCMeta.Label != "Label" {
|
||||
t.Fatalf("metadata by isrc = %#v/%v", byISRCMeta, err)
|
||||
}
|
||||
if _, err := client.GetExtendedMetadataByISRC(ctx, ""); err == nil {
|
||||
t.Fatal("expected empty ISRC metadata error")
|
||||
}
|
||||
|
||||
if typ, id, err := parseDeezerURL("https://www.deezer.com/us/track/101"); err != nil || typ != "track" || id != "101" {
|
||||
t.Fatalf("parseDeezerURL = %q/%q/%v", typ, id, err)
|
||||
}
|
||||
if _, _, err := parseDeezerURL("https://example.com/track/101"); err == nil {
|
||||
t.Fatal("expected non-Deezer URL error")
|
||||
}
|
||||
|
||||
client.cacheMu.Lock()
|
||||
client.searchCache["expired"] = &cacheEntry{expiresAt: time.Now().Add(-time.Hour)}
|
||||
client.searchCache["keep1"] = &cacheEntry{expiresAt: time.Now().Add(time.Hour)}
|
||||
client.searchCache["keep2"] = &cacheEntry{expiresAt: time.Now().Add(2 * time.Hour)}
|
||||
client.pruneExpiredCacheEntriesLocked(client.searchCache, time.Now())
|
||||
client.trimCacheEntriesLocked(client.searchCache, 1)
|
||||
client.isrcCache["1"] = "A"
|
||||
client.isrcCache["2"] = "B"
|
||||
client.trimStringCacheEntriesLocked(client.isrcCache, 1)
|
||||
client.cacheMu.Unlock()
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
)
|
||||
|
||||
// DNS-over-HTTPS fallback for DNS-level ISP blocking. The OS resolver stays
|
||||
// the primary path; only when it fails with a DNS error (NXDOMAIN, SERVFAIL,
|
||||
// refused, resolver timeout) is the host re-resolved over DoH to hardcoded
|
||||
// resolver IPs and dialed directly. TLS verification still runs against the
|
||||
// original hostname, so a bad answer cannot silently redirect traffic.
|
||||
|
||||
var dohUpstreams = []string{
|
||||
"https://1.1.1.1/dns-query",
|
||||
"https://8.8.8.8/dns-query",
|
||||
}
|
||||
|
||||
// Upstream URLs are literal IPs, so this client never needs DNS itself.
|
||||
var dohClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
|
||||
MaxIdleConnsPerHost: 1,
|
||||
IdleConnTimeout: 60 * time.Second,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
ForceAttemptHTTP2: true,
|
||||
TLSClientConfig: newTLSCompatibilityConfig(false),
|
||||
},
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
const (
|
||||
dohCacheMaxEntries = 256
|
||||
dohCacheMinTTL = time.Minute
|
||||
dohCacheMaxTTL = 30 * time.Minute
|
||||
dohCacheErrorTTL = 30 * time.Second
|
||||
)
|
||||
|
||||
type dohCacheEntry struct {
|
||||
ips []net.IP
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
dohMu sync.Mutex
|
||||
dohCache = map[string]dohCacheEntry{}
|
||||
)
|
||||
|
||||
// dialWithDoHFallback dials addr normally and, when the failure is a DNS
|
||||
// error, retries with DoH-resolved addresses. Non-DNS failures pass through
|
||||
// untouched.
|
||||
func dialWithDoHFallback(ctx context.Context, dialer *net.Dialer, network, addr string) (net.Conn, error) {
|
||||
conn, err := dialer.DialContext(ctx, network, addr)
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
var dnsErr *net.DNSError
|
||||
if !errors.As(err, &dnsErr) {
|
||||
return nil, err
|
||||
}
|
||||
host, port, splitErr := net.SplitHostPort(addr)
|
||||
if splitErr != nil || net.ParseIP(host) != nil {
|
||||
return nil, err
|
||||
}
|
||||
ips, dohErr := dohResolve(ctx, host)
|
||||
if dohErr != nil {
|
||||
// Surface the OS resolver's error, not the fallback's.
|
||||
return nil, err
|
||||
}
|
||||
GoLog("[DoH] OS resolver failed for %s (%v), dialing DoH answer\n", host, err)
|
||||
lastErr := err
|
||||
for _, ip := range ips {
|
||||
conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
||||
if dialErr == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = dialErr
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// dohResolve resolves host over DoH, IPv4 first. Failures are negative-cached
|
||||
// briefly so a burst of dials does not hammer the resolvers.
|
||||
func dohResolve(ctx context.Context, host string) ([]net.IP, error) {
|
||||
if ips, ok := dohCachedIPs(host); ok {
|
||||
if len(ips) == 0 {
|
||||
return nil, fmt.Errorf("doh: cached failure for %s", host)
|
||||
}
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, upstream := range dohUpstreams {
|
||||
ips, ttl, err := dohQuery(ctx, upstream, host, dnsmessage.TypeA)
|
||||
if err == nil && len(ips) == 0 {
|
||||
ips, ttl, err = dohQuery(ctx, upstream, host, dnsmessage.TypeAAAA)
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
ips = filterDialableIPs(ips)
|
||||
if len(ips) == 0 {
|
||||
break
|
||||
}
|
||||
dohCachePut(host, ips, min(max(ttl, dohCacheMinTTL), dohCacheMaxTTL))
|
||||
return ips, nil
|
||||
}
|
||||
dohCachePut(host, nil, dohCacheErrorTTL)
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("doh: no address for %s", host)
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func dohQuery(ctx context.Context, upstream, host string, qtype dnsmessage.Type) ([]net.IP, time.Duration, error) {
|
||||
name, err := dnsmessage.NewName(host + ".")
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("doh: invalid host %q: %w", host, err)
|
||||
}
|
||||
msg := dnsmessage.Message{
|
||||
Header: dnsmessage.Header{RecursionDesired: true},
|
||||
Questions: []dnsmessage.Question{{
|
||||
Name: name,
|
||||
Type: qtype,
|
||||
Class: dnsmessage.ClassINET,
|
||||
}},
|
||||
}
|
||||
packed, err := msg.Pack()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstream, bytes.NewReader(packed))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/dns-message")
|
||||
req.Header.Set("Accept", "application/dns-message")
|
||||
|
||||
resp, err := dohClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, 0, fmt.Errorf("doh: %s answered HTTP %d", upstream, resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var reply dnsmessage.Message
|
||||
if err := reply.Unpack(body); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if reply.RCode != dnsmessage.RCodeSuccess {
|
||||
return nil, 0, fmt.Errorf("doh: rcode %v for %s", reply.RCode, host)
|
||||
}
|
||||
|
||||
var ips []net.IP
|
||||
ttl := dohCacheMaxTTL
|
||||
for _, ans := range reply.Answers {
|
||||
var ip net.IP
|
||||
switch r := ans.Body.(type) {
|
||||
case *dnsmessage.AResource:
|
||||
ip = net.IP(r.A[:])
|
||||
case *dnsmessage.AAAAResource:
|
||||
ip = net.IP(r.AAAA[:])
|
||||
default:
|
||||
continue
|
||||
}
|
||||
ips = append(ips, ip)
|
||||
if t := time.Duration(ans.Header.TTL) * time.Second; t < ttl {
|
||||
ttl = t
|
||||
}
|
||||
}
|
||||
return ips, ttl, nil
|
||||
}
|
||||
|
||||
// filterDialableIPs drops private/loopback/link-local answers unless the user
|
||||
// opted into private-network access — a DoH answer must not bypass the SSRF
|
||||
// guard the OS-resolver path enforces.
|
||||
func filterDialableIPs(ips []net.IP) []net.IP {
|
||||
if IsPrivateNetworkAllowed() {
|
||||
return ips
|
||||
}
|
||||
kept := ips[:0]
|
||||
for _, ip := range ips {
|
||||
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, ip)
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
func dohCachedIPs(host string) ([]net.IP, bool) {
|
||||
dohMu.Lock()
|
||||
defer dohMu.Unlock()
|
||||
e, ok := dohCache[host]
|
||||
if !ok || time.Now().After(e.expiresAt) {
|
||||
return nil, false
|
||||
}
|
||||
return e.ips, true
|
||||
}
|
||||
|
||||
func dohCachePut(host string, ips []net.IP, ttl time.Duration) {
|
||||
dohMu.Lock()
|
||||
defer dohMu.Unlock()
|
||||
if len(dohCache) >= dohCacheMaxEntries {
|
||||
now := time.Now()
|
||||
for k, e := range dohCache {
|
||||
if now.After(e.expiresAt) {
|
||||
delete(dohCache, k)
|
||||
}
|
||||
}
|
||||
for k := range dohCache {
|
||||
if len(dohCache) < dohCacheMaxEntries {
|
||||
break
|
||||
}
|
||||
delete(dohCache, k)
|
||||
}
|
||||
}
|
||||
dohCache[host] = dohCacheEntry{ips: ips, expiresAt: time.Now().Add(ttl)}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// downloadPathLocks serializes writes per final output path so concurrent
|
||||
// downloads that resolve to the same file cannot interleave bytes into one
|
||||
// output or race the staged-promote rename. Keys are normalized case-folded
|
||||
// cleaned paths; entries live for the process lifetime (bounded by the number
|
||||
// of distinct output files in a session).
|
||||
var downloadPathLocks sync.Map
|
||||
|
||||
// lockDownloadOutputPath locks the given final output path and returns the
|
||||
// unlock function. Different paths keep downloading in parallel; a second
|
||||
// download of the same path blocks until the first finishes.
|
||||
func lockDownloadOutputPath(path string) func() {
|
||||
key := strings.ToLower(filepath.Clean(path))
|
||||
value, _ := downloadPathLocks.LoadOrStore(key, &sync.Mutex{})
|
||||
mu := value.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
return mu.Unlock
|
||||
}
|
||||
|
||||
// stagedDownloadPath returns the sibling name downloads are streamed into
|
||||
// before being promoted to the final path with an atomic rename. The suffix
|
||||
// keeps the staged file invisible to extension-based duplicate checks, which
|
||||
// match on the final audio extension.
|
||||
func stagedDownloadPath(finalPath string) string {
|
||||
return finalPath + ".partial"
|
||||
}
|
||||