Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e530ffbc3 | ||
|
|
14f6776fdc | ||
|
|
da1c6e9171 | ||
|
|
9c3e934395 | ||
|
|
15d2c3b465 | ||
|
|
8aaa6d5cbe | ||
|
|
9158d0228d | ||
|
|
2bbcda3320 | ||
|
|
a7622676dd | ||
|
|
5779f910a2 | ||
|
|
030f44a444 | ||
|
|
1248270fb4 | ||
|
|
413e3b0686 | ||
|
|
ac711efadc | ||
|
|
59f2fe880a | ||
|
|
355f2eba2a | ||
|
|
f2f45fa31d | ||
|
|
042937a8ed | ||
|
|
674e9af3d0 | ||
|
|
76d50fab3a | ||
|
|
81e25d7dab | ||
|
|
26f26f792a | ||
|
|
4dfa76b49e | ||
|
|
f511f30ad0 | ||
|
|
a1aa1319ce | ||
|
|
c936bd7dd0 | ||
|
|
3a60ea2f4e | ||
|
|
7dba938299 | ||
|
|
93e77aeb84 | ||
|
|
dd750b95ca | ||
|
|
e42e44f28b | ||
|
|
67daefdf60 | ||
|
|
fabaf0a3ff | ||
|
|
fb90c73f42 | ||
|
|
c6cf65f075 | ||
|
|
25de009ebc | ||
|
|
8918d74bb5 | ||
|
|
f9de8d45d9 | ||
|
|
48eef0853d | ||
|
|
fc70a912bf | ||
|
|
cd3e5b4b28 | ||
|
|
482ca82eb4 | ||
|
|
6d87ae5484 | ||
|
|
bd3e2b999b | ||
|
|
186196e12b |
@@ -1,24 +0,0 @@
|
|||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
charset = utf-8
|
|
||||||
end_of_line = lf
|
|
||||||
insert_final_newline = true
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
|
|
||||||
[*.{dart,json,yaml,yml}]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
|
|
||||||
[*.{kt,kts,swift}]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 4
|
|
||||||
|
|
||||||
[*.go]
|
|
||||||
indent_style = tab
|
|
||||||
|
|
||||||
[*.md]
|
|
||||||
trim_trailing_whitespace = false
|
|
||||||
|
|
||||||
[*.{bat,cmd,ps1}]
|
|
||||||
end_of_line = crlf
|
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
* text=auto eol=lf
|
* text=auto eol=lf
|
||||||
|
|
||||||
# Scripts executed on Unix runners must keep LF line endings.
|
|
||||||
*.sh text eol=lf
|
|
||||||
gradlew text eol=lf
|
|
||||||
|
|
||||||
# Windows scripts
|
# Windows scripts
|
||||||
*.bat text eol=crlf
|
*.bat text eol=crlf
|
||||||
*.cmd text eol=crlf
|
*.cmd text eol=crlf
|
||||||
|
|||||||
@@ -1,29 +1,30 @@
|
|||||||
name: Bug Report
|
name: Bug Report
|
||||||
description: Report a reproducible bug or unexpected behavior
|
description: Report a bug or unexpected behavior
|
||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels: ["bug"]
|
labels: ["bug"]
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Thanks for reporting a SpotiFLAC Mobile bug. Please provide enough detail for another person to reproduce it.
|
Thanks for taking the time to report a bug! Please fill out the form below.
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: checklist
|
id: checklist
|
||||||
attributes:
|
attributes:
|
||||||
label: Checklist
|
label: Checklist
|
||||||
|
description: Please confirm the following before submitting
|
||||||
options:
|
options:
|
||||||
- label: I searched existing issues and did not find this bug.
|
- label: I have searched existing issues and this bug hasn't been reported yet
|
||||||
required: true
|
required: true
|
||||||
- label: I can reproduce this on the release channel selected below.
|
- label: I am using the latest version of SpotiFLAC (Stable Version)
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: description
|
id: description
|
||||||
attributes:
|
attributes:
|
||||||
label: Description
|
label: Bug Description
|
||||||
description: What happened, and what did you expect instead?
|
description: A clear and concise description of what the bug is
|
||||||
placeholder: Include the visible error and any relevant context.
|
placeholder: Describe the bug...
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
@@ -31,26 +32,29 @@ body:
|
|||||||
id: steps
|
id: steps
|
||||||
attributes:
|
attributes:
|
||||||
label: Steps to Reproduce
|
label: Steps to Reproduce
|
||||||
|
description: Steps to reproduce the behavior
|
||||||
placeholder: |
|
placeholder: |
|
||||||
1. Open ...
|
1. Go to '...'
|
||||||
2. Enable ...
|
2. Click on '...'
|
||||||
3. Tap ...
|
3. See error
|
||||||
4. Observe ...
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: dropdown
|
- type: textarea
|
||||||
id: trigger
|
id: expected
|
||||||
attributes:
|
attributes:
|
||||||
label: Area
|
label: Expected Behavior
|
||||||
options:
|
description: What did you expect to happen?
|
||||||
- App startup
|
placeholder: Describe what you expected...
|
||||||
- Search or navigation
|
validations:
|
||||||
- Download or verification
|
required: true
|
||||||
- Library or metadata
|
|
||||||
- Playback or background service
|
- type: textarea
|
||||||
- Audio analysis
|
id: actual
|
||||||
- Other
|
attributes:
|
||||||
|
label: Actual Behavior
|
||||||
|
description: What actually happened?
|
||||||
|
placeholder: Describe what actually happened...
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
@@ -58,73 +62,62 @@ body:
|
|||||||
id: version
|
id: version
|
||||||
attributes:
|
attributes:
|
||||||
label: App Version
|
label: App Version
|
||||||
description: Find this in Settings > About.
|
description: Which version of SpotiFLAC are you using? (Check in Settings > About)
|
||||||
placeholder: "e.g. 4.8.1 (139)"
|
placeholder: "e.g., v2.2.0"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: channel
|
id: platform
|
||||||
attributes:
|
attributes:
|
||||||
label: Release Channel
|
label: Platform
|
||||||
|
description: Which platform are you using?
|
||||||
options:
|
options:
|
||||||
- Stable
|
- Android
|
||||||
- Beta
|
- iOS
|
||||||
- Local or development build
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: device
|
id: device
|
||||||
attributes:
|
attributes:
|
||||||
label: Device and OS
|
label: Device & OS Version
|
||||||
placeholder: "e.g. Samsung Galaxy S24, Android 16"
|
description: What device and OS version are you using?
|
||||||
|
placeholder: "e.g., Samsung Galaxy S24, Android 14"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: storage
|
id: download-service
|
||||||
attributes:
|
attributes:
|
||||||
label: Download Storage
|
label: Download Service
|
||||||
|
description: Which download service were you using when the bug occurred?
|
||||||
options:
|
options:
|
||||||
- SAF folder
|
- Tidal
|
||||||
- App-specific folder
|
- Qobuz
|
||||||
|
- Amazon Music
|
||||||
|
- Deezer (search only)
|
||||||
- Not applicable
|
- Not applicable
|
||||||
- Not sure
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: dropdown
|
|
||||||
id: worker
|
|
||||||
attributes:
|
|
||||||
label: Download Worker
|
|
||||||
options:
|
|
||||||
- Native worker
|
|
||||||
- Dart worker
|
|
||||||
- Not applicable
|
|
||||||
- Not sure
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
id: provider
|
|
||||||
attributes:
|
|
||||||
label: Extension or Provider
|
|
||||||
description: If relevant, include the extension/provider name and version.
|
|
||||||
placeholder: "Name, version, and selected priority"
|
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: logs
|
id: logs
|
||||||
attributes:
|
attributes:
|
||||||
label: Logs and Media
|
label: Logs / Screenshots
|
||||||
description: |
|
description: |
|
||||||
Enable detailed logging, reproduce the problem, then export logs from Settings > Logs. For an Android cold-start crash, attach `adb logcat -b crash -d`. Add screenshots or a short recording when useful. Remove credentials and personal data first.
|
If applicable, add logs or screenshots to help explain your problem.
|
||||||
placeholder: Paste logs or drag files here.
|
|
||||||
validations:
|
**To get logs:**
|
||||||
required: true
|
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
|
- type: textarea
|
||||||
id: additional
|
id: additional
|
||||||
attributes:
|
attributes:
|
||||||
label: Additional Context
|
label: Additional Context
|
||||||
placeholder: Settings, frequency, regression version, or anything else that may help.
|
description: Any other context about the problem
|
||||||
|
placeholder: Add any other context...
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: README
|
- name: README
|
||||||
url: https://github.com/spotiflacapp/SpotiFLAC-Mobile#readme
|
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
|
||||||
about: Check the README for setup instructions and FAQ
|
about: Check the README for setup instructions and FAQ
|
||||||
- name: Extension Development Guide
|
- name: Extension Development Guide
|
||||||
url: https://spotiflac.zarz.moe/docs
|
url: https://spotiflac.zarz.moe/docs
|
||||||
about: Documentation for building SpotiFLAC Mobile extensions
|
about: Documentation for building SpotiFLAC extensions
|
||||||
|
|||||||
@@ -1,124 +1,111 @@
|
|||||||
name: Download Issue
|
name: Download Issue
|
||||||
description: Report a wrong, missing, failed, or low-quality download
|
description: Report issues with downloading specific tracks or albums
|
||||||
title: "[Download]: "
|
title: "[Download]: "
|
||||||
labels: ["download-issue"]
|
labels: ["download-issue"]
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Download matching is extension-driven. Include the source item, provider order, and logs so the exact decision path can be inspected.
|
Having trouble downloading a specific track or album? Please provide details below.
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: checklist
|
id: checklist
|
||||||
attributes:
|
attributes:
|
||||||
label: Checklist
|
label: Checklist
|
||||||
|
description: Please confirm the following before submitting
|
||||||
options:
|
options:
|
||||||
- label: I searched existing issues and did not find this problem.
|
- label: I have tried downloading with a different service (Tidal/Qobuz/Amazon)
|
||||||
required: true
|
required: true
|
||||||
- label: I updated the affected extension and retried once.
|
- label: I am using the latest version of SpotiFLAC (Stable Version)
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: issue-type
|
id: issue-type
|
||||||
attributes:
|
attributes:
|
||||||
label: Issue Type
|
label: Issue Type
|
||||||
|
description: What kind of download issue are you experiencing?
|
||||||
options:
|
options:
|
||||||
- Track or album not found
|
- Track not found on service
|
||||||
- Wrong track downloaded
|
- Wrong track downloaded
|
||||||
- Download fails or stalls
|
- Download fails/errors
|
||||||
- Metadata or cover is incorrect
|
- Metadata incorrect
|
||||||
- Audio format or quality is incorrect
|
- Audio quality issue
|
||||||
- Duplicate or replacement behavior
|
|
||||||
- Other
|
- Other
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: source-url
|
id: spotify-url
|
||||||
attributes:
|
attributes:
|
||||||
label: Source URL
|
label: Spotify URL
|
||||||
description: URL pasted or opened in SpotiFLAC Mobile, if available.
|
description: The Spotify URL of the track/album you're trying to download
|
||||||
placeholder: "https://..."
|
placeholder: "https://open.spotify.com/track/..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: track-info
|
id: track-info
|
||||||
attributes:
|
attributes:
|
||||||
label: Track or Album
|
label: Track Info
|
||||||
placeholder: "Artist - Track or album title"
|
description: Artist name and track title
|
||||||
validations:
|
placeholder: "Artist - Track Title"
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
id: metadata-source
|
|
||||||
attributes:
|
|
||||||
label: Search or Metadata Extension
|
|
||||||
placeholder: "Extension name and version"
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
id: download-provider
|
|
||||||
attributes:
|
|
||||||
label: Selected Download Extension
|
|
||||||
placeholder: "Extension name and version"
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: priority
|
|
||||||
attributes:
|
|
||||||
label: Provider Priority and Requested Quality
|
|
||||||
placeholder: |
|
|
||||||
Provider order: first, second, third
|
|
||||||
Requested quality: best / 24-bit / ...
|
|
||||||
Automatic fallback: on/off
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: storage
|
id: download-service
|
||||||
attributes:
|
attributes:
|
||||||
label: Download Storage
|
label: Download Service
|
||||||
|
description: Which service did you try to download from?
|
||||||
options:
|
options:
|
||||||
- SAF folder
|
- Tidal
|
||||||
- App-specific folder
|
- Qobuz
|
||||||
- Not sure
|
- Amazon Music
|
||||||
|
- All services
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: worker
|
id: search-service
|
||||||
attributes:
|
attributes:
|
||||||
label: Download Worker
|
label: Search Service
|
||||||
|
description: Which search service are you using?
|
||||||
options:
|
options:
|
||||||
- Native worker
|
- Spotify
|
||||||
- Dart worker
|
- Deezer
|
||||||
- Not sure
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: description
|
id: description
|
||||||
attributes:
|
attributes:
|
||||||
label: What Happened?
|
label: Description
|
||||||
description: For a wrong match, identify both the expected and downloaded recording.
|
description: Describe the issue in detail
|
||||||
placeholder: Describe the result and the expected result.
|
placeholder: |
|
||||||
|
What happened? What did you expect?
|
||||||
|
If wrong track was downloaded, what track was downloaded instead?
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: environment
|
id: version
|
||||||
attributes:
|
attributes:
|
||||||
label: App Version, Device, and OS
|
label: App Version
|
||||||
placeholder: "4.8.1 (139), device model, Android/iOS version"
|
description: Which version of SpotiFLAC are you using?
|
||||||
|
placeholder: "e.g., v2.2.0"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: logs
|
id: screenshots
|
||||||
attributes:
|
attributes:
|
||||||
label: Logs and Screenshots
|
label: Screenshots / Logs
|
||||||
description: Enable detailed logging, retry once, and export Settings > Logs. Remove credentials and personal data.
|
description: |
|
||||||
placeholder: Paste logs or drag files here.
|
If applicable, add screenshots or logs.
|
||||||
validations:
|
|
||||||
required: true
|
**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...
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ body:
|
|||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Thanks for helping improve the SpotiFLAC Mobile Extension API!
|
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.
|
This form is for extension developers who need new features or capabilities that don't exist yet.
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
@@ -15,7 +15,7 @@ body:
|
|||||||
label: Checklist
|
label: Checklist
|
||||||
description: Please confirm the following before submitting
|
description: Please confirm the following before submitting
|
||||||
options:
|
options:
|
||||||
- label: I have read the [Extension Development Guide](https://github.com/spotiflacapp/SpotiFLAC-Mobile/blob/main/docs/EXTENSION_DEVELOPMENT.md)
|
- label: I have read the [Extension Development Guide](https://github.com/zarzet/SpotiFLAC-Mobile/blob/main/docs/EXTENSION_DEVELOPMENT.md)
|
||||||
required: true
|
required: true
|
||||||
- label: I have searched existing issues and this API feature hasn't been requested yet
|
- label: I have searched existing issues and this API feature hasn't been requested yet
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ body:
|
|||||||
options:
|
options:
|
||||||
- UI/UX Improvement
|
- UI/UX Improvement
|
||||||
- Download Feature
|
- Download Feature
|
||||||
- Extension System/API
|
- New Service Integration
|
||||||
- Metadata/Tagging
|
- Metadata/Tagging
|
||||||
- Performance
|
- Performance
|
||||||
- Settings/Configuration
|
- Settings/Configuration
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
## Summary
|
|
||||||
|
|
||||||
<!-- What changed, why is it needed, and which user-visible behavior is affected? -->
|
|
||||||
|
|
||||||
## Related Issues
|
|
||||||
|
|
||||||
<!-- Use "Fixes #123" when this PR should close an issue. -->
|
|
||||||
|
|
||||||
## Type of Change
|
|
||||||
|
|
||||||
- [ ] Bug fix
|
|
||||||
- [ ] Feature
|
|
||||||
- [ ] Performance improvement
|
|
||||||
- [ ] Refactor with no intended behavior change
|
|
||||||
- [ ] Tests, build, or repository maintenance
|
|
||||||
- [ ] Documentation or localization
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
<!-- Check only commands relevant to this change. Explain skipped checks below. -->
|
|
||||||
|
|
||||||
- [ ] `dart format --output=none --set-exit-if-changed lib test`
|
|
||||||
- [ ] `flutter analyze`
|
|
||||||
- [ ] `flutter test`
|
|
||||||
- [ ] `gofmt`, `go vet ./...`, and `go test ./...` in `go_backend/`
|
|
||||||
- [ ] Android or iOS native checks
|
|
||||||
- [ ] Manual reproduction or device test
|
|
||||||
|
|
||||||
Validation notes:
|
|
||||||
|
|
||||||
<!-- Devices, commands, test cases, or the reason a check was not applicable. -->
|
|
||||||
|
|
||||||
## Contributor Checklist
|
|
||||||
|
|
||||||
- [ ] This PR is focused and contains no unrelated changes.
|
|
||||||
- [ ] Extension-specific behavior, if any, uses a generic capability or manifest contract.
|
|
||||||
- [ ] User-facing strings, documentation, and generated files are updated where needed.
|
|
||||||
- [ ] No credentials, downloaded media, or build artifacts are included.
|
|
||||||
|
|
||||||
## Screenshots or Recordings
|
|
||||||
|
|
||||||
<!-- Add before/after media for UI changes. Delete this section otherwise. -->
|
|
||||||
@@ -1,184 +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'
|
|
||||||
- '.fvmrc'
|
|
||||||
- '.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'
|
|
||||||
- '.fvmrc'
|
|
||||||
- '.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"
|
|
||||||
flutter-version-file: .fvmrc
|
|
||||||
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:
|
|
||||||
go-version-file: go_backend/go.mod
|
|
||||||
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-file: go_backend/go.mod
|
|
||||||
cache-dependency-path: go_backend/go.sum
|
|
||||||
|
|
||||||
- name: Setup Flutter
|
|
||||||
uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
channel: "stable"
|
|
||||||
flutter-version-file: .fvmrc
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Setup Gradle
|
|
||||||
uses: gradle/actions/setup-gradle@v5
|
|
||||||
with:
|
|
||||||
gradle-version: "9.6.1"
|
|
||||||
|
|
||||||
- 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.0" \
|
|
||||||
"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: gradle -p android :app:compileDebugKotlin :app:testDebugUnitTest
|
|
||||||
@@ -47,14 +47,15 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Free disk space
|
- name: Free disk space
|
||||||
run: |
|
run: |
|
||||||
# Remove large unused tools (~15GB total). Docker prune was dropped:
|
# Remove large unused tools (~15GB total)
|
||||||
# it took 1-2 minutes and the rm below already frees enough.
|
|
||||||
sudo rm -rf /usr/share/dotnet
|
sudo rm -rf /usr/share/dotnet
|
||||||
sudo rm -rf /opt/ghc
|
sudo rm -rf /opt/ghc
|
||||||
sudo rm -rf /opt/hostedtoolcache/CodeQL
|
sudo rm -rf /opt/hostedtoolcache/CodeQL
|
||||||
sudo rm -rf /usr/local/share/boost
|
sudo rm -rf /usr/local/share/boost
|
||||||
sudo rm -rf /usr/share/swift
|
sudo rm -rf /usr/share/swift
|
||||||
sudo rm -rf /usr/local/.ghcup
|
sudo rm -rf /usr/local/.ghcup
|
||||||
|
# Clean docker images
|
||||||
|
sudo docker image prune --all --force
|
||||||
# Show available space
|
# Show available space
|
||||||
df -h
|
df -h
|
||||||
|
|
||||||
@@ -65,12 +66,12 @@ jobs:
|
|||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: "temurin"
|
distribution: "temurin"
|
||||||
java-version: "25"
|
java-version: "17"
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: go_backend/go.mod
|
go-version: "1.25.8"
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
# Cache Gradle for faster builds
|
# Cache Gradle for faster builds
|
||||||
@@ -83,12 +84,6 @@ jobs:
|
|||||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||||
restore-keys: gradle-${{ runner.os }}-
|
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
|
- name: Install Android SDK & NDK
|
||||||
run: |
|
run: |
|
||||||
# Use pre-installed Android SDK on GitHub runners
|
# Use pre-installed Android SDK on GitHub runners
|
||||||
@@ -99,29 +94,22 @@ jobs:
|
|||||||
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
||||||
|
|
||||||
# Install NDK r29 (supports 16KB page size for Android 15+)
|
# Install NDK r29 (supports 16KB page size for Android 15+)
|
||||||
# Keep the installed platform aligned with compileSdk/targetSdk.
|
# Platform android-36 and build-tools 36.0.0 for targetSdk 36 (Android 16)
|
||||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;29.0.14206865" "platforms;android-37.0" "build-tools;37.0.0"
|
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;29.0.14206865" "platforms;android-36" "build-tools;36.0.0"
|
||||||
|
|
||||||
# Set NDK path
|
# Set NDK path
|
||||||
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> $GITHUB_ENV
|
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Install gomobile
|
- name: Install gomobile
|
||||||
working-directory: go_backend
|
|
||||||
run: |
|
run: |
|
||||||
# Installed from inside the module so the go.mod-pinned x/mobile
|
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||||
# version is used (reproducible + covered by the setup-go cache).
|
|
||||||
go install golang.org/x/mobile/cmd/gomobile
|
|
||||||
gomobile init
|
gomobile init
|
||||||
|
|
||||||
- name: Build Go backend for Android
|
- name: Build Go backend for Android
|
||||||
working-directory: go_backend
|
working-directory: go_backend
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ../android/app/libs
|
mkdir -p ../android/app/libs
|
||||||
# arm/arm64 only: ndk.abiFilters in app/build.gradle.kts strips
|
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
|
||||||
# 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 .
|
|
||||||
env:
|
env:
|
||||||
CGO_ENABLED: 1
|
CGO_ENABLED: 1
|
||||||
|
|
||||||
@@ -129,16 +117,8 @@ jobs:
|
|||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
flutter-version-file: .fvmrc
|
|
||||||
cache: true
|
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
|
- name: Get Flutter dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|
||||||
@@ -147,11 +127,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Build APK (Release - unsigned)
|
- name: Build APK (Release - unsigned)
|
||||||
run: |
|
run: |
|
||||||
flutter build apk --release --split-per-abi \
|
flutter build apk --release --split-per-abi || true
|
||||||
--target-platform android-arm,android-arm64
|
# Verify APKs were created
|
||||||
ls -la build/app/outputs/flutter-apk/
|
ls -la build/app/outputs/flutter-apk/
|
||||||
test -f build/app/outputs/flutter-apk/app-arm64-v8a-release.apk
|
if [ ! -f "build/app/outputs/flutter-apk/app-arm64-v8a-release.apk" ]; then
|
||||||
test -f build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk
|
echo "ERROR: APK not found!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Sign APKs
|
- name: Sign APKs
|
||||||
uses: r0adkll/sign-android-release@v1
|
uses: r0adkll/sign-android-release@v1
|
||||||
@@ -167,29 +149,12 @@ jobs:
|
|||||||
|
|
||||||
- name: Rename APKs
|
- name: Rename APKs
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ needs.get-version.outputs.version }}"
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
cd build/app/outputs/flutter-apk
|
cd build/app/outputs/flutter-apk
|
||||||
|
# Signed files have -signed suffix
|
||||||
rename_required_apk() {
|
mv app-arm64-v8a-release-signed.apk SpotiFLAC-${VERSION}-arm64.apk || mv app-arm64-v8a-release.apk SpotiFLAC-${VERSION}-arm64.apk || true
|
||||||
unsigned="$1"
|
mv app-armeabi-v7a-release-signed.apk SpotiFLAC-${VERSION}-arm32.apk || mv app-armeabi-v7a-release.apk SpotiFLAC-${VERSION}-arm32.apk || true
|
||||||
destination="$2"
|
mv app-release-signed.apk SpotiFLAC-${VERSION}-universal.apk || mv app-release.apk SpotiFLAC-${VERSION}-universal.apk || true
|
||||||
signed="${unsigned%.apk}-signed.apk"
|
|
||||||
if [ -f "$signed" ]; then
|
|
||||||
mv "$signed" "$destination"
|
|
||||||
elif [ -f "$unsigned" ]; then
|
|
||||||
mv "$unsigned" "$destination"
|
|
||||||
else
|
|
||||||
echo "ERROR: Missing APK: $unsigned"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
rename_required_apk \
|
|
||||||
app-arm64-v8a-release.apk \
|
|
||||||
"SpotiFLAC-${VERSION}-arm64.apk"
|
|
||||||
rename_required_apk \
|
|
||||||
app-armeabi-v7a-release.apk \
|
|
||||||
"SpotiFLAC-${VERSION}-arm32.apk"
|
|
||||||
ls -la
|
ls -la
|
||||||
|
|
||||||
- name: Upload APK artifact
|
- name: Upload APK artifact
|
||||||
@@ -214,7 +179,7 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: go_backend/go.mod
|
go-version: "1.25.8"
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
# Cache CocoaPods
|
# Cache CocoaPods
|
||||||
@@ -226,10 +191,8 @@ jobs:
|
|||||||
restore-keys: pods-${{ runner.os }}-
|
restore-keys: pods-${{ runner.os }}-
|
||||||
|
|
||||||
- name: Install gomobile
|
- name: Install gomobile
|
||||||
working-directory: go_backend
|
|
||||||
run: |
|
run: |
|
||||||
# go.mod-pinned x/mobile version (reproducible + cached by setup-go).
|
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||||
go install golang.org/x/mobile/cmd/gomobile
|
|
||||||
gomobile init
|
gomobile init
|
||||||
|
|
||||||
- name: Build Go backend for iOS
|
- name: Build Go backend for iOS
|
||||||
@@ -247,9 +210,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Add XCFramework to Xcode project
|
- name: Add XCFramework to Xcode project
|
||||||
run: |
|
run: |
|
||||||
# xcodeproj usually ships with the preinstalled CocoaPods; only
|
# Install xcodeproj gem for modifying Xcode project
|
||||||
# install it when missing.
|
sudo gem install xcodeproj
|
||||||
gem list -i xcodeproj >/dev/null || sudo gem install xcodeproj
|
|
||||||
|
|
||||||
# Create Ruby script to add framework
|
# Create Ruby script to add framework
|
||||||
cat > add_framework.rb << 'EOF'
|
cat > add_framework.rb << 'EOF'
|
||||||
@@ -290,28 +252,11 @@ jobs:
|
|||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
flutter-version-file: .fvmrc
|
|
||||||
cache: true
|
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
|
- name: Get Flutter dependencies
|
||||||
run: flutter pub get
|
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
|
- name: Generate app icons
|
||||||
run: dart run flutter_launcher_icons
|
run: dart run flutter_launcher_icons
|
||||||
|
|
||||||
@@ -434,6 +379,8 @@ jobs:
|
|||||||
### Installation
|
### Installation
|
||||||
**Android**: Enable "Install from unknown sources" and install the APK
|
**Android**: Enable "Install from unknown sources" and install the APK
|
||||||
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
||||||
|
|
||||||
|
  
|
||||||
FOOTER
|
FOOTER
|
||||||
|
|
||||||
echo "Release body:"
|
echo "Release body:"
|
||||||
@@ -443,7 +390,7 @@ jobs:
|
|||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ needs.get-version.outputs.version }}
|
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
|
body_path: /tmp/release_body.txt
|
||||||
files: ./release/*
|
files: ./release/*
|
||||||
draft: false
|
draft: false
|
||||||
@@ -491,7 +438,7 @@ jobs:
|
|||||||
|
|
||||||
jq --arg ver "$VERSION_NUM" \
|
jq --arg ver "$VERSION_NUM" \
|
||||||
--arg date "$DATE" \
|
--arg date "$DATE" \
|
||||||
--arg url "https://github.com/${{ github.repository }}/releases/download/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa" \
|
--arg url "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa" \
|
||||||
--argjson size "$IPA_SIZE" \
|
--argjson size "$IPA_SIZE" \
|
||||||
'.apps[0].version = $ver | .apps[0].versionDate = $date | .apps[0].downloadURL = $url | .apps[0].size = $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
|
apps.json > apps.json.tmp && mv apps.json.tmp apps.json
|
||||||
@@ -609,7 +556,7 @@ jobs:
|
|||||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||||
-F document=@"${ARM64_APK}" \
|
-F document=@"${ARM64_APK}" \
|
||||||
-F caption="SpotiFLAC Mobile ${VERSION} - arm64 (recommended)"
|
-F caption="SpotiFLAC ${VERSION} - arm64 (recommended)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Upload arm32 APK to channel
|
# Upload arm32 APK to channel
|
||||||
@@ -618,7 +565,7 @@ jobs:
|
|||||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||||
-F document=@"${ARM32_APK}" \
|
-F document=@"${ARM32_APK}" \
|
||||||
-F caption="SpotiFLAC Mobile ${VERSION} - arm32"
|
-F caption="SpotiFLAC ${VERSION} - arm32"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Upload iOS IPA to channel
|
# Upload iOS IPA to channel
|
||||||
@@ -628,7 +575,7 @@ jobs:
|
|||||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||||
-F document=@"${IOS_IPA}" \
|
-F document=@"${IOS_IPA}" \
|
||||||
-F caption="SpotiFLAC Mobile ${VERSION} - iOS (unsigned, sideload required)"
|
-F caption="SpotiFLAC ${VERSION} - iOS (unsigned, sideload required)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Telegram notification sent!"
|
echo "Telegram notification sent!"
|
||||||
|
|||||||
@@ -1,98 +1,83 @@
|
|||||||
# Operating-system files
|
# OS files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
Desktop.ini
|
|
||||||
|
|
||||||
# Editors and local assistants
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.iml
|
*.iml
|
||||||
.cursorignore
|
.cursorignore
|
||||||
.cursorrules
|
.cursorrules
|
||||||
.kiro/
|
|
||||||
.playwright-mcp/
|
|
||||||
.rtk/
|
|
||||||
.claude/settings.local.json
|
|
||||||
CLAUDE.md
|
|
||||||
AGENTS.md
|
|
||||||
|
|
||||||
# Flutter and Dart
|
# 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/
|
||||||
|
|
||||||
|
# Flutter/Dart
|
||||||
.dart_tool/
|
.dart_tool/
|
||||||
.fvm/
|
|
||||||
.packages
|
.packages
|
||||||
|
build/
|
||||||
|
*.lock
|
||||||
|
!pubspec.lock
|
||||||
.flutter-plugins
|
.flutter-plugins
|
||||||
.flutter-plugins-dependencies
|
.flutter-plugins-dependencies
|
||||||
.metadata
|
.metadata
|
||||||
/build/
|
|
||||||
/coverage/
|
|
||||||
*.apk
|
*.apk
|
||||||
*.aab
|
|
||||||
*.ipa
|
|
||||||
|
|
||||||
# Local JavaScript tooling (the app itself has no Node dependency)
|
# Go backend build artifacts
|
||||||
/node_modules/
|
go_backend/*.aar
|
||||||
/bun.lock
|
go_backend/*.jar
|
||||||
/package-lock.json
|
go_backend/*.exe
|
||||||
|
go_backend/*.xcframework/
|
||||||
|
|
||||||
# Go backend build outputs
|
# Android
|
||||||
/go_backend/*.aar
|
android/.gradle/
|
||||||
/go_backend/*.jar
|
android/app/libs/gobackend.aar
|
||||||
/go_backend/*.exe
|
android/local.properties
|
||||||
/go_backend/*.xcframework/
|
android/*.iml
|
||||||
|
android/key.properties
|
||||||
|
android/*.jks
|
||||||
|
android/*.keystore
|
||||||
|
android/app/*.jks
|
||||||
|
|
||||||
# Android build state and signing material
|
# iOS
|
||||||
/android/.gradle/
|
ios/Frameworks/
|
||||||
/android/.kotlin/
|
ios/Pods/
|
||||||
/android/app/build/
|
ios/.symlinks/
|
||||||
/android/app/libs/*.aar
|
ios/Flutter/Flutter.framework/
|
||||||
/android/app/libs/*-sources.jar
|
ios/Flutter/Flutter.podspec
|
||||||
/android/local.properties
|
android/app/libs/gobackend-sources.jar
|
||||||
/android/*.iml
|
|
||||||
/android/key.properties
|
|
||||||
/android/*.jks
|
|
||||||
/android/*.keystore
|
|
||||||
/android/app/*.jks
|
|
||||||
|
|
||||||
# iOS generated state
|
# Extension folder
|
||||||
/ios/Frameworks/
|
extension/
|
||||||
/ios/Pods/
|
|
||||||
/ios/.symlinks/
|
|
||||||
/ios/Flutter/ephemeral/
|
|
||||||
/ios/Flutter/Flutter.framework/
|
|
||||||
/ios/Flutter/Flutter.podspec
|
|
||||||
*.xcarchive
|
|
||||||
DerivedData/
|
|
||||||
|
|
||||||
# Credentials and machine-local configuration
|
# Agent instructions
|
||||||
.env
|
AGENTS.md
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
*.mobileprovision
|
|
||||||
*.p12
|
|
||||||
*.pem
|
|
||||||
|
|
||||||
# Local research and design material
|
# Temp/misc
|
||||||
/design/
|
|
||||||
/referensi/
|
|
||||||
/extension/
|
|
||||||
/tool/
|
|
||||||
/spotiflac_android/
|
|
||||||
|
|
||||||
# Documentation published separately. Keep the in-repo extension contract
|
|
||||||
# available because contribution templates link to it.
|
|
||||||
/docs/*
|
|
||||||
!/docs/EXTENSION_DEVELOPMENT.md
|
|
||||||
|
|
||||||
# Temporary files and logs
|
|
||||||
/.tmp/
|
|
||||||
nul
|
nul
|
||||||
NUL
|
network_requests.txt
|
||||||
/network_requests.txt
|
|
||||||
/AndroidManifest.xml
|
# Log files
|
||||||
*.bak
|
|
||||||
*.orig
|
|
||||||
*.swp
|
|
||||||
*.tmp
|
|
||||||
*.log
|
*.log
|
||||||
hs_err_*.log
|
hs_err_*.log
|
||||||
flutter_*.log
|
flutter_*.log
|
||||||
|
|
||||||
|
# Development tools
|
||||||
|
tool/
|
||||||
|
.claude/settings.local.json
|
||||||
|
.playwright-mcp/
|
||||||
|
|
||||||
|
# FVM Version Cache
|
||||||
|
.fvm/
|
||||||
|
|||||||
@@ -1,513 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [4.8.5] - 2026-08-01
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Searchable Settings**: Search both top-level pages and controls inside them. Opening a nested result now scrolls to the matching control and briefly highlights it.
|
|
||||||
- **Album Multi-Select Download**: Select several tracks in an album and choose the provider and quality once for the whole selection.
|
|
||||||
- **Player & Queue Controls**: Repeat Off/All/One modes, swipe up from Now Playing to open the queue, move queued tracks up or down, `Download next`, and one-tap retry when connectivity returns.
|
|
||||||
- **Playback Session Restore**: Restore the previous queue, track, technical quality, and playback position after the app process is restarted.
|
|
||||||
- **Track Navigation**: `Go to Album` is available from track actions, including sparse search results, and `Open on...` exposes supported platform links.
|
|
||||||
- **Library Views**: Playlist filtering, a configurable default Library view, a missing-ISRC filter, and clearer horizontally scrollable filter chips.
|
|
||||||
- **Quality Labels**: Choose between measured bitrate labels and classic bit-depth/sample-rate labels. High-bitrate tracks receive a distinct badge color, and lossless average bitrate is shown in Track Metadata.
|
|
||||||
- **Duplicate Review**: Detect ISRC duplicates beyond FLAC, review matching files, and keep the preferred copy.
|
|
||||||
- **M3U Playlists**: Import M3U/M3U8 playlists and export Library collections as M3U8.
|
|
||||||
- **Metadata Sources**: Choose a specific extension for online auto-fill, use extension-defined custom search, or manually fetch metadata from MusicBrainz.
|
|
||||||
- **Cover Tools**: View the current embedded-cover dimensions, preview the dimensions of a fetched cover, resize embedded artwork, and select the best available online cover candidate.
|
|
||||||
- **Android Download Widget**: Monitor the active download queue from the Android home screen.
|
|
||||||
- **Appearance Controls**: Adaptive detail-page colors derived from cover art and an optional force-backdrop-blur setting for lower device tiers.
|
|
||||||
- **Audio Processing**: Very-high-quality SoXR resampling settings for supported lossless conversions.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Provider Matching**: Honor the explicitly selected provider and reject mismatched provider results that could download a cover or unrelated song with the original album metadata.
|
|
||||||
- **Verification & Sessions**: Coordinate one verification flow across sequential and parallel album downloads, follow canonical gateway/provider error contracts, block challenged session generations, and prevent stale requests from invalidating a newer session.
|
|
||||||
- **Verification Alerts**: Notify the user when downloads pause for verification and recover signed-session bootstrap failures more reliably.
|
|
||||||
- **Download Storage**: Recover from an unwritable selected folder, retain published files when bookkeeping fails, and keep cancelled items retryable after app restarts.
|
|
||||||
- **Quality Variants**: Complete alternate-quality downloads reliably and append technical quality to filenames only when a real filename collision exists.
|
|
||||||
- **Library Stability**: Keep rows and cached history visible during active downloads and refreshes, retain records when deletion fails, and backfill/persist local bitrate metadata without opening every track.
|
|
||||||
- **Playback & Lyrics**: Refresh lyrics after automatic track changes and cold starts, reject empty embedded lyric payloads, preserve technical quality between tracks, and restore playback position reliably.
|
|
||||||
- **WAV & AIFF Metadata**: Preserve supported tags and artwork through conversion and metadata editing.
|
|
||||||
- **Audio Analysis**: Wait for complete FFmpeg metrics, isolate analysis from active downloads, detect effective/full-band spectral cutoff more accurately, and avoid unsupported AC-4 spectrogram analysis.
|
|
||||||
- **Startup Compatibility**: Stop forcing the highest Android display mode during every cold start, avoiding OEM-specific crashes and unnecessary GPU load.
|
|
||||||
- **User-Facing Errors**: Replace raw `PlatformException` and backend details with concise, sanitized messages.
|
|
||||||
- **Metadata UI**: Keep Track Metadata headers readable in light themes and make the online source picker, preview, apply, and feedback flow consistent with the rest of the app.
|
|
||||||
- **Selection & Sheets**: Keep selection bars and modal sheets above the app navbar and make download-picker drag dismissal feel natural.
|
|
||||||
- **Database Upgrade**: Make playback-session migration safe when an older installation already contains the destination table.
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
|
|
||||||
- **Now Playing**: Smoother track transitions, consistent custom action sheets, better synced-lyrics positioning, and aligned play/preview controls and hitboxes.
|
|
||||||
- **Responsive UI**: More consistent search fields, settings groups, page headers, modal sheets, empty/error states, and collection selection behavior, with improved tablet widths and short-screen setup/tutorial layouts.
|
|
||||||
- **Download Performance**: Faster duplicate checks for large playlists, fewer native-widget updates, reduced progress/session write churn, and safer HTTP Range recovery.
|
|
||||||
- **Library Performance**: Batched history/index updates, fewer rebuilds during scans, lazy duplicate loading, cached MusicBrainz lookups, and quality probes that avoid unnecessary SAF copies.
|
|
||||||
- **Extension Security**: Verify decentralized package integrity, isolate bad registry checksums, anchor URL handlers to their declared hosts, and enforce `minAppVersion` and required runtime features.
|
|
||||||
- **Dependencies**: Updated Flutter, Go, FFmpeg, Android notification, and supporting native dependencies.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [4.8.0] - 2026-07-24
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Tablet & Landscape UI**: Navigation rail, landscape Now Playing, clamped layouts for track lists, dialogs, sheets, settings, queue lists, and the setup wizard, with adaptive selection-card columns.
|
|
||||||
- **Playback Volume Normalization**: ReplayGain/R128 volume normalization for the built-in player (Library > Playback, off by default).
|
|
||||||
- **Concurrent Downloads**: Support up to 3 simultaneous downloads.
|
|
||||||
- **Download Resume**: Interrupted downloads resume via HTTP Range/If-Range instead of restarting from zero.
|
|
||||||
- **Lost Folder Access Recovery**: Detects a lost SAF grant and offers folder re-selection.
|
|
||||||
- **Native Tag Editors**: New native MP3 ID3v2, Ogg/Opus Vorbis comment, and M4A ilst tag editors — metadata edits for these formats no longer need FFmpeg.
|
|
||||||
- **Shared-Element Transitions**: Hero covers fly from search/recents to detail screens, the player expands from the mini player with drag-to-dismiss, and skeletons crossfade into content.
|
|
||||||
- **Player Gestures**: Swipe the mini player away to stop playback.
|
|
||||||
- **Convert Options**: Optionally retain original files after conversion, and support same-format lossless re-encoding.
|
|
||||||
- **Quality Variants**: Downloads preserve quality variants, named by measured quality.
|
|
||||||
- **Explicit Badge**: Explicit tracks show an [E] badge.
|
|
||||||
- **iOS Verification**: Extension verification and OAuth run through ASWebAuthenticationSession.
|
|
||||||
- **Forced Update**: Force update when 3 or more stable releases behind.
|
|
||||||
- **Display**: Requests the highest display refresh rate on Android; fluid bouncing scroll physics on capable devices; shimmer skeletons for home feed and artist screens.
|
|
||||||
- **Hero Animation Toggle**: Setting to disable Hero animations.
|
|
||||||
- **Network Hardening**: DNS-over-HTTPS fallback and download retry hardening.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Safer File Writes**: All SAF and direct-mode downloads are staged then atomically promoted with fsync; FLAC, M4A, and AC-4 tags are written atomically; fixed the endless FLAC-write failures on misnamed files.
|
|
||||||
- **Native Worker Reliability**: Recovers from dead runs, keeps batches and retries intact across races, never deletes a published download over bookkeeping failures, and flushes queue persistence when the app is backgrounded.
|
|
||||||
- **Download History**: Persisted and reconciled reliably; completed downloads bridge into the library; library records persist before download completion.
|
|
||||||
- **Playlist Import Order**: Imported playlists keep their track order (#482).
|
|
||||||
- **Playback**: No auto-resume after an audio interruption while backgrounded; stale playback and SAF temp files cleaned up.
|
|
||||||
- **M4A Metadata**: stco/co64 chunk offsets shift correctly when the ilst tag resizes.
|
|
||||||
- **Extensions**: Data directory removed on uninstall, storage/credential writes serialized across runtimes, stale pending auth challenges expire, package and runtime lifecycle hardened, bounded file reads and cookie state.
|
|
||||||
- **Localization**: Deezer requests send Accept-Language so names follow the app language.
|
|
||||||
- **arm32 Stability**: Built with Go 1.26.5 for aligned cgo frames.
|
|
||||||
- **CSV Import**: Use State's context instead of a stale parameter (#460) — thanks @A831ARD0.
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
|
|
||||||
- **Broad Performance Pass**: Streaming tag rewrites for Ogg and M4A, streamed PCM analysis, SAF scan results spill to disk, Go heap and image caches released on memory pressure, per-extension goja runtime pool, incremental queue persistence and ISRC index, byte-capped cover disk cache, TTL/ETag-cached update checks, gzip and TLS session resumption, backdrop blur gated by device capability, incremental DB auto-vacuum.
|
|
||||||
- **Codebase Health**: Large-scale deduplication across screens, providers, and the Go backend; dead code paths removed.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- **Graduated from Beta**: Native download worker and Backup & Restore are now stable.
|
|
||||||
- **Terminology**: Extension "store" renamed to "repo" across all layers.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [4.7.1] - 2026-07-02
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Full Extension Metadata Embedding**: Full metadata and cover are embedded after extension downloads.
|
|
||||||
- **Lossless Conversion Options**: Dither and resampler options.
|
|
||||||
- **Verification Browser Preference**: Choose in-app or external browser for extension verification, with manual verification help when browser launch fails.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Convert Sheets**: Draggable and scroll-controlled bottom sheets.
|
|
||||||
- **Verification**: Defaults to the in-app browser first; native worker verification hands off to the interactive queue; signed-session auth bootstraps and clears pending state correctly.
|
|
||||||
- **AC-4**: Truncated sample entries are rejected safely.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [4.7.0] - 2026-07-01
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **New Playback Experience**: Rebuilt built-in player with media integration, reorderable up-next queue, and playback stability fixes.
|
|
||||||
- **Track Previews**: Play short preview snippets from track lists.
|
|
||||||
- **Motion Artwork**: HLS motion-artwork header banners (Apple Music style) and audio-quality badges.
|
|
||||||
- **Backup & Restore (Beta)**: Settings, download history, library, and installed extensions.
|
|
||||||
- **Lossless Conversion Caps**: Cap bit depth/sample rate on lossless conversion, including album batch convert.
|
|
||||||
- **ReplayGain**: Opus R128 gain tags.
|
|
||||||
- **AC-4 Passthrough**: Download support for AC-4 streams.
|
|
||||||
- **Queue**: Tap a failed download to view its error details.
|
|
||||||
- **Filename Placeholders**: `{playlist_position}` placeholder.
|
|
||||||
- **Playlist Folders**: Rename action.
|
|
||||||
- **Network Setting**: Opt-in allow local/private network access.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Verification**: Retries tracked per service; signed session files scoped by endpoint and app context; early abort in fallback.
|
|
||||||
- **Lyrics**: Provider priority synced to backend, improved fallback and health handling.
|
|
||||||
- **Metadata**: Oversized cover art capped, QuickTime/MP4 tag support, native M4A ISRC/label writes, all edited fields read back from file.
|
|
||||||
- **Navigation**: Tapping an artist name navigates to the correct artist.
|
|
||||||
- **Compatibility**: Impeller disabled on Sony audio players and Vivante GPUs.
|
|
||||||
- **Fallback Quality**: Actual output format detected from audio codec; requested quality honored when the fallback provider recognizes it.
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
|
|
||||||
- **Performance**: Optimized queue/library pagination, counts, scan, and extension runtime.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [4.6.0] - 2026-06-14
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **WAV & AIFF Support**: Full metadata support with a settings-style metadata menu.
|
|
||||||
- **LyricsPlus Provider** and **ReplayGain batch scanning**.
|
|
||||||
- **French & German Locales**.
|
|
||||||
- **UI Polish**: Blurred backdrop playlist headers, frosted translucent bottom navbar, active downloads shown inside the library grid, actual lyrics source shown in metadata.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Downloads**: Selected provider honored when it equals the track source; fallback provider requests its own highest quality; iOS background task handling.
|
|
||||||
- **UI**: High-res cover in the track metadata header, centered modals on large screens, modernized edit-metadata and convert sheets, predictive-back page transition override.
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
- **Concurrent Download Option**: Removed during the playback rework (returned in 4.8.0 with proper support).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [4.5.6] - 2026-06-02
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Cross-Extension Sharing**: Rebuilt sharing and queue controls; polished search empty state.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Sharing & Fallback Routing**: Cross-service sharing aligned with fallback routing.
|
|
||||||
- **Metadata**: Stricter matching, `embedLyrics` setting respected, improved Apple Music lyrics; album-only autofill and placeholder re-enrich regressions resolved.
|
|
||||||
- **Downloads**: Hardened error handling and re-enrich sidecars.
|
|
||||||
- **iOS**: Deployment target aligned for the file picker.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [4.5.5] - 2026-05-15
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **5 New Lyrics Providers**.
|
|
||||||
- **Enhanced Audio Analysis**: Loudness, clipping, and spectral cutoff metrics, with rescan support after a cached result.
|
|
||||||
- **AAC Support**: AAC conversion and lossy download target.
|
|
||||||
- **Apple Music Word-Synced Lyrics**: Toggle for eLRC word sync.
|
|
||||||
- **Launch Announcements**: Remote-config announcements shown on app start.
|
|
||||||
- **History**: Codec format and bitrate persisted in download history.
|
|
||||||
- **MP4 Codec Detection**: FLAC/ALAC/EAC3/AC3/AC4 detected inside MP4 containers.
|
|
||||||
- **Library**: Scroll-to-top and scroll-to-bottom quick buttons.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **No Fake Lossless**: Codec probes prevent lossy-to-lossless upscale conversions in every branch.
|
|
||||||
- **MP3 Lyrics**: Embedded via ID3v2.3 USLT frame.
|
|
||||||
- **FLAC**: Native muxer forced when decrypting to .flac output; native handling fixes.
|
|
||||||
- **SAF**: Extension-agnostic `.partial` staged filename.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [4.5.1] - 2026-05-08
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Native Worker**: Avoids the Android binder payload limit.
|
|
||||||
- **Extensions**: Missing-extension state shown for returning users.
|
|
||||||
- **Downloads**: `dataSync` foreground service type declared; default quality settings restored.
|
|
||||||
- **UI**: Settings editor white screens fixed; library search stabilized.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [4.5.0] - 2026-05-06
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- **All Built-in Download Providers Retired**: Tidal, Qobuz, and Deezer now live as extensions with isolated runtimes. Install them from the Store to keep downloading.
|
|
||||||
- **Settings Reorganized**: Split into focused pages — thanks @Amonoman.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Android Native Download Worker (Experimental)**: Downloads continue reliably in the background.
|
|
||||||
- **Favorite Artists Collection**.
|
|
||||||
- **Extension Service Health**: Health indicators for extension services.
|
|
||||||
- **Download Deduplication Setting** — thanks @Amonoman.
|
|
||||||
- **New Look**: Google Sans Flex font and monochrome icon support.
|
|
||||||
- **Extension APIs**: `skip_fallback` capability, download dedup/ISRC/lyrics APIs, generic provider resolution, progress phases.
|
|
||||||
- **Metadata**: MusicBrainz album artist fallback; instrumental lyrics heuristic; audio duration exposed.
|
|
||||||
- **Home Feed**: Can be disabled.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **iOS**: Security-scoped bookmarks for download directory persistence.
|
|
||||||
- **SAF**: Filenames and directory segments truncate safely at UTF-8 boundaries.
|
|
||||||
- **Native Worker**: Metadata embedding, notification progress, and history schema sync.
|
|
||||||
- **Localization**: Missing Crowdin locale variants mapped; unsupported locales fall back to English.
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
|
|
||||||
- **Database-Backed Pagination**: Queue tab and local library migrated from in-memory lists to paginated database queries — much lower memory use on large libraries.
|
|
||||||
- **Performance**: Delta-mode native worker snapshots, reduced bridge/UI churn, improved queue resilience.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [4.3.1] - 2026-04-14
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Extension Output Control**: `preserveNativeOutputExtensions` capability; M4A converts to FLAC when the extension doesn't prefer native M4A output; extension download metadata carried through the host pipeline.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Container Handling**: `.mp4` treated as alias for `.m4a` throughout the download pipeline; `OutputExt` reset on extension→extension and extension→built-in fallback.
|
|
||||||
- **Provider Choice**: User's provider choice respected over source extension priority.
|
|
||||||
- **Tidal Metadata**: Copyright and album artist improved; DATE/YEAR tag sync fixed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [4.3.0] - 2026-04-13
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Artist Search Filter** and normalized search filter handling.
|
|
||||||
- **Default Search Tab Preference**.
|
|
||||||
- **Native M4A ReplayGain** tag writing and SAF picker error handling.
|
|
||||||
- **Download Cancel**: Propagates to extension HTTP requests.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- **Deezer Search via Extension**: Search flow moved out of the core app.
|
|
||||||
- **Extension Manifest**: `author` field removed.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **iOS**: Extension OAuth callback handled.
|
|
||||||
- **M4A**: Existing metadata preserved during embed; ALAC quality parsing improved.
|
|
||||||
- **Extensions**: Shared link handling stabilized; genre falls back to extra metadata.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [4.2.2] - 2026-04-06
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Configurable Extension Download Fallback**.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- **Deezer is now an Extension**: Moved from built-in service to installable extension.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Metadata**: Downloaded metadata persisted, re-enrich matching aligned with autofill, composer preserved across Qobuz and history, flat singles output preserved for extension releases.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [4.2.1] - 2026-04-04
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Metadata Enrichment**: Composer and track totals.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Crash Fix**: Stale audio service manifest entries removed (crashed on some devices).
|
|
||||||
- **Stability**: Hardened gomobile extension bindings, M4A cover retention, local convert format and library entries preserved, embedded metadata details preserved.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [4.2.0] - 2026-04-04
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **ReplayGain Scanning** and APEv2 tag support.
|
|
||||||
- **Singles/EP Filename Format**: Separate format setting (#271).
|
|
||||||
- **Bulk Re-enrich Field Selection**: Choose which fields to update.
|
|
||||||
- **Resolve API**: With SongLink fallback.
|
|
||||||
- **Extension API**: `skipLyrics` manifest field; additional search/metadata API with separate rate limiting.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Multi-Artist Tags** (#288).
|
|
||||||
- **Qobuz 401 Errors**: API calls routed through an authenticated gateway.
|
|
||||||
- **Metadata**: Missing track/disc numbers from search downloads, ISRC validation to prevent ID leakage, label/copyright resolved from file metadata.
|
|
||||||
- **UI**: System navigation bar matches app theme.
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
|
|
||||||
- **UI Smoothness**: Memoization, compute isolates, SQL-backed playlist picker, viewport-aware image caching.
|
|
||||||
- **Performance**: Incremental queue lookups, async cover cleanup, native JSON decoding on iOS.
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
- **Legacy API Clients**: Including the Yoinkify fallback and an unused lyrics provider.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [4.1.3] - 2026-03-30
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Artist Tag Mode**: Setting with split Vorbis support.
|
|
||||||
- **Search & Filters**: Metadata filters and extended sort options; Qobuz album-search fallback; stable cover cache keys.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Samsung SAF Library Scan**, Qobuz album cover, and M4A metadata save.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [4.1.2] - 2026-03-29
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Batch Progress Dialog**: Replaces snackbars for batch operations.
|
|
||||||
- **Library**: Haptic feedback when swiping tabs; play button on playlist/library track tiles.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Extension Quality**: Tidal quality options used as fallback; DEFAULT quality normalized to prevent API failures.
|
|
||||||
- **ALAC**: `attached_pic` disposition for cover art embedding.
|
|
||||||
- **Downloads**: `START_NOT_STICKY` prevents DownloadService auto-restart.
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
|
|
||||||
- **Track Matching**: Scoring-based ReEnrich selection; Spotify URLs routed through extensions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [4.1.1] - 2026-03-27
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Extension Download Progress**: Byte-level progress tracking.
|
|
||||||
- **Spectrogram Cache**: Cached as PNG for instant loading on subsequent views.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **UI**: Artist skeleton polish; SpectrogramView null crash when loading from PNG cache.
|
|
||||||
- **SAF**: `artist_album_flat` case added to relative output dir builder.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [4.1.0] - 2026-03-26
|
|
||||||
|
|
||||||
Version numbering returns to 4.x, continuing from the 3.x rollback in 3.7.0.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Audio Quality Analysis**: Analysis widget with cached results.
|
|
||||||
- **Smart Service Selection**: Recommended download service auto-selected based on content source.
|
|
||||||
- **Search Result Sorting**.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- **YouTube Downloads**: Extracted to the YouTube Music extension.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **UI**: Skeleton visibility and artist header in light mode, store URL input flash on startup, unintended home reset on tab switch, embedded cover refresh.
|
|
||||||
- **Metadata**: Hi-res cover art for Tidal/Qobuz, album metadata override, FLAC metadata fallback for mismatched files, USLT lyrics detection.
|
|
||||||
- **Navigation**: Tidal/Qobuz items from Recent Access open built-in screens.
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
|
|
||||||
- **Startup**: Lazy extension VM init and incremental startup maintenance.
|
|
||||||
- **Deezer Resolution**: Uses Tidal/Qobuz metadata; Qobuz skips SongLink when ISRC is already available.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [3.9.0] - 2026-03-25
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Built-in Tidal/Qobuz Search**: With a recommended service picker and a built-in search provider setting.
|
|
||||||
- **Home Feed Provider Setting**.
|
|
||||||
- **Tidal HIGH Restored**: AAC 320kbps lossy quality option (#242).
|
|
||||||
- **Full M4A Tag Engine**: Read engine with atom path fallback, metadata/cover embed across all screens, and library scan support.
|
|
||||||
- **Playlist Source Folders**: `createPlaylistFolder` setting for playlist folder prefixes.
|
|
||||||
- **Resilient Artist Matching**: Diacritic folding for loose artist name matching.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Localization**: Crowdin locale files consolidated, ICU plural warnings fixed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [3.8.8] - 2026-03-18
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Tidal ISRC & Metadata Search**.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- **Lyrics Providers**: Migrated to Paxsenix endpoints.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Extension Download Reliability** and Qobuz API integration.
|
|
||||||
- **Stability**: Library scan IDs, pause queue behavior, and a scan race condition.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [3.8.7] - 2026-03-17
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Queue**: Active downloads properly stop when pausing; queue-as-FLAC hides when all selected tracks are already FLAC and skips FLAC tracks from selection.
|
|
||||||
- **Downloads**: Already-downloaded tracks skipped in library folder download-all; Spotify track availability resolution improved.
|
|
||||||
- **Covers**: Deezer and Tidal cover art upgraded to max quality.
|
|
||||||
- **Library**: Auto-scan cooldown honored.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [3.8.6] - 2026-03-16
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Local Library Auto-Scan** option.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Re-downloads**: Tracks converted to a different format are no longer re-downloaded.
|
|
||||||
- **iOS**: Folder picker delayed until sheet dismiss; Afkar hosts updated.
|
|
||||||
- **UI**: Double horizontal padding removed in the store tab.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [3.8.5] - 2026-03-16
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **FLAC/ALAC Bidirectional Lossless Conversion**.
|
|
||||||
- **Selective Auto-Fill**: Pick fields to auto-fill from online in the Edit Metadata sheet, with improved track resolution.
|
|
||||||
- **FLAC Redownload Queue**: Queue FLAC redownloads for local library tracks.
|
|
||||||
- **Opus 320kbps Quality** (Tidal HIGH tier removed; restored in 3.9.0).
|
|
||||||
- **Bulk Playlist Download**: Download multiple selected playlists — thanks @ViscousPot.
|
|
||||||
- **Playlist Import**: Playlist name auto-filled during import — thanks @ViscousPot.
|
|
||||||
- **Qobuz Afkar API Provider**: With request metadata preferred for consistent album grouping.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Wrong-Track Protection**: Resolved Tidal/Deezer tracks are verified against the download request before downloading.
|
|
||||||
- **Download All**: Skips already-downloaded tracks for albums and playlists.
|
|
||||||
- **Various Artists**: Album-level artist used instead of the first track's artist.
|
|
||||||
- **M4A/ALAC**: Cover art extracted for conversion, embedded lyrics detected, metadata and cover preserved in ALAC/M4A→FLAC conversion.
|
|
||||||
- **Batch Convert**: Target formats filtered by source formats.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [3.8.0] - 2026-03-14
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Qobuz & Tidal Metadata Search**: Built-in metadata search providers with priority-based unified search, full metadata API, URL parsers, and store support.
|
|
||||||
- **Auto-Enrich for Extension Downloads**: Metadata is automatically enriched.
|
|
||||||
- **Store Registry URL Management**: With iOS handler support and a cleaner store UI.
|
|
||||||
- **Provider Priority**: Deezer entry added to the priority UI.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Tidal**: Track resolution and playlist owner info.
|
|
||||||
- **iOS**: Stale built-in Spotify bridge handlers removed.
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
|
|
||||||
- **Performance**: Optimized polling, progress caching, staggered warmup, snapshot-based library scan, SAF metadata reading, CUE sibling resolution, and startup initialization.
|
|
||||||
- **Localization**: Hardcoded strings replaced with l10n keys across 13 screens.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [3.7.2] - 2026-03-07
|
## [3.7.2] - 2026-03-07
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@@ -1253,8 +745,8 @@ Thank you for your understanding and continued support. This decision was made t
|
|||||||
|
|
||||||
### Highlights
|
### Highlights
|
||||||
|
|
||||||
- **Local Library Scanning** ([#117](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/117)): Scan existing music collection to detect duplicates (FLAC, M4A, MP3, Opus, OGG)
|
- **Local Library Scanning** ([#117](https://github.com/zarzet/SpotiFLAC-Mobile/issues/117)): Scan existing music collection to detect duplicates (FLAC, M4A, MP3, Opus, OGG)
|
||||||
- **Duplicate Detection** ([#117](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/117)): "In Library" badge on tracks matching by ISRC or track name + artist
|
- **Duplicate Detection** ([#117](https://github.com/zarzet/SpotiFLAC-Mobile/issues/117)): "In Library" badge on tracks matching by ISRC or track name + artist
|
||||||
- **Unified Library Tab**: History renamed to Library, shows Downloaded + Local Library tracks with source badges
|
- **Unified Library Tab**: History renamed to Library, shows Downloaded + Local Library tracks with source badges
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -1322,7 +814,7 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Clear All Queue Button**: Cancel all queued downloads with one tap ([#96](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/96))
|
- **Clear All Queue Button**: Cancel all queued downloads with one tap ([#96](https://github.com/zarzet/SpotiFLAC-Mobile/issues/96))
|
||||||
- **IDHS Fallback**: Fallback link resolver when SongLink fails (rate limited 8 req/min)
|
- **IDHS Fallback**: Fallback link resolver when SongLink fails (rate limited 8 req/min)
|
||||||
- **Lossy Bitrate Options**: MP3 (320/256/192/128kbps), Opus (128/96/64kbps)
|
- **Lossy Bitrate Options**: MP3 (320/256/192/128kbps), Opus (128/96/64kbps)
|
||||||
- **Search Filters**: Filter results by type (Tracks, Artists, Albums, Playlists)
|
- **Search Filters**: Filter results by type (Tracks, Artists, Albums, Playlists)
|
||||||
@@ -1339,10 +831,10 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **MP3 Download Error 403**: Fixed 403 Forbidden error when downloading MP3 files ([#108](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/108))
|
- **MP3 Download Error 403**: Fixed 403 Forbidden error when downloading MP3 files ([#108](https://github.com/zarzet/SpotiFLAC-Mobile/issues/108))
|
||||||
- **Opus Cover Art**: Implemented METADATA_BLOCK_PICTURE for proper cover embedding
|
- **Opus Cover Art**: Implemented METADATA_BLOCK_PICTURE for proper cover embedding
|
||||||
- **Deezer Pagination**: Fixed >25 tracks only showing first 25 ([#112](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/112))
|
- **Deezer Pagination**: Fixed >25 tracks only showing first 25 ([#112](https://github.com/zarzet/SpotiFLAC-Mobile/issues/112))
|
||||||
- **Duplicate Embed Lyrics Setting**: Removed from Options page ([#110](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/110))
|
- **Duplicate Embed Lyrics Setting**: Removed from Options page ([#110](https://github.com/zarzet/SpotiFLAC-Mobile/issues/110))
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1555,7 +1047,7 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
|||||||
- Spanish: Credits 125 ([@credits125](https://crowdin.com/profile/credits125))
|
- Spanish: Credits 125 ([@credits125](https://crowdin.com/profile/credits125))
|
||||||
- Portuguese: Pedro Marcondes ([@justapedro](https://crowdin.com/profile/justapedro))
|
- Portuguese: Pedro Marcondes ([@justapedro](https://crowdin.com/profile/justapedro))
|
||||||
- Russian: Владислав ([@odinokiy_kot](https://crowdin.com/profile/odinokiy_kot))
|
- Russian: Владислав ([@odinokiy_kot](https://crowdin.com/profile/odinokiy_kot))
|
||||||
- **Quick Search Provider Switcher** ([#76](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/76)): Dropdown menu in search bar for instant provider switching
|
- **Quick Search Provider Switcher** ([#76](https://github.com/zarzet/SpotiFLAC-Mobile/issues/76)): Dropdown menu in search bar for instant provider switching
|
||||||
- Tap the search icon to reveal a dropdown menu with all available search providers
|
- Tap the search icon to reveal a dropdown menu with all available search providers
|
||||||
- Shows default provider (Deezer based on metadata source setting) at the top
|
- Shows default provider (Deezer based on metadata source setting) at the top
|
||||||
- Lists all enabled extensions with custom search capability
|
- Lists all enabled extensions with custom search capability
|
||||||
@@ -1564,16 +1056,16 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
|||||||
- Search hint text updates immediately when switching providers
|
- Search hint text updates immediately when switching providers
|
||||||
- Re-triggers search automatically if there's existing text in the search bar
|
- Re-triggers search automatically if there's existing text in the search bar
|
||||||
- Eliminates need to navigate to Settings > Extensions > Search Provider
|
- Eliminates need to navigate to Settings > Extensions > Search Provider
|
||||||
- **Extension Button Setting Type** ([#74](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/74)): New setting type for extension actions
|
- **Extension Button Setting Type** ([#74](https://github.com/zarzet/SpotiFLAC-Mobile/issues/74)): New setting type for extension actions
|
||||||
- Extensions can define `button` type in manifest settings
|
- Extensions can define `button` type in manifest settings
|
||||||
- Triggers JavaScript function when tapped (e.g., start OAuth flow)
|
- Triggers JavaScript function when tapped (e.g., start OAuth flow)
|
||||||
- Useful for authentication, manual sync, or any custom action
|
- Useful for authentication, manual sync, or any custom action
|
||||||
- **Genre & Label Metadata** ([#75](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/75)): Downloaded tracks now include genre and record label information
|
- **Genre & Label Metadata** ([#75](https://github.com/zarzet/SpotiFLAC-Mobile/issues/75)): Downloaded tracks now include genre and record label information
|
||||||
- Fetches genre and label from Deezer album API for each track
|
- Fetches genre and label from Deezer album API for each track
|
||||||
- Embeds GENRE, ORGANIZATION (label), and COPYRIGHT tags into FLAC files
|
- Embeds GENRE, ORGANIZATION (label), and COPYRIGHT tags into FLAC files
|
||||||
- Works automatically when Deezer track ID is available (via ISRC matching)
|
- Works automatically when Deezer track ID is available (via ISRC matching)
|
||||||
- Supports all download services (Tidal, Qobuz, Amazon) and extension downloads
|
- Supports all download services (Tidal, Qobuz, Amazon) and extension downloads
|
||||||
- **MP3 Quality Option** ([#69](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/69)): Optional MP3 download format with FLAC-to-MP3 conversion
|
- **MP3 Quality Option** ([#69](https://github.com/zarzet/SpotiFLAC-Mobile/issues/69)): Optional MP3 download format with FLAC-to-MP3 conversion
|
||||||
- New "Enable MP3 Option" toggle in Settings > Download > Audio Quality
|
- New "Enable MP3 Option" toggle in Settings > Download > Audio Quality
|
||||||
- When enabled, MP3 (320kbps) appears as a quality option alongside FLAC options
|
- When enabled, MP3 (320kbps) appears as a quality option alongside FLAC options
|
||||||
- Available in both the quality picker dialog and default quality settings
|
- Available in both the quality picker dialog and default quality settings
|
||||||
@@ -1599,12 +1091,12 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
|||||||
- **Artist Name in Album Screen**: Album info card now displays artist name below album title
|
- **Artist Name in Album Screen**: Album info card now displays artist name below album title
|
||||||
- Extracted from first track's artist metadata
|
- Extracted from first track's artist metadata
|
||||||
- Styled with `onSurfaceVariant` color for visual hierarchy
|
- Styled with `onSurfaceVariant` color for visual hierarchy
|
||||||
- **Disc Separation for Multi-Disc Albums** ([#70](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/70)): Downloaded albums with multiple discs now display tracks grouped by disc
|
- **Disc Separation for Multi-Disc Albums** ([#70](https://github.com/zarzet/SpotiFLAC-Mobile/issues/70)): Downloaded albums with multiple discs now display tracks grouped by disc
|
||||||
- Visual disc separator header showing "Disc 1", "Disc 2", etc.
|
- Visual disc separator header showing "Disc 1", "Disc 2", etc.
|
||||||
- Tracks sorted by disc number first, then by track number
|
- Tracks sorted by disc number first, then by track number
|
||||||
- Single-disc albums display normally without separators
|
- Single-disc albums display normally without separators
|
||||||
- Fixes confusion when albums have duplicate track numbers across discs
|
- Fixes confusion when albums have duplicate track numbers across discs
|
||||||
- **Album Grouping in Recents** ([#70](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/70)): Downloads now show as albums instead of individual tracks in the Recent section
|
- **Album Grouping in Recents** ([#70](https://github.com/zarzet/SpotiFLAC-Mobile/issues/70)): Downloads now show as albums instead of individual tracks in the Recent section
|
||||||
- Prevents flooding the recents list when downloading full albums
|
- Prevents flooding the recents list when downloading full albums
|
||||||
- Groups tracks by album name and artist
|
- Groups tracks by album name and artist
|
||||||
- Tapping navigates directly to the downloaded album screen
|
- Tapping navigates directly to the downloaded album screen
|
||||||
@@ -1744,4 +1236,4 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
_For older versions, see [GitHub Releases_](https://github.com/spotiflacapp/SpotiFLAC-Mobile/releases)
|
_For older versions, see [GitHub Releases_](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
|
|||||||
@@ -1,167 +1,282 @@
|
|||||||
# Contributing to SpotiFLAC Mobile
|
# Contributing to SpotiFLAC
|
||||||
|
|
||||||
Thank you for helping improve SpotiFLAC Mobile. Bug reports, focused pull
|
First off, thank you for considering contributing to SpotiFLAC! 🎉
|
||||||
requests, documentation, and translations are all welcome.
|
|
||||||
|
|
||||||
Please follow the [Code of Conduct](CODE_OF_CONDUCT.md) when participating in
|
This document provides guidelines and steps for contributing. Following these guidelines helps maintain code quality and ensures a smooth collaboration process.
|
||||||
the project.
|
|
||||||
|
|
||||||
## Before You Start
|
## Table of Contents
|
||||||
|
|
||||||
- Search the [existing issues](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues)
|
- [Code of Conduct](#code-of-conduct)
|
||||||
before opening a new one.
|
- [How Can I Contribute?](#how-can-i-contribute)
|
||||||
- Use the issue template that best matches the problem.
|
- [Reporting Bugs](#reporting-bugs)
|
||||||
- Keep pull requests focused. Separate unrelated fixes into separate PRs.
|
- [Suggesting Features](#suggesting-features)
|
||||||
- Never commit credentials, signing files, downloaded media, or generated build
|
- [Code Contributions](#code-contributions)
|
||||||
artifacts.
|
- [Translations](#translations)
|
||||||
|
- [Development Setup](#development-setup)
|
||||||
|
- [Project Structure](#project-structure)
|
||||||
|
- [Coding Guidelines](#coding-guidelines)
|
||||||
|
- [Commit Guidelines](#commit-guidelines)
|
||||||
|
- [Pull Request Process](#pull-request-process)
|
||||||
|
|
||||||
Translations are managed through the
|
## Code of Conduct
|
||||||
[SpotiFLAC Mobile Crowdin project](https://crowdin.com/project/spotiflac-mobile).
|
|
||||||
The English source strings live in `lib/l10n/arb/app_en.arb`.
|
|
||||||
|
|
||||||
## Toolchain
|
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.
|
||||||
|
|
||||||
The repository is the source of truth for tool versions:
|
## How Can I Contribute?
|
||||||
|
|
||||||
- Flutter: `.fvmrc`
|
### Reporting Bugs
|
||||||
- Dart: bundled with the pinned Flutter SDK
|
|
||||||
- Go: `go_backend/go.mod`
|
|
||||||
- Android SDK, NDK, and Java: `.github/workflows/ci.yml`
|
|
||||||
- Xcode: required only for iOS builds
|
|
||||||
|
|
||||||
[FVM](https://fvm.app/) is recommended. If you do not use FVM, install the
|
Before creating bug reports, please check the [existing issues](https://github.com/zarzet/SpotiFLAC-Mobile/issues) to avoid duplicates.
|
||||||
exact Flutter version declared in `.fvmrc` and replace `fvm flutter` with
|
|
||||||
`flutter` (and `fvm dart` with `dart`) in the commands below.
|
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 `dev`
|
||||||
|
2. **Make your changes** following our coding guidelines
|
||||||
|
3. **Test your changes** thoroughly
|
||||||
|
4. **Submit a pull request** to the `dev` 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
|
## Development Setup
|
||||||
|
|
||||||
1. Fork and clone the repository:
|
### 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
|
```bash
|
||||||
git clone https://github.com/YOUR_USERNAME/SpotiFLAC-Mobile.git
|
git clone https://github.com/YOUR_USERNAME/SpotiFLAC-Mobile.git
|
||||||
cd SpotiFLAC-Mobile
|
cd SpotiFLAC-Mobile
|
||||||
git remote add upstream https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Install the pinned Flutter SDK and Dart dependencies:
|
2. **Add upstream remote**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
fvm install
|
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
|
||||||
fvm flutter pub get
|
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Build the Go backend for Android. `ANDROID_NDK_HOME` must point to the NDK
|
3. **Use FVM (Flutter Version: 3.38.1)**
|
||||||
version used by CI and `CGO_ENABLED` must be enabled.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd go_backend
|
fvm use
|
||||||
go mod download
|
```
|
||||||
go install golang.org/x/mobile/cmd/gomobile
|
|
||||||
gomobile init
|
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.7)**
|
||||||
|
```bash
|
||||||
|
cd go_backend
|
||||||
mkdir -p ../android/app/libs
|
mkdir -p ../android/app/libs
|
||||||
gomobile bind \
|
gomobile init
|
||||||
-target=android/arm,android/arm64 \
|
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
|
||||||
-androidapi 24 \
|
|
||||||
-o ../android/app/libs/gobackend.aar \
|
|
||||||
.
|
|
||||||
cd ..
|
cd ..
|
||||||
```
|
```
|
||||||
|
|
||||||
Running `go install` from `go_backend/` uses the `x/mobile` version pinned by
|
7. **Run the app**
|
||||||
`go.mod`. Do not replace it with `@latest` in project scripts.
|
|
||||||
|
|
||||||
4. Run the app:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
fvm flutter run
|
flutter run
|
||||||
```
|
```
|
||||||
|
|
||||||
For iOS, run `scripts/build_ios.sh` on macOS before opening
|
### Building
|
||||||
`ios/Runner.xcworkspace`.
|
|
||||||
|
|
||||||
## Project Boundaries
|
|
||||||
|
|
||||||
```text
|
|
||||||
lib/ Flutter UI, state, models, and platform orchestration
|
|
||||||
go_backend/ Download pipeline, extension runtime, and shared backend logic
|
|
||||||
android/ Android platform bridge and foreground worker
|
|
||||||
ios/ iOS platform bridge and application project
|
|
||||||
test/ Flutter unit and widget tests
|
|
||||||
assets/ Images, fonts, and bundled resources
|
|
||||||
docs/ Contributor-facing technical contracts
|
|
||||||
scripts/ Reproducible project build helpers
|
|
||||||
```
|
|
||||||
|
|
||||||
SpotiFLAC Mobile is extension-driven. Extension-specific behavior must be
|
|
||||||
declared through a generic manifest field, capability, or reusable app API.
|
|
||||||
Do not add provider-name checks such as `if source == 'provider-name'` to the
|
|
||||||
main app. The Go backend should parse and expose the generic declaration, and
|
|
||||||
Dart should consume that declaration without knowing which extension uses it.
|
|
||||||
|
|
||||||
## Generated Files
|
|
||||||
|
|
||||||
- After changing ARB files, run `fvm flutter gen-l10n` and commit the resulting
|
|
||||||
localization sources.
|
|
||||||
- Run `fvm dart run build_runner build --delete-conflicting-outputs` only when a
|
|
||||||
model or generator input changes, then commit the relevant generated source.
|
|
||||||
- Do not commit `build/`, `.dart_tool/`, AAR/XCFramework output, IDE state, or
|
|
||||||
local research directories.
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
Run checks that cover the code you changed. Before opening a PR, the relevant
|
|
||||||
commands should pass.
|
|
||||||
|
|
||||||
Flutter and Dart:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
fvm dart format --output=none --set-exit-if-changed lib test
|
# Debug build
|
||||||
fvm flutter analyze
|
flutter build apk --debug
|
||||||
fvm flutter test
|
|
||||||
|
# Release build
|
||||||
|
flutter build apk --release
|
||||||
```
|
```
|
||||||
|
|
||||||
Go backend:
|
## 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
|
```bash
|
||||||
cd go_backend
|
dart format .
|
||||||
gofmt -w .
|
|
||||||
go vet ./...
|
|
||||||
go test ./...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Android native code, after building `gobackend.aar`:
|
### Linting
|
||||||
|
|
||||||
|
Ensure your code passes all lints:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd android
|
flutter analyze
|
||||||
./gradlew :app:compileDebugKotlin :app:testDebugUnitTest
|
|
||||||
```
|
```
|
||||||
|
|
||||||
For user-facing changes, add or update tests where practical and include
|
### State Management
|
||||||
before/after screenshots for UI changes.
|
|
||||||
|
|
||||||
## Code and Commit Style
|
We use **Riverpod** for state management. Follow these patterns:
|
||||||
|
|
||||||
- Follow `analysis_options.yaml`, `.editorconfig`, and existing module patterns.
|
```dart
|
||||||
- Keep user-facing strings in the localization files.
|
// Use code generation with riverpod_annotation
|
||||||
- Prefer small functions and explicit error handling at platform boundaries.
|
@riverpod
|
||||||
- Use [Conventional Commits](https://www.conventionalcommits.org/), for example:
|
class MyNotifier extends _$MyNotifier {
|
||||||
|
@override
|
||||||
|
MyState build() => MyState();
|
||||||
|
|
||||||
|
// Methods to update state
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
```text
|
### Localization
|
||||||
feat(download): add batch selection
|
|
||||||
fix(storage): handle revoked folder access
|
|
||||||
docs(contributing): refresh Android setup
|
|
||||||
```
|
|
||||||
|
|
||||||
## Pull Requests
|
All user-facing strings should be localized:
|
||||||
|
|
||||||
1. Create a branch from an up-to-date `main`.
|
```dart
|
||||||
2. Make one focused change and include tests or verification evidence.
|
// Good
|
||||||
3. Complete the pull request template, including any checks that were not run
|
Text(AppLocalizations.of(context)!.downloadComplete)
|
||||||
and why.
|
|
||||||
4. Link related issues with `Fixes #123` where appropriate.
|
|
||||||
5. Respond to review feedback with follow-up commits; maintainers may squash
|
|
||||||
commits when merging.
|
|
||||||
|
|
||||||
When reporting a crash, include the SpotiFLAC Mobile version, release channel,
|
// Bad
|
||||||
device/OS, exact reproduction steps, storage mode, and exported app logs. For a
|
Text('Download Complete')
|
||||||
cold-start Android crash, `adb logcat -b crash -d` is especially useful.
|
```
|
||||||
|
|
||||||
|
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/dev
|
||||||
|
```
|
||||||
|
|
||||||
|
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 `dev` 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
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
If you have questions, feel free to:
|
||||||
|
|
||||||
|
- Open a [Discussion](https://github.com/zarzet/SpotiFLAC-Mobile/discussions)
|
||||||
|
- Check existing [Issues](https://github.com/zarzet/SpotiFLAC-Mobile/issues)
|
||||||
|
|
||||||
|
Thank you for contributing! 💚
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="assets/readme/banner-readme-dark.png">
|
<source media="(prefers-color-scheme: dark)" srcset="assets/images/banner-readme-dark.png">
|
||||||
<source media="(prefers-color-scheme: light)" srcset="assets/readme/banner-readme-light.png">
|
<source media="(prefers-color-scheme: light)" srcset="assets/images/banner-readme-light.png">
|
||||||
<img alt="SpotiFLAC Mobile" src="assets/readme/banner-readme-light.png" width="650" height="auto">
|
<img alt="SpotiFLAC Mobile" src="assets/images/banner-readme-light.png" width="650" height="auto">
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://trendshift.io/repositories/25971" target="_blank">
|
<a href="https://trendshift.io/repositories/17247">
|
||||||
<img src="https://trendshift.io/api/badge/repositories/25971" alt="spotiflacapp%2FSpotiFLAC-Mobile | Trendshift" width="250" height="55">
|
<img src="https://trendshift.io/api/badge/repositories/17247" alt="zarzet%2FSpotiFLAC-Mobile | Trendshift" width="250" height="55">
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -16,7 +16,8 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[](https://github.com/spotiflacapp/SpotiFLAC-Mobile/releases)
|
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
|
[](https://www.virustotal.com/gui/file/31d1bf3c3b2015c13e83c4f909a7c6093a9423e3e702f0c582a3e0035c849424)
|
||||||
[](https://crowdin.com/project/spotiflac-mobile)
|
[](https://crowdin.com/project/spotiflac-mobile)
|
||||||
|
|
||||||
[](https://t.me/spotiflac)
|
[](https://t.me/spotiflac)
|
||||||
@@ -27,10 +28,10 @@
|
|||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="assets/readme/1.jpg?v=2" width="200" />
|
<img src="assets/images/1.jpg?v=2" width="200" />
|
||||||
<img src="assets/readme/2.jpg?v=2" width="200" />
|
<img src="assets/images/2.jpg?v=2" width="200" />
|
||||||
<img src="assets/readme/3.jpg?v=2" width="200" />
|
<img src="assets/images/3.jpg?v=2" width="200" />
|
||||||
<img src="assets/readme/4.jpg?v=2" width="200" />
|
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -51,29 +52,14 @@ Extensions let the community add new music sources and features without waiting
|
|||||||
### Developing Extensions
|
### Developing Extensions
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Want to build your own extension? Start with the
|
> Want to build your own extension? The [Extension Development Guide](https://zarzet.github.io/SpotiFLAC-Mobile/docs) has everything you need.
|
||||||
> [Extension Development Guide](docs/EXTENSION_DEVELOPMENT.md). The
|
|
||||||
> [documentation site](https://spotiflac.zarz.moe/docs) contains the expanded
|
|
||||||
> API reference.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
SpotiFLAC Mobile combines a Flutter/Dart UI, a Go backend compiled with
|
|
||||||
`gomobile`, and thin Android/iOS platform bridges. Toolchain versions are
|
|
||||||
pinned in [`.fvmrc`](.fvmrc), [`go_backend/go.mod`](go_backend/go.mod), and the
|
|
||||||
GitHub Actions workflows.
|
|
||||||
|
|
||||||
Start with the [Contributing Guide](CONTRIBUTING.md) for the development setup,
|
|
||||||
project boundaries, validation commands, and pull request checklist.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Related Projects
|
## Related Projects
|
||||||
|
|
||||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||||
Download music in true lossless FLAC from extension-provided sources on Windows, macOS & Linux.
|
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music available for Windows, macOS & Linux.
|
||||||
|
|
||||||
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
|
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
|
||||||
Python library for SpotiFLAC integration, maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu).
|
Python library for SpotiFLAC integration, maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu).
|
||||||
@@ -86,10 +72,7 @@ Python library for SpotiFLAC integration, maintained by [@ShuShuzinhuu](https://
|
|||||||
<summary><b>Why does the Store tab ask me to enter a URL?</b></summary>
|
<summary><b>Why does the Store tab ask me to enter a URL?</b></summary>
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
Starting from version 3.8.0, SpotiFLAC Mobile uses a decentralized extension
|
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.
|
||||||
repository system. Extensions are hosted independently rather than on a
|
|
||||||
built-in server, so anyone can create and host a compatible repository. Enter
|
|
||||||
a repository URL in the Store tab to browse and install extensions.
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -97,7 +80,7 @@ a repository URL in the Store tab to browse and install extensions.
|
|||||||
<summary><b>Why is my download failing with "Song not found"?</b></summary>
|
<summary><b>Why is my download failing with "Song not found"?</b></summary>
|
||||||
<br>
|
<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.
|
The track may not be available on the streaming services. Try enabling more providers under **Settings > Download > Provider Priority**, or install additional extensions like Amazon Music from the Store.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -105,7 +88,10 @@ The track may not be available from your enabled providers. Try enabling more pr
|
|||||||
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
|
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
|
||||||
<br>
|
<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.
|
Quality depends on what's available from the streaming service and its extensions. Built-in providers:
|
||||||
|
- **Tidal** up to 24-bit/192kHz
|
||||||
|
- **Qobuz** up to 24-bit/192kHz
|
||||||
|
- **Deezer** up to 16-bit/44.1kHz
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -121,9 +107,7 @@ Yes! Just paste the playlist URL in the search bar. The app will fetch all track
|
|||||||
<summary><b>Why do I need to grant storage permission?</b></summary>
|
<summary><b>Why do I need to grant storage permission?</b></summary>
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
The app needs a writable destination for downloaded files. On Android, choose a
|
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**.
|
||||||
folder with the system folder picker (SAF), or use the app-specific folder. If
|
|
||||||
Android revokes a saved folder grant, select the folder again in Settings.
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -131,8 +115,7 @@ Android revokes a saved folder grant, select the folder again in Settings.
|
|||||||
<summary><b>Is this app safe?</b></summary>
|
<summary><b>Is this app safe?</b></summary>
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
SpotiFLAC Mobile is open source, so its code and build workflows can be
|
Yes SpotiFLAC is open source and you can verify the code yourself. Each release is also scanned with VirusTotal (see badge above).
|
||||||
inspected directly in this repository.
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -145,13 +128,13 @@ Some countries have restricted access to certain streaming service APIs. If down
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Can I add SpotiFLAC Mobile to AltStore or SideStore?</b></summary>
|
<summary><b>Can I add SpotiFLAC to AltStore or SideStore?</b></summary>
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
Yes! Add the official source to receive updates directly within the app. Copy this link:
|
Yes! Add the official source to receive updates directly within the app. Copy this link:
|
||||||
|
|
||||||
```
|
```
|
||||||
https://raw.githubusercontent.com/spotiflacapp/SpotiFLAC-Mobile/refs/heads/main/apps.json
|
https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/refs/heads/main/apps.json
|
||||||
```
|
```
|
||||||
|
|
||||||
In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link.
|
In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link.
|
||||||
@@ -159,7 +142,7 @@ In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> If SpotiFLAC Mobile is useful to you, consider supporting development:
|
> If SpotiFLAC is useful to you, consider supporting development:
|
||||||
>
|
>
|
||||||
> [](https://ko-fi.com/zarzet)
|
> [](https://ko-fi.com/zarzet)
|
||||||
|
|
||||||
@@ -169,8 +152,8 @@ In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link
|
|||||||
|
|
||||||
Thanks to everyone who has contributed to SpotiFLAC Mobile!
|
Thanks to everyone who has contributed to SpotiFLAC Mobile!
|
||||||
|
|
||||||
<a href="https://github.com/spotiflacapp/SpotiFLAC-Mobile/graphs/contributors">
|
<a href="https://github.com/zarzet/SpotiFLAC-Mobile/graphs/contributors">
|
||||||
<img src="https://contrib.rocks/image?repo=spotiflacapp/SpotiFLAC-Mobile" />
|
<img src="https://contrib.rocks/image?repo=zarzet/SpotiFLAC-Mobile" />
|
||||||
</a>
|
</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.
|
We also appreciate everyone who helped with [translations on Crowdin](https://crowdin.com/project/spotiflac-mobile), reported bugs, suggested features, and spread the word.
|
||||||
@@ -183,8 +166,9 @@ Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md)
|
|||||||
|
|
||||||
| | | | | |
|
| | | | | |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| [MusicDL](https://www.musicdl.me) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) | [Song.link](https://song.link) |
|
| [hifi-api](https://github.com/binimum/hifi-api) | [music.binimum.org](https://music.binimum.org) | [qqdl.site](https://qqdl.site) | [squid.wtf](https://squid.wtf) | [spotisaver.net](https://spotisaver.net) |
|
||||||
| [IDHS](https://github.com/sjdonado/idonthavespotify) | | | | |
|
| [dabmusic.xyz](https://dabmusic.xyz) | [AfkarXYZ](https://github.com/afkarxyz) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) |
|
||||||
|
| [qwkuns.me](https://qwkuns.me) | [SpotubeDL](https://spotubedl.com) | [Song.link](https://song.link) | [IDHS](https://github.com/sjdonado/idonthavespotify) | |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,6 @@
|
|||||||
# packages, and plugins designed to encourage good coding practices.
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
include: package:flutter_lints/flutter.yaml
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
plugins:
|
|
||||||
riverpod_lint: 3.1.4-dev.3
|
|
||||||
|
|
||||||
analyzer:
|
analyzer:
|
||||||
exclude:
|
exclude:
|
||||||
- build/**
|
- build/**
|
||||||
@@ -22,6 +19,9 @@ analyzer:
|
|||||||
strict-casts: true
|
strict-casts: true
|
||||||
strict-inference: true
|
strict-inference: true
|
||||||
strict-raw-types: true
|
strict-raw-types: true
|
||||||
|
plugins:
|
||||||
|
- custom_lint
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
# The lint rules applied to this project can be customized in the
|
# The lint rules applied to this project can be customized in the
|
||||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||||
@@ -36,16 +36,13 @@ linter:
|
|||||||
rules:
|
rules:
|
||||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||||
always_declare_return_types: true
|
|
||||||
avoid_dynamic_calls: true
|
avoid_dynamic_calls: true
|
||||||
avoid_types_as_parameter_names: true
|
|
||||||
strict_top_level_inference: true
|
|
||||||
type_annotate_public_apis: true
|
|
||||||
cancel_subscriptions: true
|
cancel_subscriptions: true
|
||||||
close_sinks: true
|
close_sinks: true
|
||||||
# Catches dead cross-layer chains (Dart wrapper kept alive only by its own
|
|
||||||
# declaration) before they accumulate into another dedup campaign.
|
custom_lint:
|
||||||
unreachable_from_main: true
|
rules:
|
||||||
|
- avoid_public_notifier_properties
|
||||||
|
|
||||||
# Additional information about this file can be found at
|
# Additional information about this file can be found at
|
||||||
# https://dart.dev/guides/language/analysis-options
|
# https://dart.dev/guides/language/analysis-options
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -17,22 +17,18 @@ if (keystorePropertiesFile.exists()) {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.zarz.spotiflac"
|
namespace = "com.zarz.spotiflac"
|
||||||
compileSdk = 37
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
buildFeatures {
|
|
||||||
buildConfig = true
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
isCoreLibraryDesugaringEnabled = true
|
isCoreLibraryDesugaringEnabled = true
|
||||||
sourceCompatibility = JavaVersion.VERSION_25
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_25
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +46,7 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.zarz.spotiflac"
|
applicationId = "com.zarz.spotiflac"
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = 37
|
targetSdk = 36
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
multiDexEnabled = true
|
multiDexEnabled = true
|
||||||
@@ -62,8 +58,6 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
getByName("debug") {
|
getByName("debug") {
|
||||||
applicationIdSuffix = ".debug"
|
|
||||||
versionNameSuffix = "-debug"
|
|
||||||
ndk {
|
ndk {
|
||||||
debugSymbolLevel = "FULL"
|
debugSymbolLevel = "FULL"
|
||||||
}
|
}
|
||||||
@@ -122,14 +116,8 @@ dependencies {
|
|||||||
// Include all AAR and JAR files from libs folder
|
// Include all AAR and JAR files from libs folder
|
||||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
||||||
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||||
implementation("androidx.documentfile:documentfile:1.1.0")
|
implementation("androidx.documentfile:documentfile:1.1.0")
|
||||||
implementation("androidx.activity:activity-ktx:1.13.0")
|
implementation("androidx.activity:activity-ktx:1.12.3")
|
||||||
|
|
||||||
// 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")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<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"
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="29" />
|
android:maxSdkVersion="29" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
@@ -19,12 +18,9 @@
|
|||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="SpotiFLAC Mobile"
|
android:label="SpotiFLAC"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:allowBackup="false"
|
|
||||||
android:fullBackupContent="@xml/backup_rules_legacy"
|
|
||||||
android:dataExtractionRules="@xml/backup_rules"
|
|
||||||
android:usesCleartextTraffic="false"
|
android:usesCleartextTraffic="false"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:enableOnBackInvokedCallback="true"
|
android:enableOnBackInvokedCallback="true"
|
||||||
@@ -90,26 +86,6 @@
|
|||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="https" android:host="music.youtube.com" />
|
<data android:scheme="https" android:host="music.youtube.com" />
|
||||||
</intent-filter>
|
</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>
|
</activity>
|
||||||
|
|
||||||
<!-- Download Service -->
|
<!-- Download Service -->
|
||||||
@@ -118,34 +94,6 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="dataSync" />
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
<receiver
|
|
||||||
android:name=".DownloadQueueWidgetProvider"
|
|
||||||
android:exported="false">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
|
||||||
</intent-filter>
|
|
||||||
<meta-data
|
|
||||||
android:name="android.appwidget.provider"
|
|
||||||
android:resource="@xml/widget_download_queue_info" />
|
|
||||||
</receiver>
|
|
||||||
|
|
||||||
<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 -->
|
<!-- flutter_local_notifications receivers -->
|
||||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
|
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
|
||||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
|
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
|
||||||
@@ -162,10 +110,6 @@
|
|||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|
||||||
<meta-data
|
|
||||||
android:name="com.google.android.gms.car.application"
|
|
||||||
android:resource="@xml/automotive_app_desc" />
|
|
||||||
|
|
||||||
<!-- FileProvider for APK installation -->
|
<!-- FileProvider for APK installation -->
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
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,131 +0,0 @@
|
|||||||
package com.zarz.spotiflac
|
|
||||||
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.appwidget.AppWidgetManager
|
|
||||||
import android.appwidget.AppWidgetProvider
|
|
||||||
import android.content.ComponentName
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.view.View
|
|
||||||
import android.widget.RemoteViews
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Home-screen widget showing the active download. Updated only on discrete
|
|
||||||
* events (track transition, status change, coarse progress steps) from
|
|
||||||
* DownloadService — never per progress byte — to respect battery discipline.
|
|
||||||
* The last pushed state is persisted so launcher-driven refreshes (reboot,
|
|
||||||
* resize) render without the service running.
|
|
||||||
*/
|
|
||||||
class DownloadQueueWidgetProvider : AppWidgetProvider() {
|
|
||||||
|
|
||||||
override fun onEnabled(context: Context) {
|
|
||||||
widgetPreferences(context).edit().putBoolean(KEY_ENABLED, true).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDisabled(context: Context) {
|
|
||||||
widgetPreferences(context).edit().putBoolean(KEY_ENABLED, false).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onUpdate(
|
|
||||||
context: Context,
|
|
||||||
appWidgetManager: AppWidgetManager,
|
|
||||||
appWidgetIds: IntArray,
|
|
||||||
) {
|
|
||||||
if (appWidgetIds.isNotEmpty()) {
|
|
||||||
widgetPreferences(context).edit().putBoolean(KEY_ENABLED, true).apply()
|
|
||||||
}
|
|
||||||
render(context, appWidgetManager, appWidgetIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val PREFS = "download_widget_state"
|
|
||||||
private const val KEY_ENABLED = "enabled"
|
|
||||||
private const val KEY_RUNNING = "running"
|
|
||||||
private const val KEY_TITLE = "title"
|
|
||||||
private const val KEY_SUBTITLE = "subtitle"
|
|
||||||
private const val KEY_PERCENT = "percent" // -1 = indeterminate
|
|
||||||
|
|
||||||
/** Persists the state and re-renders all widget instances. */
|
|
||||||
fun push(
|
|
||||||
context: Context,
|
|
||||||
running: Boolean,
|
|
||||||
title: String = "",
|
|
||||||
subtitle: String = "",
|
|
||||||
percent: Int = -1,
|
|
||||||
) {
|
|
||||||
val prefs = widgetPreferences(context)
|
|
||||||
// Most users never add the optional launcher widget. Keep the
|
|
||||||
// download hot path free of AppWidget IPC and preference writes
|
|
||||||
// for those users.
|
|
||||||
if (!prefs.getBoolean(KEY_ENABLED, false)) return
|
|
||||||
|
|
||||||
val manager = AppWidgetManager.getInstance(context)
|
|
||||||
val ids = manager.getAppWidgetIds(
|
|
||||||
ComponentName(context, DownloadQueueWidgetProvider::class.java)
|
|
||||||
)
|
|
||||||
if (ids.isEmpty()) {
|
|
||||||
prefs.edit().putBoolean(KEY_ENABLED, false).apply()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
prefs.edit()
|
|
||||||
.putBoolean(KEY_RUNNING, running)
|
|
||||||
.putString(KEY_TITLE, title)
|
|
||||||
.putString(KEY_SUBTITLE, subtitle)
|
|
||||||
.putInt(KEY_PERCENT, percent)
|
|
||||||
.apply()
|
|
||||||
render(context, manager, ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun widgetPreferences(context: Context) =
|
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
private fun render(
|
|
||||||
context: Context,
|
|
||||||
manager: AppWidgetManager,
|
|
||||||
ids: IntArray,
|
|
||||||
) {
|
|
||||||
val prefs = widgetPreferences(context)
|
|
||||||
val running = prefs.getBoolean(KEY_RUNNING, false)
|
|
||||||
val views = RemoteViews(context.packageName, R.layout.widget_download_queue)
|
|
||||||
|
|
||||||
if (running) {
|
|
||||||
views.setTextViewText(
|
|
||||||
R.id.widget_title,
|
|
||||||
prefs.getString(KEY_TITLE, "").orEmpty().ifEmpty { "Downloading..." }
|
|
||||||
)
|
|
||||||
views.setTextViewText(
|
|
||||||
R.id.widget_subtitle,
|
|
||||||
prefs.getString(KEY_SUBTITLE, "").orEmpty()
|
|
||||||
)
|
|
||||||
views.setViewVisibility(R.id.widget_progress, View.VISIBLE)
|
|
||||||
val percent = prefs.getInt(KEY_PERCENT, -1)
|
|
||||||
if (percent in 0..100) {
|
|
||||||
views.setProgressBar(R.id.widget_progress, 100, percent, false)
|
|
||||||
} else {
|
|
||||||
views.setProgressBar(R.id.widget_progress, 100, 0, true)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
views.setTextViewText(R.id.widget_title, "SpotiFLAC")
|
|
||||||
views.setTextViewText(R.id.widget_subtitle, "No active downloads")
|
|
||||||
views.setViewVisibility(R.id.widget_progress, View.GONE)
|
|
||||||
}
|
|
||||||
|
|
||||||
val launchIntent = Intent(context, MainActivity::class.java).apply {
|
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
|
||||||
}
|
|
||||||
views.setOnClickPendingIntent(
|
|
||||||
R.id.widget_root,
|
|
||||||
PendingIntent.getActivity(
|
|
||||||
context,
|
|
||||||
0,
|
|
||||||
launchIntent,
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
for (id in ids) {
|
|
||||||
manager.updateAppWidget(id, views)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
package com.zarz.spotiflac
|
|
||||||
|
|
||||||
import android.app.Notification
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.app.Service
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.ServiceInfo
|
|
||||||
import android.net.ConnectivityManager
|
|
||||||
import android.net.Network
|
|
||||||
import android.net.NetworkCapabilities
|
|
||||||
import android.net.NetworkRequest
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.IBinder
|
|
||||||
import android.os.PowerManager
|
|
||||||
import android.util.AtomicFile
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import gobackend.Gobackend
|
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
import java.io.File
|
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
|
||||||
|
|
||||||
// Wi-Fi-only download policy: network callback, pause and resume.
|
|
||||||
|
|
||||||
internal fun DownloadService.configureNativeWorkerNetworkPolicy(settingsJson: String) {
|
|
||||||
nativeWorkerDownloadNetworkMode = try {
|
|
||||||
JSONObject(settingsJson).optString("download_network_mode", "any")
|
|
||||||
} catch (_: Exception) {
|
|
||||||
"any"
|
|
||||||
}
|
|
||||||
|
|
||||||
unregisterNativeWorkerNetworkCallback()
|
|
||||||
if (!NativeWorkerPolicy.requiresWifi(nativeWorkerDownloadNetworkMode)) {
|
|
||||||
nativeWorkerNetworkPaused = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
nativeWorkerNetworkPaused = !hasUsableWifiConnection()
|
|
||||||
val connectivityManager =
|
|
||||||
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
|
||||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
|
||||||
override fun onAvailable(network: Network) {
|
|
||||||
synchronized(nativeWorkerWifiNetworks) {
|
|
||||||
nativeWorkerWifiNetworks.add(network)
|
|
||||||
}
|
|
||||||
refreshNativeWorkerNetworkPause()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLost(network: Network) {
|
|
||||||
synchronized(nativeWorkerWifiNetworks) {
|
|
||||||
nativeWorkerWifiNetworks.remove(network)
|
|
||||||
}
|
|
||||||
refreshNativeWorkerNetworkPause()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCapabilitiesChanged(
|
|
||||||
network: Network,
|
|
||||||
networkCapabilities: NetworkCapabilities,
|
|
||||||
) {
|
|
||||||
synchronized(nativeWorkerWifiNetworks) {
|
|
||||||
if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
|
|
||||||
networkCapabilities.hasCapability(
|
|
||||||
NetworkCapabilities.NET_CAPABILITY_INTERNET,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
nativeWorkerWifiNetworks.add(network)
|
|
||||||
} else {
|
|
||||||
nativeWorkerWifiNetworks.remove(network)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
refreshNativeWorkerNetworkPause()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
val request = NetworkRequest.Builder()
|
|
||||||
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
|
|
||||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
|
||||||
.build()
|
|
||||||
connectivityManager.registerNetworkCallback(request, callback)
|
|
||||||
nativeWorkerNetworkCallback = callback
|
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.w(
|
|
||||||
"DownloadService",
|
|
||||||
"Failed to monitor Wi-Fi for native worker: ${e.message}",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun DownloadService.hasUsableWifiConnection(): Boolean {
|
|
||||||
val connectivityManager =
|
|
||||||
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
|
||||||
if (synchronized(nativeWorkerWifiNetworks) {
|
|
||||||
nativeWorkerWifiNetworks.isNotEmpty()
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return try {
|
|
||||||
val activeNetwork = connectivityManager.activeNetwork ?: return false
|
|
||||||
val capabilities =
|
|
||||||
connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false
|
|
||||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
|
|
||||||
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun DownloadService.refreshNativeWorkerNetworkPause() {
|
|
||||||
if (!NativeWorkerPolicy.requiresWifi(nativeWorkerDownloadNetworkMode)) return
|
|
||||||
|
|
||||||
val shouldPause = NativeWorkerPolicy.shouldPauseForNetwork(
|
|
||||||
nativeWorkerDownloadNetworkMode,
|
|
||||||
hasUsableWifiConnection(),
|
|
||||||
)
|
|
||||||
if (shouldPause == nativeWorkerNetworkPaused) return
|
|
||||||
|
|
||||||
nativeWorkerNetworkPaused = shouldPause
|
|
||||||
if (nativeWorkerJob?.isActive != true) return
|
|
||||||
|
|
||||||
if (shouldPause) {
|
|
||||||
currentStatus = if (nativeWorkerVerificationPaused) {
|
|
||||||
"verification_required"
|
|
||||||
} else {
|
|
||||||
"waiting_wifi"
|
|
||||||
}
|
|
||||||
cancelActiveNativeItemForPause()
|
|
||||||
updateNotification(0L, 0L)
|
|
||||||
} else {
|
|
||||||
currentStatus = if (nativeWorkerVerificationPaused) {
|
|
||||||
"verification_required"
|
|
||||||
} else {
|
|
||||||
"preparing"
|
|
||||||
}
|
|
||||||
updateNotification(0L, 0L)
|
|
||||||
}
|
|
||||||
writeNativeWorkerSnapshotAsync(
|
|
||||||
isRunning = nativeWorkerJob?.isActive == true,
|
|
||||||
isPaused = isNativeWorkerPaused(),
|
|
||||||
currentItemId = "",
|
|
||||||
message = if (isNativeWorkerPaused()) {
|
|
||||||
nativeWorkerPauseMessage()
|
|
||||||
} else {
|
|
||||||
"Wi-Fi restored"
|
|
||||||
},
|
|
||||||
includeItems = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun DownloadService.unregisterNativeWorkerNetworkCallback() {
|
|
||||||
val callback = nativeWorkerNetworkCallback
|
|
||||||
nativeWorkerNetworkCallback = null
|
|
||||||
synchronized(nativeWorkerWifiNetworks) {
|
|
||||||
nativeWorkerWifiNetworks.clear()
|
|
||||||
}
|
|
||||||
if (callback == null) return
|
|
||||||
try {
|
|
||||||
val connectivityManager =
|
|
||||||
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
|
||||||
connectivityManager.unregisterNetworkCallback(callback)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
package com.zarz.spotiflac
|
|
||||||
|
|
||||||
import android.app.Notification
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.app.Service
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.ServiceInfo
|
|
||||||
import android.net.ConnectivityManager
|
|
||||||
import android.net.Network
|
|
||||||
import android.net.NetworkCapabilities
|
|
||||||
import android.net.NetworkRequest
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.IBinder
|
|
||||||
import android.os.PowerManager
|
|
||||||
import android.util.AtomicFile
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import gobackend.Gobackend
|
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
import java.io.File
|
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
|
||||||
|
|
||||||
// Album ReplayGain accumulation and journal persistence for the native worker.
|
|
||||||
|
|
||||||
internal fun DownloadService.writeNativeAlbumReplayGainIfComplete(): Boolean {
|
|
||||||
val entries = synchronized(nativeReplayGainEntries) {
|
|
||||||
nativeReplayGainEntries.map { JSONObject(it.toString()) }
|
|
||||||
}
|
|
||||||
if (entries.size <= 1) return true
|
|
||||||
|
|
||||||
val statuses = synchronized(nativeWorkerItems) {
|
|
||||||
nativeWorkerItems.associate { it.itemId to it.status }
|
|
||||||
}
|
|
||||||
val requestKeys = synchronized(nativeReplayGainRequestAlbumKeys) {
|
|
||||||
nativeReplayGainRequestAlbumKeys.toMap()
|
|
||||||
}
|
|
||||||
val eligible = buildEligibleNativeAlbumReplayGain(entries, statuses, requestKeys)
|
|
||||||
if (eligible.length() <= 1) {
|
|
||||||
return !hasPendingNativeAlbumReplayGainWork(statuses)
|
|
||||||
}
|
|
||||||
return writeNativeAlbumReplayGainEntries(eligible)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun DownloadService.buildEligibleNativeAlbumReplayGain(
|
|
||||||
entries: List<JSONObject>,
|
|
||||||
statuses: Map<String, String>,
|
|
||||||
requestKeys: Map<String, String>
|
|
||||||
): JSONArray {
|
|
||||||
val eligibleIndexes = NativeReplayGainPolicy.eligibleEntryIndexes(
|
|
||||||
entryAlbumKeys = entries.map { it.optString("album_key", "") },
|
|
||||||
statuses = statuses,
|
|
||||||
requestAlbumKeys = requestKeys,
|
|
||||||
)
|
|
||||||
val eligible = JSONArray()
|
|
||||||
for (index in eligibleIndexes) {
|
|
||||||
eligible.put(entries[index])
|
|
||||||
}
|
|
||||||
return eligible
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun DownloadService.writeNativeAlbumReplayGainEntries(eligible: JSONArray): Boolean {
|
|
||||||
if (eligible.length() <= 1) return true
|
|
||||||
try {
|
|
||||||
val result = JSONObject(NativeDownloadFinalizer.writeAlbumReplayGain(this, eligible.toString()))
|
|
||||||
return result.optBoolean("success", false)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.w("DownloadService", "Native album ReplayGain failed: ${e.message}")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun DownloadService.hasPendingNativeAlbumReplayGainWork(statuses: Map<String, String>): Boolean {
|
|
||||||
return NativeReplayGainPolicy.hasPendingWork(statuses)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun DownloadService.writeNativeReplayGainJournal() {
|
|
||||||
val requestKeys = synchronized(nativeReplayGainRequestAlbumKeys) {
|
|
||||||
nativeReplayGainRequestAlbumKeys.toMap()
|
|
||||||
}
|
|
||||||
if (requestKeys.isEmpty()) return
|
|
||||||
|
|
||||||
val entries = synchronized(nativeReplayGainEntries) {
|
|
||||||
nativeReplayGainEntries.map { JSONObject(it.toString()) }
|
|
||||||
}
|
|
||||||
val statuses = synchronized(nativeWorkerItems) {
|
|
||||||
nativeWorkerItems.associate { it.itemId to it.status }
|
|
||||||
}
|
|
||||||
synchronized(DownloadService.NATIVE_REPLAYGAIN_JOURNAL_FILE_LOCK) {
|
|
||||||
val file = AtomicFile(File(filesDir, DownloadService.NATIVE_REPLAYGAIN_JOURNAL_FILE))
|
|
||||||
val existing = readNativeReplayGainJournalLocked(file)
|
|
||||||
val mergedEntries = mergeNativeReplayGainJournalEntries(
|
|
||||||
existing?.optJSONArray("entries"),
|
|
||||||
entries,
|
|
||||||
)
|
|
||||||
val mergedRequestKeys = mergeJsonObjectStringMap(
|
|
||||||
existing?.optJSONObject("request_album_keys"),
|
|
||||||
requestKeys,
|
|
||||||
)
|
|
||||||
val mergedStatuses = mergeJsonObjectStringMap(
|
|
||||||
existing?.optJSONObject("statuses"),
|
|
||||||
statuses,
|
|
||||||
)
|
|
||||||
val root = JSONObject()
|
|
||||||
.put("run_id", nativeWorkerRunId)
|
|
||||||
.put("updated_at", System.currentTimeMillis())
|
|
||||||
.put("entries", mergedEntries)
|
|
||||||
.put("request_album_keys", JSONObject(mergedRequestKeys))
|
|
||||||
.put("statuses", JSONObject(mergedStatuses))
|
|
||||||
|
|
||||||
var stream: java.io.FileOutputStream? = null
|
|
||||||
try {
|
|
||||||
stream = file.startWrite()
|
|
||||||
stream.write(root.toString().toByteArray(Charsets.UTF_8))
|
|
||||||
file.finishWrite(stream)
|
|
||||||
stream = null
|
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.w("DownloadService", "Failed to write native ReplayGain journal: ${e.message}")
|
|
||||||
} finally {
|
|
||||||
if (stream != null) {
|
|
||||||
file.failWrite(stream)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun DownloadService.readNativeReplayGainJournalLocked(file: AtomicFile): JSONObject? {
|
|
||||||
return try {
|
|
||||||
if (!file.baseFile.exists()) return null
|
|
||||||
val text = file.openRead().bufferedReader(Charsets.UTF_8).use {
|
|
||||||
it.readText()
|
|
||||||
}
|
|
||||||
JSONObject(text)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.w("DownloadService", "Failed to merge native ReplayGain journal: ${e.message}")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun DownloadService.mergeNativeReplayGainJournalEntries(
|
|
||||||
existingEntries: JSONArray?,
|
|
||||||
currentEntries: List<JSONObject>
|
|
||||||
): JSONArray {
|
|
||||||
val byKey = linkedMapOf<String, JSONObject>()
|
|
||||||
|
|
||||||
fun add(entry: JSONObject) {
|
|
||||||
val trackId = entry.optString("track_id", "")
|
|
||||||
val path = entry.optString("file_path", "")
|
|
||||||
val key = if (trackId.isNotBlank()) {
|
|
||||||
"track:$trackId"
|
|
||||||
} else {
|
|
||||||
"path:$path"
|
|
||||||
}
|
|
||||||
if (key != "path:") {
|
|
||||||
byKey[key] = JSONObject(entry.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingEntries != null) {
|
|
||||||
for (index in 0 until existingEntries.length()) {
|
|
||||||
existingEntries.optJSONObject(index)?.let(::add)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (entry in currentEntries) add(entry)
|
|
||||||
|
|
||||||
return JSONArray().apply {
|
|
||||||
for (entry in byKey.values) put(entry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun DownloadService.mergeJsonObjectStringMap(
|
|
||||||
existing: JSONObject?,
|
|
||||||
current: Map<String, String>
|
|
||||||
): Map<String, String> {
|
|
||||||
val merged = linkedMapOf<String, String>()
|
|
||||||
if (existing != null) {
|
|
||||||
for (key in existing.keys()) {
|
|
||||||
merged[key] = existing.optString(key, "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for ((key, value) in current) {
|
|
||||||
merged[key] = value
|
|
||||||
}
|
|
||||||
return merged
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun DownloadService.clearNativeReplayGainJournal() {
|
|
||||||
synchronized(DownloadService.NATIVE_REPLAYGAIN_JOURNAL_FILE_LOCK) {
|
|
||||||
try {
|
|
||||||
AtomicFile(File(filesDir, DownloadService.NATIVE_REPLAYGAIN_JOURNAL_FILE)).delete()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun DownloadService.flushNativeAlbumReplayGainJournalIfComplete() {
|
|
||||||
val root = synchronized(DownloadService.NATIVE_REPLAYGAIN_JOURNAL_FILE_LOCK) {
|
|
||||||
try {
|
|
||||||
val file = File(filesDir, DownloadService.NATIVE_REPLAYGAIN_JOURNAL_FILE)
|
|
||||||
if (!file.exists()) return
|
|
||||||
val text = AtomicFile(file).openRead().bufferedReader(Charsets.UTF_8).use {
|
|
||||||
it.readText()
|
|
||||||
}
|
|
||||||
JSONObject(text)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.w("DownloadService", "Failed to read native ReplayGain journal: ${e.message}")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val entriesArray = root.optJSONArray("entries") ?: return
|
|
||||||
val entries = mutableListOf<JSONObject>()
|
|
||||||
for (index in 0 until entriesArray.length()) {
|
|
||||||
entriesArray.optJSONObject(index)?.let { entries.add(JSONObject(it.toString())) }
|
|
||||||
}
|
|
||||||
val statusesJson = root.optJSONObject("statuses") ?: JSONObject()
|
|
||||||
val statuses = mutableMapOf<String, String>()
|
|
||||||
for (key in statusesJson.keys()) {
|
|
||||||
statuses[key] = statusesJson.optString(key, "")
|
|
||||||
}
|
|
||||||
val requestKeysJson = root.optJSONObject("request_album_keys") ?: JSONObject()
|
|
||||||
val requestKeys = mutableMapOf<String, String>()
|
|
||||||
for (key in requestKeysJson.keys()) {
|
|
||||||
requestKeys[key] = requestKeysJson.optString(key, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
val eligible = buildEligibleNativeAlbumReplayGain(entries, statuses, requestKeys)
|
|
||||||
if (eligible.length() <= 1 && hasPendingNativeAlbumReplayGainWork(statuses)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (writeNativeAlbumReplayGainEntries(eligible)) {
|
|
||||||
clearNativeReplayGainJournal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
package com.zarz.spotiflac
|
|
||||||
|
|
||||||
import android.app.Notification
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.app.Service
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.ServiceInfo
|
|
||||||
import android.net.ConnectivityManager
|
|
||||||
import android.net.Network
|
|
||||||
import android.net.NetworkCapabilities
|
|
||||||
import android.net.NetworkRequest
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.IBinder
|
|
||||||
import android.os.PowerManager
|
|
||||||
import android.util.AtomicFile
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import gobackend.Gobackend
|
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
import java.io.File
|
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
|
||||||
|
|
||||||
// Native-worker item state snapshots for the Flutter side.
|
|
||||||
|
|
||||||
internal fun DownloadService.writeNativeWorkerSnapshot(
|
|
||||||
isRunning: Boolean,
|
|
||||||
isPaused: Boolean,
|
|
||||||
currentItemId: String,
|
|
||||||
message: String,
|
|
||||||
lastResult: JSONObject? = null,
|
|
||||||
settingsJson: String = "",
|
|
||||||
includeItems: Boolean = false,
|
|
||||||
snapshotSerial: Long = snapshotWriteSerial.incrementAndGet()
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
synchronized(snapshotWriteLock) {
|
|
||||||
if (includeItems) {
|
|
||||||
if (snapshotSerial < latestCommittedStateSnapshotSerial) return
|
|
||||||
} else {
|
|
||||||
if (snapshotSerial < latestCommittedProgressSnapshotSerial) return
|
|
||||||
}
|
|
||||||
|
|
||||||
val counts = nativeWorkerCounts()
|
|
||||||
val snapshot = JSONObject()
|
|
||||||
.put("contract_version", DownloadService.NATIVE_WORKER_CONTRACT_VERSION)
|
|
||||||
.put("run_id", nativeWorkerRunId.ifBlank { readNativeWorkerRunIdFromSnapshotFile() })
|
|
||||||
.put("is_running", isRunning)
|
|
||||||
.put("is_paused", isPaused)
|
|
||||||
.put("total", counts.total)
|
|
||||||
.put("completed", counts.completed)
|
|
||||||
.put("failed", counts.failed)
|
|
||||||
.put("skipped", counts.skipped)
|
|
||||||
.put("current_item_id", currentItemId)
|
|
||||||
.put("message", message)
|
|
||||||
.put("updated_at", System.currentTimeMillis())
|
|
||||||
.put("snapshot_serial", snapshotSerial)
|
|
||||||
.put("state_serial", if (includeItems) snapshotSerial else latestCommittedStateSnapshotSerial)
|
|
||||||
.put("snapshot_mode", if (includeItems) "compact_items" else "delta")
|
|
||||||
// Snapshot of the header before the per-item payload is
|
|
||||||
// attached; served to pollers that already consumed this
|
|
||||||
// items payload (see getNativeWorkerSnapshot).
|
|
||||||
val headerCandidate = if (includeItems) snapshot.toString() else null
|
|
||||||
snapshot.put("item_ids", nativeWorkerItemIds())
|
|
||||||
if (includeItems) {
|
|
||||||
snapshot.put("items", nativeWorkerItemsSnapshot(includeStatic = false))
|
|
||||||
} else {
|
|
||||||
nativeWorkerItemSnapshot(currentItemId, includeStatic = false)?.let {
|
|
||||||
snapshot.put("item_delta", it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (settingsJson.isNotBlank() && includeItems) {
|
|
||||||
snapshot.put("settings_json", settingsJson)
|
|
||||||
}
|
|
||||||
if (lastResult != null) {
|
|
||||||
snapshot.put("last_result", lastResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized(DownloadService.NATIVE_WORKER_STATE_FILE_LOCK) {
|
|
||||||
val targetFileName = if (includeItems) {
|
|
||||||
DownloadService.NATIVE_WORKER_STATE_FILE
|
|
||||||
} else {
|
|
||||||
DownloadService.NATIVE_WORKER_PROGRESS_FILE
|
|
||||||
}
|
|
||||||
val file = AtomicFile(File(filesDir, targetFileName))
|
|
||||||
var stream: java.io.FileOutputStream? = null
|
|
||||||
try {
|
|
||||||
stream = file.startWrite()
|
|
||||||
stream.write(snapshot.toString().toByteArray(Charsets.UTF_8))
|
|
||||||
file.finishWrite(stream)
|
|
||||||
stream = null
|
|
||||||
if (includeItems) {
|
|
||||||
latestCommittedStateSnapshotSerial = snapshotSerial
|
|
||||||
if (headerCandidate != null) {
|
|
||||||
DownloadService.lastStateHeaderJson = headerCandidate
|
|
||||||
DownloadService.lastStateHeaderSerial = snapshotSerial
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
latestCommittedProgressSnapshotSerial = snapshotSerial
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (stream != null) {
|
|
||||||
file.failWrite(stream)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.w("DownloadService", "Failed to write native worker snapshot: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun DownloadService.writeNativeWorkerSnapshotAsync(
|
|
||||||
isRunning: Boolean,
|
|
||||||
isPaused: Boolean,
|
|
||||||
currentItemId: String,
|
|
||||||
message: String,
|
|
||||||
lastResult: JSONObject? = null,
|
|
||||||
settingsJson: String = "",
|
|
||||||
includeItems: Boolean = false
|
|
||||||
) {
|
|
||||||
val snapshotSerial = snapshotWriteSerial.incrementAndGet()
|
|
||||||
serviceScope.launch {
|
|
||||||
writeNativeWorkerSnapshot(
|
|
||||||
isRunning = isRunning,
|
|
||||||
isPaused = isPaused,
|
|
||||||
currentItemId = currentItemId,
|
|
||||||
message = message,
|
|
||||||
lastResult = lastResult,
|
|
||||||
settingsJson = settingsJson,
|
|
||||||
includeItems = includeItems,
|
|
||||||
snapshotSerial = snapshotSerial
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun DownloadService.readNativeWorkerRunIdFromSnapshotFile(): String {
|
|
||||||
return try {
|
|
||||||
synchronized(DownloadService.NATIVE_WORKER_STATE_FILE_LOCK) {
|
|
||||||
val file = File(filesDir, DownloadService.NATIVE_WORKER_STATE_FILE)
|
|
||||||
if (!file.exists()) {
|
|
||||||
""
|
|
||||||
} else {
|
|
||||||
val text = AtomicFile(file).openRead().bufferedReader(Charsets.UTF_8).use {
|
|
||||||
it.readText()
|
|
||||||
}
|
|
||||||
JSONObject(text).optString("run_id", "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun DownloadService.updateNativeWorkerItem(itemId: String, updater: (DownloadService.NativeWorkerItem) -> Unit) {
|
|
||||||
synchronized(nativeWorkerItems) {
|
|
||||||
nativeWorkerItems.firstOrNull { it.itemId == itemId }?.let(updater)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun DownloadService.updateNativeWorkerItemProgress(itemId: String) {
|
|
||||||
try {
|
|
||||||
val raw = Gobackend.getAllDownloadProgress()
|
|
||||||
val root = JSONObject(raw)
|
|
||||||
val items = root.optJSONObject("items") ?: return
|
|
||||||
val progress = items.optJSONObject(itemId) ?: return
|
|
||||||
val backendStatus = progress.optString("status", "downloading")
|
|
||||||
val bytesReceived = progress.optLong("bytes_received", 0L)
|
|
||||||
val bytesTotal = progress.optLong("bytes_total", 0L)
|
|
||||||
if (backendStatus == "preparing") {
|
|
||||||
currentStatus = "preparing"
|
|
||||||
updateNativeWorkerItem(itemId) {
|
|
||||||
it.status = "preparing"
|
|
||||||
it.progress = 0.0
|
|
||||||
it.bytesReceived = 0L
|
|
||||||
it.bytesTotal = 0L
|
|
||||||
}
|
|
||||||
lastProgress = 0L
|
|
||||||
lastTotal = 0L
|
|
||||||
updateNotification(0L, 0L)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val progressValue = if (bytesTotal > 0L) {
|
|
||||||
bytesReceived.toDouble() / bytesTotal.toDouble()
|
|
||||||
} else {
|
|
||||||
progress.optDouble("progress", 0.0)
|
|
||||||
}.coerceIn(0.0, 1.0)
|
|
||||||
currentStatus = if (backendStatus == "finalizing") {
|
|
||||||
"finalizing"
|
|
||||||
} else {
|
|
||||||
"downloading"
|
|
||||||
}
|
|
||||||
updateNativeWorkerItem(itemId) {
|
|
||||||
it.status = currentStatus
|
|
||||||
it.progress = progressValue
|
|
||||||
it.bytesReceived = bytesReceived
|
|
||||||
it.bytesTotal = bytesTotal
|
|
||||||
}
|
|
||||||
if (bytesTotal > 0L) {
|
|
||||||
lastProgress = bytesReceived
|
|
||||||
lastTotal = bytesTotal
|
|
||||||
updateNotification(bytesReceived, bytesTotal)
|
|
||||||
} else if (progressValue > 0.0) {
|
|
||||||
val percentProgress = (progressValue * DownloadService.NOTIFICATION_PERCENT_TOTAL).toLong()
|
|
||||||
.coerceIn(0L, DownloadService.NOTIFICATION_PERCENT_TOTAL)
|
|
||||||
lastProgress = percentProgress
|
|
||||||
lastTotal = DownloadService.NOTIFICATION_PERCENT_TOTAL
|
|
||||||
updateNotification(percentProgress, DownloadService.NOTIFICATION_PERCENT_TOTAL)
|
|
||||||
} else {
|
|
||||||
lastProgress = 0L
|
|
||||||
lastTotal = 0L
|
|
||||||
updateNotification(0L, 0L)
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun DownloadService.nativeWorkerCounts(): DownloadService.NativeWorkerCounts {
|
|
||||||
var total = 0
|
|
||||||
var completed = 0
|
|
||||||
var failed = 0
|
|
||||||
var skipped = 0
|
|
||||||
synchronized(nativeWorkerItems) {
|
|
||||||
total = nativeWorkerItems.size
|
|
||||||
for (item in nativeWorkerItems) {
|
|
||||||
when (item.status) {
|
|
||||||
"completed" -> completed++
|
|
||||||
"failed" -> failed++
|
|
||||||
"skipped" -> skipped++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return DownloadService.NativeWorkerCounts(
|
|
||||||
total = total,
|
|
||||||
completed = completed,
|
|
||||||
failed = failed,
|
|
||||||
skipped = skipped
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun DownloadService.nativeWorkerItemSnapshot(itemId: String, includeStatic: Boolean): JSONObject? {
|
|
||||||
if (itemId.isBlank()) return null
|
|
||||||
synchronized(nativeWorkerItems) {
|
|
||||||
val item = nativeWorkerItems.firstOrNull { it.itemId == itemId } ?: return null
|
|
||||||
return nativeWorkerItemSnapshotLocked(item, includeStatic)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun DownloadService.nativeWorkerItemIds(): JSONArray {
|
|
||||||
val array = JSONArray()
|
|
||||||
synchronized(nativeWorkerItems) {
|
|
||||||
for (item in nativeWorkerItems) {
|
|
||||||
array.put(item.itemId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return array
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun DownloadService.nativeWorkerItemsSnapshot(includeStatic: Boolean): JSONArray {
|
|
||||||
val array = JSONArray()
|
|
||||||
synchronized(nativeWorkerItems) {
|
|
||||||
for (item in nativeWorkerItems) {
|
|
||||||
array.put(nativeWorkerItemSnapshotLocked(item, includeStatic))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return array
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun DownloadService.nativeWorkerItemSnapshotLocked(item: DownloadService.NativeWorkerItem, includeStatic: Boolean): JSONObject {
|
|
||||||
val json = JSONObject()
|
|
||||||
.put("item_id", item.itemId)
|
|
||||||
.put("status", item.status)
|
|
||||||
.put("progress", item.progress)
|
|
||||||
.put("bytes_received", item.bytesReceived)
|
|
||||||
.put("bytes_total", item.bytesTotal)
|
|
||||||
if (includeStatic) {
|
|
||||||
json.put("track_name", item.trackName)
|
|
||||||
.put("artist_name", item.artistName)
|
|
||||||
.put("item_json", item.itemJson)
|
|
||||||
}
|
|
||||||
if (item.error.isNotBlank()) {
|
|
||||||
json.put("error", item.error)
|
|
||||||
}
|
|
||||||
item.resultJson?.let { json.put("result", it) }
|
|
||||||
return json
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,471 +0,0 @@
|
|||||||
package com.zarz.spotiflac
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.activity.OnBackPressedCallback
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.documentfile.provider.DocumentFile
|
|
||||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
|
||||||
import io.flutter.embedding.android.FlutterActivityLaunchConfigs.BackgroundMode
|
|
||||||
import io.flutter.embedding.android.FlutterFragment
|
|
||||||
import io.flutter.embedding.android.RenderMode
|
|
||||||
import io.flutter.embedding.android.TransparencyMode
|
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
|
||||||
import io.flutter.embedding.engine.FlutterShellArgs
|
|
||||||
import io.flutter.plugin.common.EventChannel
|
|
||||||
import io.flutter.plugin.common.MethodChannel
|
|
||||||
import com.ryanheise.audioservice.AudioServicePlugin
|
|
||||||
import gobackend.Gobackend
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.json.JSONTokener
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileInputStream
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
// SAF/MediaStore URI IO helpers: temp copies, writes, sidecars, and the
|
|
||||||
// SAF post-processing pipeline.
|
|
||||||
|
|
||||||
internal fun MainActivity.errorJson(message: String): String {
|
|
||||||
val obj = JSONObject()
|
|
||||||
obj.put("success", false)
|
|
||||||
obj.put("error", message)
|
|
||||||
obj.put("message", message)
|
|
||||||
return obj.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect whether a content URI belongs to the MediaStore provider.
|
|
||||||
* Samsung One UI may return MediaStore URIs from SAF tree traversal,
|
|
||||||
* which require READ_MEDIA_AUDIO / READ_EXTERNAL_STORAGE permission
|
|
||||||
* instead of SAF tree permission.
|
|
||||||
*/
|
|
||||||
internal fun MainActivity.isMediaStoreUri(uri: Uri): Boolean {
|
|
||||||
val authority = uri.authority ?: return false
|
|
||||||
return authority == "media" ||
|
|
||||||
authority.startsWith("media.") ||
|
|
||||||
authority.contains("media")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve extension from a MediaStore URI by querying DISPLAY_NAME or MIME_TYPE.
|
|
||||||
*/
|
|
||||||
internal fun MainActivity.resolveMediaStoreExt(uri: Uri, fallbackExt: String?): String {
|
|
||||||
try {
|
|
||||||
contentResolver.query(uri, arrayOf(android.provider.MediaStore.MediaColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
|
|
||||||
if (cursor.moveToFirst()) {
|
|
||||||
val name = cursor.getString(0)?.lowercase(Locale.ROOT) ?: ""
|
|
||||||
val ext = extFromFileName(name)
|
|
||||||
if (ext.isNotBlank()) return ext
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
|
|
||||||
try {
|
|
||||||
val mime = contentResolver.getType(uri)
|
|
||||||
val ext = extFromMimeType(mime)
|
|
||||||
if (ext.isNotBlank()) return ext
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
|
|
||||||
return fallbackExt ?: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun MainActivity.extFromFileName(name: String): String {
|
|
||||||
return when {
|
|
||||||
name.endsWith(".m4a") -> ".m4a"
|
|
||||||
name.endsWith(".mp4") -> ".mp4"
|
|
||||||
name.endsWith(".aac") -> ".aac"
|
|
||||||
name.endsWith(".mp3") -> ".mp3"
|
|
||||||
name.endsWith(".opus") -> ".opus"
|
|
||||||
name.endsWith(".flac") -> ".flac"
|
|
||||||
name.endsWith(".ogg") -> ".ogg"
|
|
||||||
name.endsWith(".wave") -> ".wav"
|
|
||||||
name.endsWith(".wav") -> ".wav"
|
|
||||||
name.endsWith(".aiff") -> ".aiff"
|
|
||||||
name.endsWith(".aifc") -> ".aifc"
|
|
||||||
name.endsWith(".aif") -> ".aif"
|
|
||||||
else -> ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun MainActivity.extFromMimeType(mime: String?): String {
|
|
||||||
return when (mime) {
|
|
||||||
"audio/mp4" -> ".m4a"
|
|
||||||
"audio/aac" -> ".aac"
|
|
||||||
"audio/eac3" -> ".m4a"
|
|
||||||
"audio/ac3" -> ".m4a"
|
|
||||||
"audio/ac4" -> ".m4a"
|
|
||||||
"audio/mpeg" -> ".mp3"
|
|
||||||
"audio/ogg" -> ".opus"
|
|
||||||
"audio/flac" -> ".flac"
|
|
||||||
"audio/wav", "audio/x-wav", "audio/wave", "audio/vnd.wave" -> ".wav"
|
|
||||||
"audio/aiff", "audio/x-aiff" -> ".aiff"
|
|
||||||
else -> ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun MainActivity.copyUriToTemp(uri: Uri, fallbackExt: String? = null): String? {
|
|
||||||
var tempFile: File? = null
|
|
||||||
var success = false
|
|
||||||
|
|
||||||
try {
|
|
||||||
val mime = try { contentResolver.getType(uri) } catch (_: Exception) { null }
|
|
||||||
val nameHint = (
|
|
||||||
try { DocumentFile.fromSingleUri(this, uri)?.name } catch (_: Exception) { null }
|
|
||||||
?: uri.lastPathSegment
|
|
||||||
?: ""
|
|
||||||
).lowercase(Locale.ROOT)
|
|
||||||
val extFromName = extFromFileName(nameHint)
|
|
||||||
val extFromMime = extFromMimeType(mime)
|
|
||||||
val ext = if (extFromName.isNotBlank()) extFromName else if (extFromMime.isNotBlank()) extFromMime else (fallbackExt ?: "")
|
|
||||||
val suffix: String? = if (ext.isNotBlank()) ext else null
|
|
||||||
tempFile = File.createTempFile("saf_", suffix, cacheDir)
|
|
||||||
|
|
||||||
contentResolver.openInputStream(uri)?.use { input ->
|
|
||||||
FileOutputStream(tempFile).use { output ->
|
|
||||||
input.copyTo(output)
|
|
||||||
}
|
|
||||||
} ?: return null
|
|
||||||
|
|
||||||
success = true
|
|
||||||
return tempFile.absolutePath
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
// SAF permission denied - try MediaStore fallback for Samsung One UI
|
|
||||||
// which may return MediaStore URIs from SAF tree traversal
|
|
||||||
if (isMediaStoreUri(uri)) {
|
|
||||||
android.util.Log.d(
|
|
||||||
"SpotiFLAC",
|
|
||||||
"SAF denied for MediaStore URI, trying MediaStore fallback: $uri",
|
|
||||||
)
|
|
||||||
val result = copyMediaStoreUriToTemp(uri, fallbackExt)
|
|
||||||
if (result != null) {
|
|
||||||
success = true
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
android.util.Log.w(
|
|
||||||
"SpotiFLAC",
|
|
||||||
"SAF read denied for $uri: ${e.message}",
|
|
||||||
)
|
|
||||||
return null
|
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.w(
|
|
||||||
"SpotiFLAC",
|
|
||||||
"Failed copying SAF uri $uri to temp: ${e.message}",
|
|
||||||
)
|
|
||||||
return null
|
|
||||||
} finally {
|
|
||||||
if (!success) {
|
|
||||||
try {
|
|
||||||
tempFile?.delete()
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fallback for Samsung One UI: read a MediaStore content URI using
|
|
||||||
* READ_MEDIA_AUDIO / READ_EXTERNAL_STORAGE permission instead of SAF.
|
|
||||||
* This handles the case where SAF tree traversal returns MediaStore URIs
|
|
||||||
* that the SAF document provider cannot access.
|
|
||||||
*/
|
|
||||||
internal fun MainActivity.copyMediaStoreUriToTemp(uri: Uri, fallbackExt: String?): String? {
|
|
||||||
var tempFile: File? = null
|
|
||||||
try {
|
|
||||||
val ext = resolveMediaStoreExt(uri, fallbackExt)
|
|
||||||
val suffix: String? = if (ext.isNotBlank()) ext else null
|
|
||||||
tempFile = File.createTempFile("ms_", suffix, cacheDir)
|
|
||||||
|
|
||||||
contentResolver.openInputStream(uri)?.use { input ->
|
|
||||||
FileOutputStream(tempFile).use { output ->
|
|
||||||
input.copyTo(output)
|
|
||||||
}
|
|
||||||
} ?: run {
|
|
||||||
tempFile.delete()
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
android.util.Log.d(
|
|
||||||
"SpotiFLAC",
|
|
||||||
"MediaStore fallback succeeded for $uri",
|
|
||||||
)
|
|
||||||
return tempFile.absolutePath
|
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.w(
|
|
||||||
"SpotiFLAC",
|
|
||||||
"MediaStore fallback also failed for $uri: ${e.message}",
|
|
||||||
)
|
|
||||||
try { tempFile?.delete() } catch (_: Exception) {}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun MainActivity.buildUriDisplayName(
|
|
||||||
uri: Uri,
|
|
||||||
displayNameHint: String? = null,
|
|
||||||
fallbackExt: String? = null,
|
|
||||||
): String {
|
|
||||||
val explicitName = displayNameHint?.trim().orEmpty()
|
|
||||||
if (explicitName.isNotEmpty()) return explicitName
|
|
||||||
|
|
||||||
val docName = try { DocumentFile.fromSingleUri(this, uri)?.name } catch (_: Exception) { null }
|
|
||||||
val uriName = uri.lastPathSegment
|
|
||||||
val resolvedName = (docName ?: uriName ?: "").trim()
|
|
||||||
if (resolvedName.isNotEmpty()) return resolvedName
|
|
||||||
|
|
||||||
val ext = when {
|
|
||||||
fallbackExt.isNullOrBlank().not() -> fallbackExt
|
|
||||||
isMediaStoreUri(uri) -> resolveMediaStoreExt(uri, fallbackExt)
|
|
||||||
else -> ""
|
|
||||||
}
|
|
||||||
return if (ext.isNullOrBlank()) "audio" else "audio$ext"
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun MainActivity.buildLibraryCoverCacheKey(stablePath: String, lastModified: Long): String {
|
|
||||||
val normalizedPath = stablePath.trim()
|
|
||||||
if (normalizedPath.isEmpty()) return ""
|
|
||||||
return if (lastModified > 0L) "$normalizedPath|$lastModified" else normalizedPath
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun MainActivity.readAudioMetadataFromUri(
|
|
||||||
uri: Uri,
|
|
||||||
displayNameHint: String? = null,
|
|
||||||
fallbackExt: String? = null,
|
|
||||||
coverCacheKey: String = "",
|
|
||||||
): JSONObject? {
|
|
||||||
val displayName = buildUriDisplayName(uri, displayNameHint, fallbackExt)
|
|
||||||
|
|
||||||
// Skip /proc/self/fd/ attempt when known to fail (e.g. Samsung SELinux).
|
|
||||||
if (procSelfFdReadable != false) {
|
|
||||||
try {
|
|
||||||
contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
|
|
||||||
val directPath = "/proc/self/fd/${pfd.fd}"
|
|
||||||
val metadataJson = Gobackend.readAudioMetadataWithHintAndCoverCacheKeyJSON(
|
|
||||||
directPath,
|
|
||||||
displayName,
|
|
||||||
coverCacheKey,
|
|
||||||
)
|
|
||||||
if (metadataJson.isNotBlank()) {
|
|
||||||
val obj = JSONObject(metadataJson)
|
|
||||||
val filenameFallback = obj.optBoolean("metadataFromFilename", false)
|
|
||||||
if (!obj.has("error") && !filenameFallback) {
|
|
||||||
procSelfFdReadable = true
|
|
||||||
return obj
|
|
||||||
}
|
|
||||||
// Go could not read real metadata from the fd path –
|
|
||||||
// remember so we skip the attempt for remaining files.
|
|
||||||
if (procSelfFdReadable == null) {
|
|
||||||
procSelfFdReadable = false
|
|
||||||
android.util.Log.d(
|
|
||||||
"SpotiFLAC",
|
|
||||||
"Direct /proc/self/fd read not usable on this device, " +
|
|
||||||
"using temp-file fallback for remaining files",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (procSelfFdReadable == null) {
|
|
||||||
procSelfFdReadable = false
|
|
||||||
android.util.Log.d(
|
|
||||||
"SpotiFLAC",
|
|
||||||
"Direct /proc/self/fd read not usable on this device, " +
|
|
||||||
"using temp-file fallback for remaining files",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val tempPath = try {
|
|
||||||
copyUriToTemp(uri, fallbackExt)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.w(
|
|
||||||
"SpotiFLAC",
|
|
||||||
"SAF metadata fallback copy failed for $uri: ${e.message}",
|
|
||||||
)
|
|
||||||
null
|
|
||||||
} ?: return null
|
|
||||||
|
|
||||||
try {
|
|
||||||
val metadataJson = Gobackend.readAudioMetadataWithHintAndCoverCacheKeyJSON(
|
|
||||||
tempPath,
|
|
||||||
displayName,
|
|
||||||
coverCacheKey,
|
|
||||||
)
|
|
||||||
if (metadataJson.isBlank()) return null
|
|
||||||
val obj = JSONObject(metadataJson)
|
|
||||||
return if (obj.has("error")) null else obj
|
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.w(
|
|
||||||
"SpotiFLAC",
|
|
||||||
"SAF metadata temp read failed for $uri: ${e.message}",
|
|
||||||
)
|
|
||||||
return null
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
File(tempPath).delete()
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun MainActivity.writeUriFromPath(uri: Uri, srcPath: String): Boolean {
|
|
||||||
val srcFile = File(srcPath)
|
|
||||||
if (!srcFile.exists()) return false
|
|
||||||
contentResolver.openOutputStream(uri, "wt")?.use { output ->
|
|
||||||
FileInputStream(srcFile).use { input ->
|
|
||||||
input.copyTo(output)
|
|
||||||
}
|
|
||||||
} ?: return false
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the parent DocumentFile directory for a SAF document URI.
|
|
||||||
* The child URI must be a tree-based document URI (e.g. from SAF tree scan).
|
|
||||||
* Returns a DocumentFile that supports findFile() for sibling lookup.
|
|
||||||
*/
|
|
||||||
internal fun MainActivity.safParentDir(childUri: Uri): DocumentFile? {
|
|
||||||
try {
|
|
||||||
val docId = android.provider.DocumentsContract.getDocumentId(childUri)
|
|
||||||
if (docId.isNullOrEmpty()) return null
|
|
||||||
val lastSlash = docId.lastIndexOf('/')
|
|
||||||
if (lastSlash <= 0) return null
|
|
||||||
|
|
||||||
val parentDocId = docId.substring(0, lastSlash)
|
|
||||||
val treeDocId = android.provider.DocumentsContract.getTreeDocumentId(childUri)
|
|
||||||
if (treeDocId.isNullOrEmpty()) return null
|
|
||||||
|
|
||||||
val parentUri = android.provider.DocumentsContract.buildDocumentUriUsingTree(
|
|
||||||
childUri, parentDocId
|
|
||||||
)
|
|
||||||
return DocumentFile.fromTreeUri(this, parentUri)
|
|
||||||
?: DocumentFile.fromSingleUri(this, parentUri)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.w("SpotiFLAC", "Failed to get SAF parent dir: ${e.message}")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write a ".lrc" sidecar next to a SAF audio document. The sidecar reuses
|
|
||||||
* the audio file's base name (e.g. "Song.flac" -> "Song.lrc") and is created
|
|
||||||
* in the same parent directory. Used by re-enrich when the user's lyrics
|
|
||||||
* mode requests an external/both sidecar. Best-effort: failures are logged
|
|
||||||
* and swallowed so they never abort the metadata enrichment itself.
|
|
||||||
*/
|
|
||||||
internal fun MainActivity.writeSafSidecarLrc(audioUri: Uri, lrcContent: String): Boolean {
|
|
||||||
if (lrcContent.isBlank()) return false
|
|
||||||
try {
|
|
||||||
val parent = safParentDir(audioUri) ?: run {
|
|
||||||
android.util.Log.w("SpotiFLAC", "LRC sidecar: no SAF parent dir")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
val audioName = try {
|
|
||||||
DocumentFile.fromSingleUri(this, audioUri)?.name
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
} ?: return false
|
|
||||||
val baseName = audioName.substringBeforeLast('.', audioName)
|
|
||||||
val lrcName = "$baseName.lrc"
|
|
||||||
|
|
||||||
val target = SafDownloadHandler.createOrReuseDocumentFile(
|
|
||||||
parent,
|
|
||||||
"application/octet-stream",
|
|
||||||
lrcName
|
|
||||||
) ?: run {
|
|
||||||
android.util.Log.w("SpotiFLAC", "LRC sidecar: failed to create $lrcName")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
contentResolver.openOutputStream(target.uri, "wt")?.use { output ->
|
|
||||||
output.write(lrcContent.toByteArray(Charsets.UTF_8))
|
|
||||||
} ?: return false
|
|
||||||
android.util.Log.d("SpotiFLAC", "LRC sidecar written: $lrcName")
|
|
||||||
return true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.w("SpotiFLAC", "LRC sidecar write failed: ${e.message}")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun MainActivity.runPostProcessingSafV2(fileUriStr: String, metadataJson: String): String {
|
|
||||||
val uri = Uri.parse(fileUriStr)
|
|
||||||
val doc = DocumentFile.fromSingleUri(this, uri)
|
|
||||||
?: return errorJson("SAF file not found")
|
|
||||||
|
|
||||||
val tempInput = copyUriToTemp(uri) ?: return errorJson("Failed to copy SAF file to temp")
|
|
||||||
val tempDir = File(tempInput).parentFile?.absolutePath ?: ""
|
|
||||||
if (tempDir.isNotBlank()) {
|
|
||||||
try {
|
|
||||||
Gobackend.allowDownloadDir(tempDir)
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
val inputObj = JSONObject()
|
|
||||||
inputObj.put("path", tempInput)
|
|
||||||
inputObj.put("uri", fileUriStr)
|
|
||||||
inputObj.put("name", doc.name ?: File(tempInput).name)
|
|
||||||
inputObj.put("mime_type", doc.type ?: contentResolver.getType(uri) ?: "")
|
|
||||||
inputObj.put("size", doc.length())
|
|
||||||
inputObj.put("is_saf", true)
|
|
||||||
|
|
||||||
val response = Gobackend.runPostProcessingV2JSON(inputObj.toString(), metadataJson)
|
|
||||||
val respObj = JSONObject(response)
|
|
||||||
if (!respObj.optBoolean("success", false)) {
|
|
||||||
try {
|
|
||||||
File(tempInput).delete()
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
val newPath = respObj.optString("new_file_path", "")
|
|
||||||
val outputPath = if (newPath.isNotBlank()) newPath else tempInput
|
|
||||||
val outputFile = File(outputPath)
|
|
||||||
if (!outputFile.exists()) {
|
|
||||||
try {
|
|
||||||
File(tempInput).delete()
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
respObj.put("success", false)
|
|
||||||
respObj.put("error", "postProcess output not found")
|
|
||||||
return respObj.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
val newName = outputFile.name
|
|
||||||
if (!newName.isNullOrBlank() && doc.name != null && doc.name != newName) {
|
|
||||||
try {
|
|
||||||
doc.renameTo(newName)
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
val writeOk = writeUriFromPath(uri, outputFile.absolutePath)
|
|
||||||
if (!writeOk) {
|
|
||||||
respObj.put("success", false)
|
|
||||||
respObj.put("error", "failed to write postProcess output to SAF")
|
|
||||||
return respObj.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (outputPath != tempInput) {
|
|
||||||
outputFile.delete()
|
|
||||||
}
|
|
||||||
File(tempInput).delete()
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
|
|
||||||
respObj.put("new_file_path", uri.toString())
|
|
||||||
respObj.put("file_path", uri.toString())
|
|
||||||
return respObj.toString()
|
|
||||||
}
|
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
package com.zarz.spotiflac
|
|
||||||
|
|
||||||
import java.util.Locale
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure finalization decisions shared by the Android background pipeline.
|
|
||||||
*
|
|
||||||
* Keeping format and naming policy free of Android/FFmpeg dependencies lets
|
|
||||||
* local JVM tests cover the branches that otherwise live inside the native
|
|
||||||
* finalizer's I/O-heavy orchestration.
|
|
||||||
*/
|
|
||||||
internal object NativeFinalizationPolicy {
|
|
||||||
fun normalizeAudioCodec(codec: String?): String? {
|
|
||||||
val normalized = normalizeOptional(codec)
|
|
||||||
?.lowercase(Locale.ROOT)
|
|
||||||
?.replace('-', '_')
|
|
||||||
?: return null
|
|
||||||
return when (normalized) {
|
|
||||||
"mp4a" -> "aac"
|
|
||||||
"ec_3" -> "eac3"
|
|
||||||
"ac_3" -> "ac3"
|
|
||||||
"ac_4" -> "ac4"
|
|
||||||
"mp4" -> "m4a"
|
|
||||||
"ogg" -> "opus"
|
|
||||||
else -> normalized
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun audioFormatForCodec(codec: String?): String? {
|
|
||||||
return when (normalizeAudioCodec(codec)) {
|
|
||||||
"flac" -> "FLAC"
|
|
||||||
"alac" -> "ALAC"
|
|
||||||
"aac" -> "AAC"
|
|
||||||
"eac3" -> "EAC3"
|
|
||||||
"ac3" -> "AC3"
|
|
||||||
"ac4" -> "AC4"
|
|
||||||
"mp3" -> "MP3"
|
|
||||||
"opus" -> "OPUS"
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isLossyAudioCodec(codec: String?): Boolean {
|
|
||||||
return when (normalizeAudioCodec(codec)) {
|
|
||||||
"aac", "eac3", "ac3", "ac4", "mp3", "opus", "m4a" -> true
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isLosslessAudioCodec(codec: String?): Boolean {
|
|
||||||
val normalized = normalizeAudioCodec(codec) ?: return false
|
|
||||||
if (normalized.startsWith("pcm_")) return true
|
|
||||||
return normalized in setOf(
|
|
||||||
"alac",
|
|
||||||
"flac",
|
|
||||||
"wavpack",
|
|
||||||
"ape",
|
|
||||||
"tta",
|
|
||||||
"mlp",
|
|
||||||
"truehd",
|
|
||||||
"shorten",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun displayAudioQuality(
|
|
||||||
filePath: String,
|
|
||||||
fileName: String,
|
|
||||||
bitDepth: Int?,
|
|
||||||
sampleRate: Int?,
|
|
||||||
bitrateKbps: Int?,
|
|
||||||
audioCodec: String? = null,
|
|
||||||
storedQuality: String?,
|
|
||||||
): String? {
|
|
||||||
val format = audioFormatForCodec(audioCodec)
|
|
||||||
?: audioFormatForPath(filePath, fileName)
|
|
||||||
if (
|
|
||||||
format == "OPUS" ||
|
|
||||||
format == "MP3" ||
|
|
||||||
format == "AAC" ||
|
|
||||||
format == "EAC3" ||
|
|
||||||
format == "AC3" ||
|
|
||||||
format == "AC4" ||
|
|
||||||
(format == "M4A" && (bitDepth == null || bitDepth <= 0))
|
|
||||||
) {
|
|
||||||
return if (bitrateKbps != null && bitrateKbps >= 16) {
|
|
||||||
"$format ${bitrateKbps}kbps"
|
|
||||||
} else {
|
|
||||||
nonPlaceholderQuality(storedQuality) ?: format
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bitDepth != null && bitDepth > 0 && sampleRate != null && sampleRate > 0) {
|
|
||||||
return "$bitDepth-bit/${sampleRateLabel(sampleRate)}kHz"
|
|
||||||
}
|
|
||||||
return nonPlaceholderQuality(storedQuality) ?: normalizeOptional(storedQuality)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun qualityVariantFilenameLabel(
|
|
||||||
measuredQuality: String,
|
|
||||||
bitDepth: Int?,
|
|
||||||
sampleRate: Int?,
|
|
||||||
bitrateKbps: Int?,
|
|
||||||
audioCodec: String?,
|
|
||||||
): String? {
|
|
||||||
if (isLossyAudioCodec(audioCodec)) {
|
|
||||||
val bitrate = bitrateKbps ?: Regex(
|
|
||||||
"\\b(\\d+)\\s*kbps\\b",
|
|
||||||
RegexOption.IGNORE_CASE,
|
|
||||||
).find(measuredQuality)?.groupValues?.getOrNull(1)?.toIntOrNull()
|
|
||||||
return bitrate?.takeIf { it >= 16 }?.let { "${it}kbps" }
|
|
||||||
}
|
|
||||||
|
|
||||||
var resolvedBitDepth = bitDepth
|
|
||||||
var resolvedSampleRate = sampleRate
|
|
||||||
if (resolvedBitDepth == null || resolvedSampleRate == null) {
|
|
||||||
val match = Regex(
|
|
||||||
"\\b(\\d+)\\s*(?:-|\\s)?bit\\s*[/_-]\\s*(\\d+(?:\\.\\d+)?)\\s*k?hz\\b",
|
|
||||||
RegexOption.IGNORE_CASE,
|
|
||||||
).find(measuredQuality)
|
|
||||||
resolvedBitDepth =
|
|
||||||
resolvedBitDepth ?: match?.groupValues?.getOrNull(1)?.toIntOrNull()
|
|
||||||
resolvedSampleRate =
|
|
||||||
resolvedSampleRate
|
|
||||||
?: match?.groupValues?.getOrNull(2)?.toDoubleOrNull()?.let { rate ->
|
|
||||||
if (rate < 1000) {
|
|
||||||
(rate * 1000).roundToInt()
|
|
||||||
} else {
|
|
||||||
rate.roundToInt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
resolvedBitDepth == null ||
|
|
||||||
resolvedBitDepth <= 0 ||
|
|
||||||
resolvedSampleRate == null ||
|
|
||||||
resolvedSampleRate <= 0
|
|
||||||
) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return "${resolvedBitDepth}bit-${sampleRateLabel(resolvedSampleRate)}kHz"
|
|
||||||
}
|
|
||||||
|
|
||||||
fun applyQualityVariantFilenameLabel(
|
|
||||||
fileName: String,
|
|
||||||
stagingLabel: String,
|
|
||||||
qualityLabel: String,
|
|
||||||
): String {
|
|
||||||
if (stagingLabel.isNotEmpty() && fileName.contains(stagingLabel)) {
|
|
||||||
return fileName.replace(stagingLabel, qualityLabel)
|
|
||||||
}
|
|
||||||
if (fileName.contains(qualityLabel)) return fileName
|
|
||||||
val dotIndex = fileName.lastIndexOf('.')
|
|
||||||
val hasExtension = dotIndex > 0
|
|
||||||
val stem = if (hasExtension) fileName.substring(0, dotIndex) else fileName
|
|
||||||
val extension = if (hasExtension) fileName.substring(dotIndex) else ""
|
|
||||||
return "$stem - $qualityLabel$extension"
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeQualityVariantStagingLabel(
|
|
||||||
fileName: String,
|
|
||||||
stagingLabel: String,
|
|
||||||
): String {
|
|
||||||
if (stagingLabel.isEmpty() || !fileName.contains(stagingLabel)) return fileName
|
|
||||||
val dotIndex = fileName.lastIndexOf('.')
|
|
||||||
val hasExtension = dotIndex > 0
|
|
||||||
val stem = if (hasExtension) fileName.substring(0, dotIndex) else fileName
|
|
||||||
val extension = if (hasExtension) fileName.substring(dotIndex) else ""
|
|
||||||
val cleanedStem = stem
|
|
||||||
.replace(stagingLabel, "")
|
|
||||||
.replace(Regex("[\\s_-]+$"), "")
|
|
||||||
.trim()
|
|
||||||
.ifBlank { "track" }
|
|
||||||
return "$cleanedStem$extension"
|
|
||||||
}
|
|
||||||
|
|
||||||
fun resolveQualityVariantFilename(
|
|
||||||
fileName: String,
|
|
||||||
stagingLabel: String,
|
|
||||||
qualityLabel: String,
|
|
||||||
collisionOnly: Boolean,
|
|
||||||
cleanNameExists: Boolean,
|
|
||||||
): String {
|
|
||||||
if (!collisionOnly || cleanNameExists) {
|
|
||||||
return applyQualityVariantFilenameLabel(fileName, stagingLabel, qualityLabel)
|
|
||||||
}
|
|
||||||
return removeQualityVariantStagingLabel(fileName, stagingLabel)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the user-facing name that a deferred SAF download was assigned
|
|
||||||
* before its audio was materialized in the app cache. Container and
|
|
||||||
* decryption passes may replace [currentFileName] with a temporary
|
|
||||||
* `native_saf_work_*` name, which must never become the published name.
|
|
||||||
*/
|
|
||||||
fun logicalOutputFileName(
|
|
||||||
deferredSafPublish: Boolean,
|
|
||||||
resultSafFileName: String?,
|
|
||||||
requestSafFileName: String?,
|
|
||||||
currentFileName: String,
|
|
||||||
): String {
|
|
||||||
if (!deferredSafPublish) return currentFileName
|
|
||||||
return normalizeOptional(resultSafFileName)
|
|
||||||
?: normalizeOptional(requestSafFileName)
|
|
||||||
?: currentFileName
|
|
||||||
}
|
|
||||||
|
|
||||||
fun resolvePreferredDecryptionExtension(
|
|
||||||
inputPath: String,
|
|
||||||
requested: String,
|
|
||||||
): String {
|
|
||||||
val normalizedRequest = normalizeExtension(requested)
|
|
||||||
if (normalizedRequest.isNotBlank()) return normalizedRequest
|
|
||||||
val lower = inputPath.lowercase(Locale.ROOT)
|
|
||||||
return when {
|
|
||||||
lower.endsWith(".m4a") -> ".flac"
|
|
||||||
lower.endsWith(".flac") -> ".flac"
|
|
||||||
lower.endsWith(".mp3") -> ".mp3"
|
|
||||||
lower.endsWith(".opus") -> ".opus"
|
|
||||||
lower.endsWith(".mp4") -> ".mp4"
|
|
||||||
else -> ".flac"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun formatIndexTag(number: Int, total: Int): String {
|
|
||||||
if (number <= 0) return "0"
|
|
||||||
return if (total > 0) "$number/$total" else number.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun audioFormatForPath(filePath: String, fileName: String): String? {
|
|
||||||
for (candidate in listOf(filePath, fileName)) {
|
|
||||||
val lower = candidate.trim().lowercase(Locale.ROOT)
|
|
||||||
when {
|
|
||||||
lower.endsWith(".opus") || lower.endsWith(".ogg") -> return "OPUS"
|
|
||||||
lower.endsWith(".mp3") -> return "MP3"
|
|
||||||
lower.endsWith(".aac") -> return "AAC"
|
|
||||||
lower.endsWith(".m4a") || lower.endsWith(".mp4") -> return "M4A"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun nonPlaceholderQuality(quality: String?): String? {
|
|
||||||
val normalized = normalizeOptional(quality) ?: return null
|
|
||||||
val bitrateMatch =
|
|
||||||
Regex("\\b(\\d+)\\s*kbps\\b", RegexOption.IGNORE_CASE).find(normalized)
|
|
||||||
if (bitrateMatch != null) {
|
|
||||||
val bitrate = bitrateMatch.groupValues.getOrNull(1)?.toIntOrNull()
|
|
||||||
if (bitrate != null && bitrate < 16) return null
|
|
||||||
}
|
|
||||||
val key = normalized
|
|
||||||
.lowercase(Locale.ROOT)
|
|
||||||
.replace(Regex("[^a-z0-9]+"), "_")
|
|
||||||
.trim('_')
|
|
||||||
val placeholders = setOf(
|
|
||||||
"best",
|
|
||||||
"lossless",
|
|
||||||
"hi_res",
|
|
||||||
"hires",
|
|
||||||
"hi_res_lossless",
|
|
||||||
"hires_lossless",
|
|
||||||
"high",
|
|
||||||
"cd",
|
|
||||||
"flac_best_available",
|
|
||||||
)
|
|
||||||
return if (placeholders.contains(key)) null else normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sampleRateLabel(sampleRate: Int): String {
|
|
||||||
val khz = sampleRate / 1000.0
|
|
||||||
val precision = if (sampleRate % 1000 == 0) 0 else 1
|
|
||||||
return "%.${precision}f".format(Locale.US, khz)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun normalizeOptional(value: String?): String? {
|
|
||||||
val trimmed = value?.trim().orEmpty()
|
|
||||||
if (trimmed.isEmpty() || trimmed.equals("null", ignoreCase = true)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun normalizeExtension(extension: String?): String {
|
|
||||||
val trimmed = extension?.trim().orEmpty()
|
|
||||||
if (trimmed.isEmpty()) return ""
|
|
||||||
val lower = trimmed.lowercase(Locale.ROOT)
|
|
||||||
return if (lower.startsWith(".")) lower else ".$lower"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
package com.zarz.spotiflac
|
|
||||||
|
|
||||||
import android.content.ContentValues
|
|
||||||
import android.content.Context
|
|
||||||
import android.database.sqlite.SQLiteDatabase
|
|
||||||
import android.database.sqlite.SQLiteException
|
|
||||||
import android.net.Uri
|
|
||||||
import android.util.Base64
|
|
||||||
import android.util.Log
|
|
||||||
import com.antonkarpenko.ffmpegkit.FFmpegKit
|
|
||||||
import com.antonkarpenko.ffmpegkit.FFmpegKitConfig
|
|
||||||
import com.antonkarpenko.ffmpegkit.FFmpegSession
|
|
||||||
import com.antonkarpenko.ffmpegkit.FFmpegSessionCompleteCallback
|
|
||||||
import com.antonkarpenko.ffmpegkit.LogRedirectionStrategy
|
|
||||||
import com.antonkarpenko.ffmpegkit.ReturnCode
|
|
||||||
import com.zarz.spotiflac.SafDownloadHandler.mimeTypeForExt
|
|
||||||
import com.zarz.spotiflac.SafDownloadHandler.normalizeExt
|
|
||||||
import com.zarz.spotiflac.NativeFinalizationPolicy.applyQualityVariantFilenameLabel
|
|
||||||
import com.zarz.spotiflac.NativeFinalizationPolicy.displayAudioQuality
|
|
||||||
import com.zarz.spotiflac.NativeFinalizationPolicy.formatIndexTag
|
|
||||||
import com.zarz.spotiflac.NativeFinalizationPolicy.isLosslessAudioCodec
|
|
||||||
import com.zarz.spotiflac.NativeFinalizationPolicy.isLossyAudioCodec
|
|
||||||
import com.zarz.spotiflac.NativeFinalizationPolicy.normalizeAudioCodec
|
|
||||||
import com.zarz.spotiflac.NativeFinalizationPolicy.resolvePreferredDecryptionExtension
|
|
||||||
import gobackend.Gobackend
|
|
||||||
import org.json.JSONObject
|
|
||||||
import java.io.File
|
|
||||||
import java.io.RandomAccessFile
|
|
||||||
import java.nio.ByteBuffer
|
|
||||||
import java.util.Locale
|
|
||||||
import java.util.concurrent.CancellationException
|
|
||||||
import java.util.concurrent.CountDownLatch
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
|
||||||
import kotlin.math.pow
|
|
||||||
|
|
||||||
|
|
||||||
// FFmpeg execution, probing, and container helpers for NativeDownloadFinalizer.
|
|
||||||
|
|
||||||
internal fun NativeDownloadFinalizer.isMP4ContainerFile(path: String): Boolean {
|
|
||||||
return try {
|
|
||||||
File(path).inputStream().use { stream ->
|
|
||||||
val header = ByteArray(12)
|
|
||||||
val read = stream.read(header)
|
|
||||||
read >= 8 &&
|
|
||||||
header[4] == 'f'.code.toByte() &&
|
|
||||||
header[5] == 't'.code.toByte() &&
|
|
||||||
header[6] == 'y'.code.toByte() &&
|
|
||||||
header[7] == 'p'.code.toByte()
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun NativeDownloadFinalizer.formatForPath(path: String): String {
|
|
||||||
return when (normalizeExt(File(path).extension)) {
|
|
||||||
".mp3" -> "mp3"
|
|
||||||
".opus", ".ogg" -> "opus"
|
|
||||||
".m4a", ".mp4", ".aac" -> "m4a"
|
|
||||||
else -> "flac"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun NativeDownloadFinalizer.scanReplayGain(path: String, shouldCancel: () -> Boolean = { false }): NativeDownloadFinalizer.ReplayGainScan? {
|
|
||||||
val command = "-hide_banner -nostats -i ${q(path)} -filter_complex ebur128=peak=true:framelog=quiet -f null -"
|
|
||||||
val result = runFFmpeg(command, shouldCancel)
|
|
||||||
val output = result.second
|
|
||||||
val integrated = Regex("I:\\s+(-?\\d+\\.?\\d*)\\s+LUFS")
|
|
||||||
.findAll(output)
|
|
||||||
.lastOrNull()
|
|
||||||
?.groupValues
|
|
||||||
?.getOrNull(1)
|
|
||||||
?.toDoubleOrNull() ?: return null
|
|
||||||
val truePeak = Regex("Peak:\\s+(-?\\d+\\.?\\d*)\\s+dBFS")
|
|
||||||
.findAll(output)
|
|
||||||
.mapNotNull { it.groupValues.getOrNull(1)?.toDoubleOrNull() }
|
|
||||||
.maxOrNull()
|
|
||||||
val gain = -18.0 - integrated
|
|
||||||
val peak = if (truePeak != null) 10.0.pow(truePeak / 20.0) else 1.0
|
|
||||||
return NativeDownloadFinalizer.ReplayGainScan(
|
|
||||||
trackGain = "${if (gain >= 0) "+" else ""}${"%.2f".format(Locale.US, gain)} dB",
|
|
||||||
trackPeak = "%.6f".format(Locale.US, peak),
|
|
||||||
integratedLufs = integrated,
|
|
||||||
truePeakLinear = peak,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun NativeDownloadFinalizer.runFFmpeg(command: String, shouldCancel: () -> Boolean = { false }): Pair<Boolean, String> {
|
|
||||||
checkCancelled(shouldCancel)
|
|
||||||
installNativeFFmpegCallbackFilter()
|
|
||||||
val latch = CountDownLatch(1)
|
|
||||||
var completedSession: FFmpegSession? = null
|
|
||||||
val session = FFmpegSession.create(
|
|
||||||
FFmpegKitConfig.parseArguments(command),
|
|
||||||
{ finishedSession ->
|
|
||||||
completedSession = finishedSession
|
|
||||||
latch.countDown()
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
LogRedirectionStrategy.NEVER_PRINT_LOGS,
|
|
||||||
)
|
|
||||||
val sessionId = session.sessionId
|
|
||||||
synchronized(activeFFmpegSessionLock) {
|
|
||||||
activeFFmpegSessionIds.add(sessionId)
|
|
||||||
nativeFFmpegSessionIds.add(sessionId)
|
|
||||||
}
|
|
||||||
FFmpegKitConfig.asyncFFmpegExecute(session)
|
|
||||||
try {
|
|
||||||
var cancelRequested = false
|
|
||||||
while (!latch.await(200, TimeUnit.MILLISECONDS)) {
|
|
||||||
if (shouldCancel()) {
|
|
||||||
cancelRequested = true
|
|
||||||
try {
|
|
||||||
FFmpegKit.cancel(sessionId)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cancelRequested) {
|
|
||||||
latch.await(5, TimeUnit.SECONDS)
|
|
||||||
throw CancellationException("Native FFmpeg session cancelled")
|
|
||||||
}
|
|
||||||
val finalSession = completedSession ?: session
|
|
||||||
val output = finalSession.getAllLogsAsString(1000) ?: ""
|
|
||||||
checkCancelled(shouldCancel)
|
|
||||||
return ReturnCode.isSuccess(finalSession.returnCode) to output
|
|
||||||
} finally {
|
|
||||||
synchronized(activeFFmpegSessionLock) {
|
|
||||||
activeFFmpegSessionIds.remove(sessionId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun NativeDownloadFinalizer.installNativeFFmpegCallbackFilter() {
|
|
||||||
synchronized(ffmpegCompleteCallbackLock) {
|
|
||||||
val current = FFmpegKitConfig.getFFmpegSessionCompleteCallback()
|
|
||||||
if (current !== nativeFilteringFFmpegCompleteCallback) {
|
|
||||||
forwardedFFmpegCompleteCallback = current
|
|
||||||
FFmpegKitConfig.enableFFmpegSessionCompleteCallback(nativeFilteringFFmpegCompleteCallback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun NativeDownloadFinalizer.withFFmpegCommandPump(
|
|
||||||
shouldCancel: () -> Boolean = { false },
|
|
||||||
block: () -> String,
|
|
||||||
): String {
|
|
||||||
val running = AtomicBoolean(true)
|
|
||||||
val handled = mutableSetOf<String>()
|
|
||||||
val pump = Thread {
|
|
||||||
while (running.get()) {
|
|
||||||
try {
|
|
||||||
val raw = Gobackend.getAllPendingFFmpegCommandsJSON()
|
|
||||||
val commands = org.json.JSONArray(raw)
|
|
||||||
for (index in 0 until commands.length()) {
|
|
||||||
val command = commands.optJSONObject(index) ?: continue
|
|
||||||
val id = command.optString("command_id", "")
|
|
||||||
val commandLine = command.optString("command", "")
|
|
||||||
if (id.isBlank() || commandLine.isBlank() || handled.contains(id)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
handled.add(id)
|
|
||||||
// Every claimed command must get a result delivered to
|
|
||||||
// the Go side, even on failure or cancellation: the
|
|
||||||
// backend blocks until one arrives and never retries a
|
|
||||||
// claimed id, so bailing out here would strand the
|
|
||||||
// gomobile call the main thread is sitting in forever.
|
|
||||||
val result = try {
|
|
||||||
if (shouldCancel()) {
|
|
||||||
Pair(false, "cancelled")
|
|
||||||
} else {
|
|
||||||
runFFmpeg(commandLine, shouldCancel)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Pair(false, e.message ?: "FFmpeg execution failed")
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
Gobackend.setFFmpegCommandResultByID(
|
|
||||||
id,
|
|
||||||
result.first,
|
|
||||||
result.second,
|
|
||||||
if (result.first) "" else result.second,
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Failed to deliver FFmpeg result for $id: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
Thread.sleep(100)
|
|
||||||
} catch (_: InterruptedException) {
|
|
||||||
// Keep pumping until `running` flips: on cancel the Go call
|
|
||||||
// may still be waiting for a result for an in-flight
|
|
||||||
// command, and it is delivered as failed above.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pump.isDaemon = true
|
|
||||||
pump.start()
|
|
||||||
return try {
|
|
||||||
block()
|
|
||||||
} finally {
|
|
||||||
running.set(false)
|
|
||||||
pump.interrupt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Staged sibling name for conversion outputs: "song.flac" -> "song.partial.flac".
|
|
||||||
* The ".partial<ext>" shape is ignored by library scans and duplicate checks,
|
|
||||||
* while the real trailing extension still lets FFmpeg infer the muxer. A
|
|
||||||
* process kill mid-conversion therefore never leaves a partial file under a
|
|
||||||
* real audio name in the user's music folder.
|
|
||||||
*/
|
|
||||||
internal fun NativeDownloadFinalizer.stagedConversionPath(finalPath: String): String {
|
|
||||||
val file = File(finalPath)
|
|
||||||
val ext = file.extension
|
|
||||||
val name = if (ext.isBlank()) "${file.name}.partial" else "${file.nameWithoutExtension}.partial.$ext"
|
|
||||||
return File(file.parentFile, name).absolutePath
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun NativeDownloadFinalizer.promoteStagedConversion(stagedPath: String, finalPath: String): Boolean {
|
|
||||||
val staged = File(stagedPath)
|
|
||||||
fsyncQuietly(staged)
|
|
||||||
val final = File(finalPath)
|
|
||||||
if (staged.renameTo(final)) return true
|
|
||||||
return final.delete() && staged.renameTo(final)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun NativeDownloadFinalizer.buildOutputPath(inputPath: String, extension: String): String {
|
|
||||||
val ext = normalizeExt(extension).ifBlank { ".tmp" }
|
|
||||||
val file = File(inputPath)
|
|
||||||
val base = file.nameWithoutExtension.ifBlank { "track" }
|
|
||||||
val candidate = File(file.parentFile, "$base$ext").absolutePath
|
|
||||||
if (candidate != inputPath) return candidate
|
|
||||||
return File(file.parentFile, "${base}_converted$ext").absolutePath
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun NativeDownloadFinalizer.desiredFileName(input: NativeDownloadFinalizer.FinalizeInput, state: NativeDownloadFinalizer.FinalizeState, extension: String): String {
|
|
||||||
val ext = normalizeExt(extension).ifBlank { normalizeExt(File(state.fileName).extension).ifBlank { ".flac" } }
|
|
||||||
val rawName = input.result.optString("quality_variant_file_name", "")
|
|
||||||
.ifBlank { input.request.optString("saf_file_name", "") }
|
|
||||||
.ifBlank { state.fileName }
|
|
||||||
.ifBlank { "${trackString(input, "artistName", input.request.optString("artist_name", "Artist"))} - ${trackString(input, "name", input.request.optString("track_name", "Track"))}" }
|
|
||||||
val knownExts = listOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg", ".lrc")
|
|
||||||
var base = rawName.trim()
|
|
||||||
val lower = base.lowercase(Locale.ROOT)
|
|
||||||
for (knownExt in knownExts) {
|
|
||||||
if (lower.endsWith(knownExt)) {
|
|
||||||
base = base.dropLast(knownExt.length)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
base = base
|
|
||||||
.replace("/", " ")
|
|
||||||
.replace(Regex("[\\\\:*?\"<>|]"), " ")
|
|
||||||
.trim()
|
|
||||||
.trim('.', ' ')
|
|
||||||
.ifBlank { "track" }
|
|
||||||
return "$base$ext"
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun NativeDownloadFinalizer.shouldForceContainerConversion(input: NativeDownloadFinalizer.FinalizeInput, state: NativeDownloadFinalizer.FinalizeState): Boolean {
|
|
||||||
if (input.result.optBoolean("requires_container_conversion", false)) return true
|
|
||||||
if (input.request.optBoolean("requires_container_conversion", false)) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun NativeDownloadFinalizer.probePrimaryAudioCodec(path: String, shouldCancel: () -> Boolean = { false }): String {
|
|
||||||
val result = runFFmpeg("-hide_banner -nostdin -i ${q(path)} -map 0:a:0 -frames:a 1 -f null -", shouldCancel)
|
|
||||||
val output = result.second
|
|
||||||
val match = Regex("Audio:\\s*([^,\\s]+)", RegexOption.IGNORE_CASE).find(output)
|
|
||||||
return match?.groupValues?.getOrNull(1)
|
|
||||||
?.trim()
|
|
||||||
?.lowercase(Locale.ROOT)
|
|
||||||
?.replace('-', '_')
|
|
||||||
.orEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true when the file on [path] starts with the native FLAC magic
|
|
||||||
* bytes (`fLaC`). A file may contain a FLAC audio stream yet live inside
|
|
||||||
* an MP4/fMP4 container (e.g. some Amazon Music downloads); native FLAC
|
|
||||||
* tag writers require the raw fLaC header, so we must detect that mismatch
|
|
||||||
* before skipping the container conversion step.
|
|
||||||
*/
|
|
||||||
internal fun NativeDownloadFinalizer.isNativeFlacFile(path: String): Boolean {
|
|
||||||
return try {
|
|
||||||
RandomAccessFile(path, "r").use { raf ->
|
|
||||||
if (raf.length() < 4L) return false
|
|
||||||
val header = ByteArray(4)
|
|
||||||
raf.readFully(header)
|
|
||||||
header[0] == 0x66.toByte() && // 'f'
|
|
||||||
header[1] == 0x4C.toByte() && // 'L'
|
|
||||||
header[2] == 0x61.toByte() && // 'a'
|
|
||||||
header[3] == 0x43.toByte() // 'C'
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Native FLAC magic probe failed for $path: ${e.message}")
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun NativeDownloadFinalizer.requestedDecryptionOutputExt(input: NativeDownloadFinalizer.FinalizeInput): String {
|
|
||||||
val descriptor = input.result.optJSONObject("decryption")
|
|
||||||
return normalizeExt(
|
|
||||||
descriptor?.optString("output_extension", "")
|
|
||||||
?.ifBlank { input.result.optString("output_extension", "") }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,489 +0,0 @@
|
|||||||
package com.zarz.spotiflac
|
|
||||||
|
|
||||||
import android.content.ContentValues
|
|
||||||
import android.content.Context
|
|
||||||
import android.database.sqlite.SQLiteDatabase
|
|
||||||
import android.database.sqlite.SQLiteException
|
|
||||||
import android.net.Uri
|
|
||||||
import android.util.Base64
|
|
||||||
import android.util.Log
|
|
||||||
import com.antonkarpenko.ffmpegkit.FFmpegKit
|
|
||||||
import com.antonkarpenko.ffmpegkit.FFmpegKitConfig
|
|
||||||
import com.antonkarpenko.ffmpegkit.FFmpegSession
|
|
||||||
import com.antonkarpenko.ffmpegkit.FFmpegSessionCompleteCallback
|
|
||||||
import com.antonkarpenko.ffmpegkit.LogRedirectionStrategy
|
|
||||||
import com.antonkarpenko.ffmpegkit.ReturnCode
|
|
||||||
import com.zarz.spotiflac.SafDownloadHandler.mimeTypeForExt
|
|
||||||
import com.zarz.spotiflac.SafDownloadHandler.normalizeExt
|
|
||||||
import com.zarz.spotiflac.NativeFinalizationPolicy.applyQualityVariantFilenameLabel
|
|
||||||
import com.zarz.spotiflac.NativeFinalizationPolicy.displayAudioQuality
|
|
||||||
import com.zarz.spotiflac.NativeFinalizationPolicy.formatIndexTag
|
|
||||||
import com.zarz.spotiflac.NativeFinalizationPolicy.isLosslessAudioCodec
|
|
||||||
import com.zarz.spotiflac.NativeFinalizationPolicy.isLossyAudioCodec
|
|
||||||
import com.zarz.spotiflac.NativeFinalizationPolicy.logicalOutputFileName
|
|
||||||
import com.zarz.spotiflac.NativeFinalizationPolicy.removeQualityVariantStagingLabel
|
|
||||||
import com.zarz.spotiflac.NativeFinalizationPolicy.resolveQualityVariantFilename
|
|
||||||
import com.zarz.spotiflac.NativeFinalizationPolicy.normalizeAudioCodec
|
|
||||||
import com.zarz.spotiflac.NativeFinalizationPolicy.resolvePreferredDecryptionExtension
|
|
||||||
import gobackend.Gobackend
|
|
||||||
import org.json.JSONObject
|
|
||||||
import java.io.File
|
|
||||||
import java.io.RandomAccessFile
|
|
||||||
import java.nio.ByteBuffer
|
|
||||||
import java.util.Locale
|
|
||||||
import java.util.concurrent.CancellationException
|
|
||||||
import java.util.concurrent.CountDownLatch
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
|
||||||
import kotlin.math.pow
|
|
||||||
|
|
||||||
|
|
||||||
// Metadata embedding, cover download, external LRC, and quality-variant
|
|
||||||
// filename helpers for NativeDownloadFinalizer.
|
|
||||||
|
|
||||||
internal fun NativeDownloadFinalizer.qualityVariantFilenameLabel(state: NativeDownloadFinalizer.FinalizeState): String? {
|
|
||||||
return NativeFinalizationPolicy.qualityVariantFilenameLabel(
|
|
||||||
measuredQuality = state.quality,
|
|
||||||
bitDepth = state.bitDepth,
|
|
||||||
sampleRate = state.sampleRate,
|
|
||||||
bitrateKbps = state.bitrateKbps,
|
|
||||||
audioCodec = state.audioCodec,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun NativeDownloadFinalizer.finalizeQualityVariantFilename(
|
|
||||||
context: Context,
|
|
||||||
input: NativeDownloadFinalizer.FinalizeInput,
|
|
||||||
state: NativeDownloadFinalizer.FinalizeState,
|
|
||||||
) {
|
|
||||||
if (!input.request.optBoolean("allow_quality_variant", false)) return
|
|
||||||
val stagingLabel = input.request.optString("quality_variant", "").trim()
|
|
||||||
val qualityLabel = qualityVariantFilenameLabel(state)
|
|
||||||
if (qualityLabel == null) {
|
|
||||||
Log.w(TAG, "Keeping temporary quality label because final audio specifications are unavailable")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val logicalFileName = logicalOutputFileName(
|
|
||||||
deferredSafPublish = isDeferredSafPublish(input),
|
|
||||||
resultSafFileName = input.result.optString("saf_final_file_name", ""),
|
|
||||||
requestSafFileName = input.request.optString("saf_file_name", ""),
|
|
||||||
currentFileName = state.fileName,
|
|
||||||
)
|
|
||||||
val variantName = applyQualityVariantFilenameLabel(
|
|
||||||
fileName = logicalFileName,
|
|
||||||
stagingLabel = stagingLabel,
|
|
||||||
qualityLabel = qualityLabel,
|
|
||||||
)
|
|
||||||
val cleanName = removeQualityVariantStagingLabel(logicalFileName, stagingLabel)
|
|
||||||
val collisionOnly = input.request.optBoolean("quality_variant_collision_only", false)
|
|
||||||
val preferredName = variantName
|
|
||||||
if (preferredName == logicalFileName && preferredName == state.fileName) return
|
|
||||||
input.result.put("quality_variant_file_name", preferredName)
|
|
||||||
if (isDeferredSafPublish(input)) {
|
|
||||||
state.fileName = preferredName
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.filePath.startsWith("content://")) {
|
|
||||||
val tempPath = SafDownloadHandler.copyContentUriToTemp(context, state.filePath) ?: return
|
|
||||||
try {
|
|
||||||
val treeUri = input.request.optString("saf_tree_uri", "")
|
|
||||||
val relativeDir = input.request.optString("saf_relative_dir", "")
|
|
||||||
val writeResult = if (collisionOnly) {
|
|
||||||
SafDownloadHandler.writeFileToSafCollisionAware(
|
|
||||||
context = context,
|
|
||||||
treeUriStr = treeUri,
|
|
||||||
relativeDir = relativeDir,
|
|
||||||
cleanFileName = cleanName,
|
|
||||||
variantFileName = variantName,
|
|
||||||
mimeType = mimeTypeForExt(File(variantName).extension),
|
|
||||||
srcPath = tempPath,
|
|
||||||
preservedSuffix = qualityLabel,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
SafDownloadHandler.writeFileToSafUnique(
|
|
||||||
context = context,
|
|
||||||
treeUriStr = treeUri,
|
|
||||||
relativeDir = relativeDir,
|
|
||||||
fileName = preferredName,
|
|
||||||
mimeType = mimeTypeForExt(File(preferredName).extension),
|
|
||||||
srcPath = tempPath,
|
|
||||||
preservedSuffix = qualityLabel,
|
|
||||||
)
|
|
||||||
} ?: return
|
|
||||||
SafDownloadHandler.deleteContentUri(context, state.filePath)
|
|
||||||
state.filePath = writeResult.uri
|
|
||||||
state.fileName = writeResult.fileName
|
|
||||||
} finally {
|
|
||||||
File(tempPath).delete()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val source = File(state.filePath)
|
|
||||||
val cleanTarget = File(source.parentFile, cleanName)
|
|
||||||
val lockTarget = if (collisionOnly) cleanTarget else File(source.parentFile, preferredName)
|
|
||||||
val lockKey = lockTarget.absolutePath.lowercase(Locale.ROOT)
|
|
||||||
val lock = qualityVariantNameLocks.computeIfAbsent(lockKey) { Any() }
|
|
||||||
synchronized(lock) {
|
|
||||||
val selectedName = if (collisionOnly) {
|
|
||||||
resolveQualityVariantFilename(
|
|
||||||
fileName = logicalFileName,
|
|
||||||
stagingLabel = stagingLabel,
|
|
||||||
qualityLabel = qualityLabel,
|
|
||||||
collisionOnly = true,
|
|
||||||
cleanNameExists = cleanTarget.absolutePath != source.absolutePath && cleanTarget.exists(),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
preferredName
|
|
||||||
}
|
|
||||||
input.result.put("quality_variant_file_name", selectedName)
|
|
||||||
if (selectedName == state.fileName) return@synchronized
|
|
||||||
val target = uniqueLocalFile(source.parentFile, selectedName)
|
|
||||||
if (!source.renameTo(target)) {
|
|
||||||
Log.w(TAG, "Could not rename quality variant output: ${source.absolutePath}")
|
|
||||||
return@synchronized
|
|
||||||
}
|
|
||||||
state.filePath = target.absolutePath
|
|
||||||
state.fileName = target.name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
input.result.put("file_path", state.filePath)
|
|
||||||
input.result.put("file_name", state.fileName)
|
|
||||||
input.result.optJSONObject("replaygain")?.let { replayGain ->
|
|
||||||
replayGain.put("file_path", state.filePath)
|
|
||||||
replayGain.put("file_name", state.fileName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun NativeDownloadFinalizer.uniqueLocalFile(parent: File?, preferredName: String): File {
|
|
||||||
val directory = parent ?: return File(preferredName)
|
|
||||||
var candidate = File(directory, preferredName)
|
|
||||||
if (!candidate.exists()) return candidate
|
|
||||||
val dotIndex = preferredName.lastIndexOf('.')
|
|
||||||
val hasExtension = dotIndex > 0
|
|
||||||
val stem = if (hasExtension) preferredName.substring(0, dotIndex) else preferredName
|
|
||||||
val extension = if (hasExtension) preferredName.substring(dotIndex) else ""
|
|
||||||
var counter = 2
|
|
||||||
while (candidate.exists()) {
|
|
||||||
candidate = File(directory, "$stem ($counter)$extension")
|
|
||||||
counter++
|
|
||||||
}
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun NativeDownloadFinalizer.writeExternalLrc(context: Context, input: NativeDownloadFinalizer.FinalizeInput, state: NativeDownloadFinalizer.FinalizeState) {
|
|
||||||
if (!input.request.optBoolean("embed_metadata", false) || !input.request.optBoolean("embed_lyrics", false)) return
|
|
||||||
val lyricsMode = input.request.optString("lyrics_mode", "")
|
|
||||||
if (lyricsMode != "external" && lyricsMode != "both") return
|
|
||||||
val lrc = resolveLyricsLrc(input)
|
|
||||||
if (lrc.isBlank() || lrc == "[instrumental:true]") return
|
|
||||||
val audioFileName = if (isDeferredSafRequest(input)) {
|
|
||||||
desiredFileName(input, state, File(state.filePath).extension)
|
|
||||||
} else {
|
|
||||||
state.fileName
|
|
||||||
}
|
|
||||||
val baseName = audioFileName.replace(Regex("\\.[^.]+$"), "")
|
|
||||||
if (isDeferredSafRequest(input)) {
|
|
||||||
state.pendingExternalLrc = lrc
|
|
||||||
state.pendingExternalLrcFileName = "$baseName.lrc"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (state.filePath.startsWith("content://")) {
|
|
||||||
val treeUri = input.request.optString("saf_tree_uri", "")
|
|
||||||
val relativeDir = input.request.optString("saf_relative_dir", "")
|
|
||||||
val temp = File(context.cacheDir, "native_lrc_${System.nanoTime()}.lrc")
|
|
||||||
temp.writeText(lrc)
|
|
||||||
try {
|
|
||||||
SafDownloadHandler.writeFileToSaf(
|
|
||||||
context = context,
|
|
||||||
treeUriStr = treeUri,
|
|
||||||
relativeDir = relativeDir,
|
|
||||||
fileName = "$baseName.lrc",
|
|
||||||
mimeType = "application/octet-stream",
|
|
||||||
srcPath = temp.absolutePath,
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
temp.delete()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val target = File(File(state.filePath).parentFile, "$baseName.lrc")
|
|
||||||
target.writeText(lrc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun NativeDownloadFinalizer.resolveLyricsLrc(input: NativeDownloadFinalizer.FinalizeInput): String {
|
|
||||||
val existing = input.result.optString("lyrics_lrc", "").trim()
|
|
||||||
if (existing.isNotEmpty()) return existing
|
|
||||||
|
|
||||||
val spotifyId = trackString(input, "id", input.request.optString("spotify_id", ""))
|
|
||||||
val trackName = trackString(input, "name", input.request.optString("track_name", ""))
|
|
||||||
val artistName = trackString(input, "artistName", input.request.optString("artist_name", ""))
|
|
||||||
if (trackName.isBlank() || artistName.isBlank()) return ""
|
|
||||||
|
|
||||||
return try {
|
|
||||||
val fetched = Gobackend.getLyricsLRC(
|
|
||||||
spotifyId,
|
|
||||||
trackName,
|
|
||||||
artistName,
|
|
||||||
"",
|
|
||||||
lyricsDurationMs(input),
|
|
||||||
).trim()
|
|
||||||
if (fetched.isNotEmpty()) {
|
|
||||||
input.result.put("lyrics_lrc", fetched)
|
|
||||||
}
|
|
||||||
fetched
|
|
||||||
} catch (_: Exception) {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun NativeDownloadFinalizer.lyricsDurationMs(input: NativeDownloadFinalizer.FinalizeInput): Long {
|
|
||||||
val requestDuration = input.request.optLong("duration_ms", 0L)
|
|
||||||
val trackDuration = trackInt(input, "duration", 0).toLong()
|
|
||||||
val duration = if (requestDuration > 0L) requestDuration else trackDuration
|
|
||||||
if (duration <= 0L) return 0L
|
|
||||||
return if (duration > 10000L) duration else duration * 1000L
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun NativeDownloadFinalizer.embedBasicMetadata(context: Context, path: String, input: NativeDownloadFinalizer.FinalizeInput, format: String) {
|
|
||||||
if (!input.request.optBoolean("embed_metadata", false)) return
|
|
||||||
val title = resultString(input, "title").ifBlank {
|
|
||||||
trackString(input, "name", requestString(input, "track_name"))
|
|
||||||
}
|
|
||||||
val artist = resultString(input, "artist").ifBlank {
|
|
||||||
trackString(input, "artistName", requestString(input, "artist_name"))
|
|
||||||
}
|
|
||||||
val album = resultString(input, "album").ifBlank {
|
|
||||||
trackString(input, "albumName", requestString(input, "album_name"))
|
|
||||||
}
|
|
||||||
val albumArtist = resultString(input, "album_artist").ifBlank {
|
|
||||||
trackString(input, "albumArtist", requestString(input, "album_artist"))
|
|
||||||
}
|
|
||||||
val date = resultString(input, "release_date").ifBlank {
|
|
||||||
resultString(input, "date").ifBlank {
|
|
||||||
trackString(input, "releaseDate", requestString(input, "release_date"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val trackNumberValue = positiveOrNull(input.result.optInt("track_number", 0), trackInt(input, "trackNumber", input.request.optInt("track_number", 0))) ?: 0
|
|
||||||
val totalTracksValue = positiveOrNull(input.result.optInt("total_tracks", 0), trackInt(input, "totalTracks", input.request.optInt("total_tracks", 0))) ?: 0
|
|
||||||
val discNumberValue = positiveOrNull(input.result.optInt("disc_number", 0), trackInt(input, "discNumber", input.request.optInt("disc_number", 0))) ?: 0
|
|
||||||
val totalDiscsValue = positiveOrNull(input.result.optInt("total_discs", 0), trackInt(input, "totalDiscs", input.request.optInt("total_discs", 0))) ?: 0
|
|
||||||
val trackNumber = formatIndexTag(trackNumberValue, totalTracksValue)
|
|
||||||
val discNumber = formatIndexTag(discNumberValue, totalDiscsValue)
|
|
||||||
val isrc = resultString(input, "isrc").ifBlank {
|
|
||||||
trackString(input, "isrc", requestString(input, "isrc"))
|
|
||||||
}
|
|
||||||
val composer = resultString(input, "composer").ifBlank {
|
|
||||||
trackString(input, "composer", requestString(input, "composer"))
|
|
||||||
}
|
|
||||||
val genre = resultString(input, "genre").ifBlank { requestString(input, "genre") }
|
|
||||||
val label = resultString(input, "label").ifBlank { requestString(input, "label") }
|
|
||||||
val copyright = resultString(input, "copyright").ifBlank { requestString(input, "copyright") }
|
|
||||||
val lyricsMode = input.request.optString("lyrics_mode", "embed")
|
|
||||||
val shouldResolveLyrics = input.request.optBoolean("embed_lyrics", false) &&
|
|
||||||
(lyricsMode == "embed" || lyricsMode == "both")
|
|
||||||
val lyrics = if (shouldResolveLyrics) resolveLyricsLrc(input) else ""
|
|
||||||
val shouldEmbedLyrics = shouldResolveLyrics &&
|
|
||||||
lyrics.isNotBlank() &&
|
|
||||||
lyrics != "[instrumental:true]"
|
|
||||||
// FLAC, MP3, Opus, and M4A all have native Go tag writers that edit the
|
|
||||||
// tag block atomically without an ffmpeg remux (which drops foreign
|
|
||||||
// frames and rewrites the whole container). The Go side answers
|
|
||||||
// method=ffmpeg when it cannot handle the file natively.
|
|
||||||
if (format == "flac" || format == "mp3" || format == "opus" || format == "m4a") {
|
|
||||||
val nativeCover = downloadCoverForMetadata(context, input)
|
|
||||||
val handledNatively = try {
|
|
||||||
val fields = JSONObject()
|
|
||||||
.put("title", title)
|
|
||||||
.put("artist", artist)
|
|
||||||
.put("album", album)
|
|
||||||
.put("album_artist", albumArtist)
|
|
||||||
.put("date", date)
|
|
||||||
.put("isrc", isrc)
|
|
||||||
.put("composer", composer)
|
|
||||||
.put("genre", genre)
|
|
||||||
.put("label", label)
|
|
||||||
.put("copyright", copyright)
|
|
||||||
if (trackNumberValue > 0) fields.put("track_number", trackNumberValue.toString())
|
|
||||||
if (totalTracksValue > 0) fields.put("track_total", totalTracksValue.toString())
|
|
||||||
if (discNumberValue > 0) fields.put("disc_number", discNumberValue.toString())
|
|
||||||
if (totalDiscsValue > 0) fields.put("disc_total", totalDiscsValue.toString())
|
|
||||||
if (nativeCover != null) fields.put("cover_path", nativeCover.absolutePath)
|
|
||||||
if (shouldEmbedLyrics) {
|
|
||||||
fields.put("lyrics", lyrics)
|
|
||||||
fields.put("unsyncedlyrics", lyrics)
|
|
||||||
}
|
|
||||||
val response = Gobackend.editFileMetadata(path, fields.toString())
|
|
||||||
val method = try {
|
|
||||||
JSONObject(response).optString("method", "")
|
|
||||||
} catch (_: Exception) {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
method != "ffmpeg"
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (format == "flac") throw e
|
|
||||||
Log.w(TAG, "Native tag embed failed for $format: ${e.message}; falling back to ffmpeg")
|
|
||||||
false
|
|
||||||
} finally {
|
|
||||||
nativeCover?.delete()
|
|
||||||
}
|
|
||||||
if (handledNatively) return
|
|
||||||
}
|
|
||||||
|
|
||||||
val ext = normalizeExt(File(path).extension).ifBlank { ".tmp" }
|
|
||||||
val inputFile = File(path)
|
|
||||||
// ".partial<ext>" keeps the temp invisible to library scans while FFmpeg
|
|
||||||
// still infers the muxer from the real trailing extension.
|
|
||||||
val temp = File(inputFile.parentFile, "${inputFile.nameWithoutExtension}_tagged.partial$ext")
|
|
||||||
val isM4a = format == "m4a"
|
|
||||||
val isOpus = format == "opus"
|
|
||||||
val coverFile = if (isM4a || isOpus) downloadCoverForMetadata(context, input) else null
|
|
||||||
val labelKey = if (isM4a) "organization" else "label"
|
|
||||||
val metadataPairs = mutableListOf(
|
|
||||||
"title" to title,
|
|
||||||
"artist" to artist,
|
|
||||||
"album" to album,
|
|
||||||
"album_artist" to albumArtist,
|
|
||||||
"date" to date,
|
|
||||||
"track" to trackNumber,
|
|
||||||
"disc" to discNumber,
|
|
||||||
"isrc" to isrc,
|
|
||||||
"composer" to composer,
|
|
||||||
"genre" to genre,
|
|
||||||
labelKey to label,
|
|
||||||
"copyright" to copyright,
|
|
||||||
"lyrics" to if (shouldEmbedLyrics) lyrics else "",
|
|
||||||
"unsyncedlyrics" to if (shouldEmbedLyrics) lyrics else "",
|
|
||||||
)
|
|
||||||
if (isOpus && coverFile != null) {
|
|
||||||
createMetadataBlockPicture(coverFile)?.let {
|
|
||||||
metadataPairs.add("METADATA_BLOCK_PICTURE" to it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val metadataArgs = metadataPairs
|
|
||||||
.filter { it.second.isNotBlank() && it.second != "0" }
|
|
||||||
.joinToString(" ") { "-metadata ${it.first}=${q(it.second)}" }
|
|
||||||
if (metadataArgs.isBlank() && coverFile == null) return
|
|
||||||
val mp3Flags = if (format == "mp3") "-id3v2_version 3 " else ""
|
|
||||||
var adoptedTemp = false
|
|
||||||
var originalDeleted = false
|
|
||||||
|
|
||||||
fun buildEmbedCommand(forceMov: Boolean): String {
|
|
||||||
return if (isM4a && coverFile != null) {
|
|
||||||
"-v error -hide_banner -i ${q(path)} -i ${q(coverFile.absolutePath)} " +
|
|
||||||
"-map 0:a -c:a copy -map_metadata 0 -map 1:v -c:v copy " +
|
|
||||||
"-disposition:v:0 attached_pic " +
|
|
||||||
"-metadata:s:v ${q("title=Album cover")} " +
|
|
||||||
"-metadata:s:v ${q("comment=Cover (front)")} " +
|
|
||||||
"$metadataArgs -f ${if (forceMov) "mov" else "mp4"} ${q(temp.absolutePath)} -y"
|
|
||||||
} else {
|
|
||||||
val movFlag = if (forceMov) "-f mov " else ""
|
|
||||||
"-v error -hide_banner -i ${q(path)} -map 0 -c copy -map_metadata 0 $metadataArgs $mp3Flags$movFlag${q(temp.absolutePath)} -y"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
var result = runFFmpeg(buildEmbedCommand(false))
|
|
||||||
// MOV muxer fallback for codecs the MP4 muxer rejects (e.g. AC-4).
|
|
||||||
if (!result.first && (isM4a || ext.equals(".mp4", ignoreCase = true))) {
|
|
||||||
temp.delete()
|
|
||||||
result = runFFmpeg(buildEmbedCommand(true))
|
|
||||||
}
|
|
||||||
if (result.first && temp.exists()) {
|
|
||||||
fsyncQuietly(temp)
|
|
||||||
// Rename directly over the original: a process kill between a
|
|
||||||
// delete-first and the rename would lose the file entirely.
|
|
||||||
adoptedTemp = temp.renameTo(inputFile)
|
|
||||||
if (!adoptedTemp && inputFile.delete()) {
|
|
||||||
originalDeleted = true
|
|
||||||
adoptedTemp = temp.renameTo(inputFile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!adoptedTemp && !originalDeleted) {
|
|
||||||
temp.delete()
|
|
||||||
}
|
|
||||||
coverFile?.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Best-effort fsync so a file's bytes are durable before it is renamed
|
|
||||||
* over another file; fsync on a fresh handle flushes the page cache pages
|
|
||||||
* written earlier by ffmpeg in this process.
|
|
||||||
*/
|
|
||||||
internal fun NativeDownloadFinalizer.fsyncQuietly(file: File) {
|
|
||||||
try {
|
|
||||||
RandomAccessFile(file, "rw").use { it.fd.sync() }
|
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun NativeDownloadFinalizer.createMetadataBlockPicture(coverFile: File): String? {
|
|
||||||
return try {
|
|
||||||
if (!coverFile.exists() || coverFile.length() <= 0L) return null
|
|
||||||
val imageData = coverFile.readBytes()
|
|
||||||
if (imageData.isEmpty()) return null
|
|
||||||
val mimeType = detectCoverMimeType(coverFile, imageData)
|
|
||||||
val mimeBytes = mimeType.toByteArray(Charsets.UTF_8)
|
|
||||||
val descriptionBytes = ByteArray(0)
|
|
||||||
val blockSize = 4 + 4 + mimeBytes.size + 4 + descriptionBytes.size + 4 + 4 + 4 + 4 + 4 + imageData.size
|
|
||||||
val buffer = ByteBuffer.allocate(blockSize)
|
|
||||||
buffer.putInt(3)
|
|
||||||
buffer.putInt(mimeBytes.size)
|
|
||||||
buffer.put(mimeBytes)
|
|
||||||
buffer.putInt(descriptionBytes.size)
|
|
||||||
buffer.put(descriptionBytes)
|
|
||||||
buffer.putInt(0)
|
|
||||||
buffer.putInt(0)
|
|
||||||
buffer.putInt(0)
|
|
||||||
buffer.putInt(0)
|
|
||||||
buffer.putInt(imageData.size)
|
|
||||||
buffer.put(imageData)
|
|
||||||
Base64.encodeToString(buffer.array(), Base64.NO_WRAP)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Failed to create Opus cover picture block: ${e.message}")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun NativeDownloadFinalizer.detectCoverMimeType(coverFile: File, imageData: ByteArray): String {
|
|
||||||
val ext = coverFile.extension.lowercase(Locale.ROOT)
|
|
||||||
if (ext == "png") return "image/png"
|
|
||||||
if (ext == "jpg" || ext == "jpeg") return "image/jpeg"
|
|
||||||
if (imageData.size >= 8 &&
|
|
||||||
imageData[0] == 0x89.toByte() &&
|
|
||||||
imageData[1] == 0x50.toByte() &&
|
|
||||||
imageData[2] == 0x4E.toByte() &&
|
|
||||||
imageData[3] == 0x47.toByte()
|
|
||||||
) {
|
|
||||||
return "image/png"
|
|
||||||
}
|
|
||||||
return "image/jpeg"
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun NativeDownloadFinalizer.downloadCoverForMetadata(context: Context, input: NativeDownloadFinalizer.FinalizeInput): File? {
|
|
||||||
val coverUrl = metadataCoverUrl(input).ifBlank { resultString(input, "cover_url") }
|
|
||||||
if (coverUrl.isBlank()) return null
|
|
||||||
|
|
||||||
val safeItemId = input.itemId.ifBlank { "item" }.replace(Regex("[^A-Za-z0-9._-]"), "_")
|
|
||||||
val output = File.createTempFile("native_cover_${safeItemId}_", ".jpg", context.cacheDir)
|
|
||||||
return try {
|
|
||||||
Gobackend.downloadCoverToFile(
|
|
||||||
coverUrl,
|
|
||||||
output.absolutePath,
|
|
||||||
input.request.optBoolean("embed_max_quality_cover", true)
|
|
||||||
)
|
|
||||||
if (output.exists() && output.length() > 0L) {
|
|
||||||
output
|
|
||||||
} else {
|
|
||||||
output.delete()
|
|
||||||
null
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Failed to download metadata cover: ${e.message}")
|
|
||||||
output.delete()
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
package com.zarz.spotiflac
|
|
||||||
|
|
||||||
import android.content.ContentValues
|
|
||||||
import android.content.Context
|
|
||||||
import android.database.sqlite.SQLiteDatabase
|
|
||||||
import android.database.sqlite.SQLiteException
|
|
||||||
import android.net.Uri
|
|
||||||
import android.util.Base64
|
|
||||||
import android.util.Log
|
|
||||||
import com.antonkarpenko.ffmpegkit.FFmpegKit
|
|
||||||
import com.antonkarpenko.ffmpegkit.FFmpegKitConfig
|
|
||||||
import com.antonkarpenko.ffmpegkit.FFmpegSession
|
|
||||||
import com.antonkarpenko.ffmpegkit.FFmpegSessionCompleteCallback
|
|
||||||
import com.antonkarpenko.ffmpegkit.LogRedirectionStrategy
|
|
||||||
import com.antonkarpenko.ffmpegkit.ReturnCode
|
|
||||||
import com.zarz.spotiflac.SafDownloadHandler.mimeTypeForExt
|
|
||||||
import com.zarz.spotiflac.SafDownloadHandler.normalizeExt
|
|
||||||
import com.zarz.spotiflac.NativeFinalizationPolicy.applyQualityVariantFilenameLabel
|
|
||||||
import com.zarz.spotiflac.NativeFinalizationPolicy.displayAudioQuality
|
|
||||||
import com.zarz.spotiflac.NativeFinalizationPolicy.formatIndexTag
|
|
||||||
import com.zarz.spotiflac.NativeFinalizationPolicy.isLosslessAudioCodec
|
|
||||||
import com.zarz.spotiflac.NativeFinalizationPolicy.isLossyAudioCodec
|
|
||||||
import com.zarz.spotiflac.NativeFinalizationPolicy.normalizeAudioCodec
|
|
||||||
import com.zarz.spotiflac.NativeFinalizationPolicy.removeQualityVariantStagingLabel
|
|
||||||
import com.zarz.spotiflac.NativeFinalizationPolicy.resolvePreferredDecryptionExtension
|
|
||||||
import gobackend.Gobackend
|
|
||||||
import org.json.JSONObject
|
|
||||||
import java.io.File
|
|
||||||
import java.io.RandomAccessFile
|
|
||||||
import java.nio.ByteBuffer
|
|
||||||
import java.util.Locale
|
|
||||||
import java.util.concurrent.CancellationException
|
|
||||||
import java.util.concurrent.CountDownLatch
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
|
||||||
import kotlin.math.pow
|
|
||||||
|
|
||||||
|
|
||||||
// Deferred SAF publish helpers for NativeDownloadFinalizer.
|
|
||||||
|
|
||||||
internal fun NativeDownloadFinalizer.promoteStagedSafOutputIfNeeded(
|
|
||||||
context: Context,
|
|
||||||
input: NativeDownloadFinalizer.FinalizeInput,
|
|
||||||
state: NativeDownloadFinalizer.FinalizeState,
|
|
||||||
) {
|
|
||||||
if (!state.filePath.startsWith("content://")) return
|
|
||||||
if (!input.result.optBoolean("saf_staged_output", false)) return
|
|
||||||
val stagedName = input.result.optString("saf_staged_file_name", "").trim()
|
|
||||||
if (stagedName.isNotEmpty() && state.fileName != stagedName) return
|
|
||||||
|
|
||||||
val localInput = materializeForFFmpeg(context, input, state)
|
|
||||||
try {
|
|
||||||
replaceStatePath(context, input, state, localInput, deleteOld = true)
|
|
||||||
} finally {
|
|
||||||
File(localInput).delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun NativeDownloadFinalizer.isDeferredSafPublish(input: NativeDownloadFinalizer.FinalizeInput): Boolean {
|
|
||||||
return input.request.optBoolean("defer_saf_publish", false) &&
|
|
||||||
input.result.optBoolean("saf_deferred_publish", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun NativeDownloadFinalizer.isDeferredSafRequest(input: NativeDownloadFinalizer.FinalizeInput): Boolean {
|
|
||||||
return input.request.optString("storage_mode", "") == "saf" &&
|
|
||||||
input.request.optBoolean("defer_saf_publish", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun NativeDownloadFinalizer.publishDeferredSafOutput(
|
|
||||||
context: Context,
|
|
||||||
input: NativeDownloadFinalizer.FinalizeInput,
|
|
||||||
state: NativeDownloadFinalizer.FinalizeState,
|
|
||||||
) {
|
|
||||||
if (!isDeferredSafPublish(input)) return
|
|
||||||
if (state.filePath.startsWith("content://")) return
|
|
||||||
|
|
||||||
val outputFile = File(state.filePath)
|
|
||||||
if (!outputFile.exists() || outputFile.length() <= 0L) {
|
|
||||||
throw IllegalStateException("deferred SAF output missing or empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
val finalName = desiredFileName(input, state, outputFile.extension)
|
|
||||||
val treeUri = input.result.optString("saf_tree_uri", "")
|
|
||||||
.ifBlank { input.request.optString("saf_tree_uri", "") }
|
|
||||||
val relativeDir = input.result.optString("saf_relative_dir", "")
|
|
||||||
.ifBlank { input.request.optString("saf_relative_dir", "") }
|
|
||||||
val mimeType = mimeTypeForExt(outputFile.extension)
|
|
||||||
val preserveQualityVariant = input.request.optBoolean("allow_quality_variant", false)
|
|
||||||
val qualityLabel = qualityVariantFilenameLabel(state).orEmpty()
|
|
||||||
val collisionOnly = preserveQualityVariant &&
|
|
||||||
input.request.optBoolean("quality_variant_collision_only", false)
|
|
||||||
val stagingLabel = input.request.optString("quality_variant", "").trim()
|
|
||||||
val logicalVariantName = input.request.optString("saf_file_name", "")
|
|
||||||
.ifBlank { finalName }
|
|
||||||
val cleanName = removeQualityVariantStagingLabel(logicalVariantName, stagingLabel)
|
|
||||||
val variantName = if (qualityLabel.isNotEmpty()) {
|
|
||||||
applyQualityVariantFilenameLabel(logicalVariantName, stagingLabel, qualityLabel)
|
|
||||||
} else {
|
|
||||||
finalName
|
|
||||||
}
|
|
||||||
|
|
||||||
var alreadyExists = false
|
|
||||||
val published = when {
|
|
||||||
collisionOnly -> SafDownloadHandler.writeFileToSafCollisionAware(
|
|
||||||
context = context,
|
|
||||||
treeUriStr = treeUri,
|
|
||||||
relativeDir = relativeDir,
|
|
||||||
cleanFileName = cleanName,
|
|
||||||
variantFileName = variantName,
|
|
||||||
mimeType = mimeType,
|
|
||||||
srcPath = outputFile.absolutePath,
|
|
||||||
preservedSuffix = qualityLabel,
|
|
||||||
)
|
|
||||||
preserveQualityVariant -> SafDownloadHandler.writeFileToSafUnique(
|
|
||||||
context = context,
|
|
||||||
treeUriStr = treeUri,
|
|
||||||
relativeDir = relativeDir,
|
|
||||||
fileName = finalName,
|
|
||||||
mimeType = mimeType,
|
|
||||||
srcPath = outputFile.absolutePath,
|
|
||||||
preservedSuffix = qualityLabel,
|
|
||||||
)
|
|
||||||
else -> SafDownloadHandler.writeFileToSafIfAbsent(
|
|
||||||
context = context,
|
|
||||||
treeUriStr = treeUri,
|
|
||||||
relativeDir = relativeDir,
|
|
||||||
fileName = finalName,
|
|
||||||
mimeType = mimeType,
|
|
||||||
srcPath = outputFile.absolutePath,
|
|
||||||
)?.let { result ->
|
|
||||||
alreadyExists = result.alreadyExists
|
|
||||||
SafDownloadHandler.UniqueWriteResult(result.uri, result.fileName)
|
|
||||||
}
|
|
||||||
} ?: throw IllegalStateException("failed to publish deferred SAF output")
|
|
||||||
val newUri = published.uri
|
|
||||||
val publishedName = published.fileName
|
|
||||||
|
|
||||||
Log.i(TAG, "Published deferred SAF output once: file=$publishedName bytes=${outputFile.length()}")
|
|
||||||
outputFile.delete()
|
|
||||||
state.filePath = newUri
|
|
||||||
state.fileName = publishedName
|
|
||||||
input.result.put("file_path", newUri)
|
|
||||||
input.result.put("file_name", publishedName)
|
|
||||||
if (alreadyExists) {
|
|
||||||
input.result.put("already_exists", true)
|
|
||||||
input.result.put("message", "File already exists")
|
|
||||||
input.result.put("publish_collision_existing", true)
|
|
||||||
}
|
|
||||||
input.result.optJSONObject("replaygain")?.let { replayGain ->
|
|
||||||
replayGain.put("file_path", newUri)
|
|
||||||
replayGain.put("file_name", publishedName)
|
|
||||||
}
|
|
||||||
if (state.pendingExternalLrc != null) {
|
|
||||||
state.pendingExternalLrcFileName = "${publishedName.replace(Regex("\\.[^.]+$"), "")}.lrc"
|
|
||||||
}
|
|
||||||
input.result.put("saf_deferred_published", true)
|
|
||||||
publishPendingDeferredExternalLrc(context, input, state)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun NativeDownloadFinalizer.publishPendingDeferredExternalLrc(
|
|
||||||
context: Context,
|
|
||||||
input: NativeDownloadFinalizer.FinalizeInput,
|
|
||||||
state: NativeDownloadFinalizer.FinalizeState,
|
|
||||||
) {
|
|
||||||
val lrc = state.pendingExternalLrc ?: return
|
|
||||||
val fileName = state.pendingExternalLrcFileName ?: return
|
|
||||||
val treeUri = input.result.optString("saf_tree_uri", "")
|
|
||||||
.ifBlank { input.request.optString("saf_tree_uri", "") }
|
|
||||||
val relativeDir = input.result.optString("saf_relative_dir", "")
|
|
||||||
.ifBlank { input.request.optString("saf_relative_dir", "") }
|
|
||||||
val temp = File(context.cacheDir, "native_lrc_${System.nanoTime()}.lrc")
|
|
||||||
try {
|
|
||||||
temp.writeText(lrc)
|
|
||||||
val newUri = SafDownloadHandler.writeFileToSaf(
|
|
||||||
context = context,
|
|
||||||
treeUriStr = treeUri,
|
|
||||||
relativeDir = relativeDir,
|
|
||||||
fileName = fileName,
|
|
||||||
mimeType = "application/octet-stream",
|
|
||||||
srcPath = temp.absolutePath,
|
|
||||||
)
|
|
||||||
if (newUri == null) {
|
|
||||||
Log.w(TAG, "Failed to publish deferred external LRC: $fileName")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Failed to publish deferred external LRC: ${e.message}")
|
|
||||||
} finally {
|
|
||||||
temp.delete()
|
|
||||||
state.pendingExternalLrc = null
|
|
||||||
state.pendingExternalLrcFileName = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package com.zarz.spotiflac
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure album ReplayGain completion policy used by DownloadService.
|
|
||||||
*/
|
|
||||||
internal object NativeReplayGainPolicy {
|
|
||||||
private val blockingStatuses = setOf(
|
|
||||||
"failed",
|
|
||||||
"skipped",
|
|
||||||
"queued",
|
|
||||||
"downloading",
|
|
||||||
"finalizing",
|
|
||||||
)
|
|
||||||
private val pendingStatuses = setOf("queued", "downloading", "finalizing")
|
|
||||||
|
|
||||||
fun eligibleEntryIndexes(
|
|
||||||
entryAlbumKeys: List<String>,
|
|
||||||
statuses: Map<String, String>,
|
|
||||||
requestAlbumKeys: Map<String, String>,
|
|
||||||
): List<Int> {
|
|
||||||
val blockedKeys = mutableSetOf<String>()
|
|
||||||
val expectedCompletedByKey = mutableMapOf<String, Int>()
|
|
||||||
for ((itemId, albumKey) in requestAlbumKeys) {
|
|
||||||
when (statuses[itemId]) {
|
|
||||||
"completed" -> {
|
|
||||||
expectedCompletedByKey[albumKey] =
|
|
||||||
(expectedCompletedByKey[albumKey] ?: 0) + 1
|
|
||||||
}
|
|
||||||
in blockingStatuses -> blockedKeys.add(albumKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val indexesByKey = entryAlbumKeys.indices.groupBy { entryAlbumKeys[it] }
|
|
||||||
val eligible = mutableListOf<Int>()
|
|
||||||
for ((albumKey, indexes) in indexesByKey) {
|
|
||||||
if (
|
|
||||||
albumKey.isBlank() ||
|
|
||||||
albumKey in blockedKeys ||
|
|
||||||
indexes.size <= 1
|
|
||||||
) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val expected = expectedCompletedByKey[albumKey] ?: continue
|
|
||||||
if (indexes.size == expected) {
|
|
||||||
eligible.addAll(indexes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return eligible
|
|
||||||
}
|
|
||||||
|
|
||||||
fun hasPendingWork(statuses: Map<String, String>): Boolean =
|
|
||||||
statuses.values.any { it in pendingStatuses }
|
|
||||||
}
|
|
||||||
@@ -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,793 +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)
|
|
||||||
data class ExistingAwareWriteResult(
|
|
||||||
val uri: String,
|
|
||||||
val fileName: String,
|
|
||||||
val alreadyExists: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun writeFileToSafCollisionAware(
|
|
||||||
context: Context,
|
|
||||||
treeUriStr: String,
|
|
||||||
relativeDir: String,
|
|
||||||
cleanFileName: String,
|
|
||||||
variantFileName: String,
|
|
||||||
mimeType: String,
|
|
||||||
srcPath: String,
|
|
||||||
preservedSuffix: String = "",
|
|
||||||
): UniqueWriteResult? {
|
|
||||||
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
|
||||||
val cleanName = sanitizeFilename(cleanFileName)
|
|
||||||
val preferredVariant = sanitizeFilenamePreservingSuffix(
|
|
||||||
variantFileName,
|
|
||||||
preservedSuffix,
|
|
||||||
)
|
|
||||||
return withSafNameLock(treeUriStr, safeRelativeDir, cleanName) {
|
|
||||||
val treeUri = Uri.parse(treeUriStr)
|
|
||||||
val targetDir = ensureDocumentDir(context, treeUri, safeRelativeDir)
|
|
||||||
?: return@withSafNameLock null
|
|
||||||
val selectedName = if (targetDir.findFile(cleanName) == null) {
|
|
||||||
cleanName
|
|
||||||
} else {
|
|
||||||
findAvailableFileName(targetDir, preferredVariant, preservedSuffix)
|
|
||||||
}
|
|
||||||
val uri = writeFileToSafLocked(
|
|
||||||
context,
|
|
||||||
treeUriStr,
|
|
||||||
safeRelativeDir,
|
|
||||||
selectedName,
|
|
||||||
srcPath,
|
|
||||||
) ?: return@withSafNameLock null
|
|
||||||
UniqueWriteResult(uri = uri, fileName = selectedName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun writeFileToSafIfAbsent(
|
|
||||||
context: Context,
|
|
||||||
treeUriStr: String,
|
|
||||||
relativeDir: String,
|
|
||||||
fileName: String,
|
|
||||||
mimeType: String,
|
|
||||||
srcPath: String,
|
|
||||||
): ExistingAwareWriteResult? {
|
|
||||||
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
|
||||||
val finalName = sanitizeFilename(fileName)
|
|
||||||
return withSafNameLock(treeUriStr, safeRelativeDir, finalName) {
|
|
||||||
val treeUri = Uri.parse(treeUriStr)
|
|
||||||
val targetDir = ensureDocumentDir(context, treeUri, safeRelativeDir)
|
|
||||||
?: return@withSafNameLock null
|
|
||||||
val existing = targetDir.findFile(finalName)
|
|
||||||
if (existing != null && existing.isFile && existing.length() > 0L) {
|
|
||||||
return@withSafNameLock ExistingAwareWriteResult(
|
|
||||||
uri = existing.uri.toString(),
|
|
||||||
fileName = existing.name ?: finalName,
|
|
||||||
alreadyExists = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val uri = writeFileToSafLocked(
|
|
||||||
context,
|
|
||||||
treeUriStr,
|
|
||||||
safeRelativeDir,
|
|
||||||
finalName,
|
|
||||||
srcPath,
|
|
||||||
) ?: return@withSafNameLock null
|
|
||||||
ExistingAwareWriteResult(
|
|
||||||
uri = uri,
|
|
||||||
fileName = finalName,
|
|
||||||
alreadyExists = false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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: 2.8 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,4 +1,12 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="?android:colorBackground" />
|
<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>
|
</layer-list>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1,4 +1,12 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="@android:color/white" />
|
<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>
|
</layer-list>
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:shape="rectangle">
|
|
||||||
<solid android:color="@color/widgetBackground" />
|
|
||||||
<corners android:radius="16dp" />
|
|
||||||
</shape>
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:id="@+id/widget_root"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:background="@drawable/widget_download_bg"
|
|
||||||
android:gravity="center_vertical"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:padding="12dp">
|
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="36dp"
|
|
||||||
android:layout_height="36dp"
|
|
||||||
android:contentDescription="@null"
|
|
||||||
android:src="@mipmap/ic_launcher" />
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="12dp"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:orientation="vertical">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/widget_title"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:ellipsize="end"
|
|
||||||
android:maxLines="1"
|
|
||||||
android:textColor="@color/widgetTextPrimary"
|
|
||||||
android:textSize="13sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/widget_subtitle"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:ellipsize="end"
|
|
||||||
android:maxLines="1"
|
|
||||||
android:textColor="@color/widgetTextSecondary"
|
|
||||||
android:textSize="11sp" />
|
|
||||||
|
|
||||||
<ProgressBar
|
|
||||||
android:id="@+id/widget_progress"
|
|
||||||
style="@android:style/Widget.ProgressBar.Horizontal"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="4dp"
|
|
||||||
android:layout_marginTop="6dp"
|
|
||||||
android:max="100"
|
|
||||||
android:progressTint="@color/widgetAccent" />
|
|
||||||
</LinearLayout>
|
|
||||||
</LinearLayout>
|
|
||||||
@@ -6,9 +6,4 @@
|
|||||||
android:drawable="@drawable/ic_launcher_foreground"
|
android:drawable="@drawable/ic_launcher_foreground"
|
||||||
android:inset="16%" />
|
android:inset="16%" />
|
||||||
</foreground>
|
</foreground>
|
||||||
<monochrome>
|
|
||||||
<inset
|
|
||||||
android:drawable="@drawable/ic_launcher_monochrome"
|
|
||||||
android:inset="16%" />
|
|
||||||
</monochrome>
|
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 954 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 647 B |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="widgetBackground">@android:color/system_neutral1_900</color>
|
|
||||||
<color name="widgetTextPrimary">@android:color/system_neutral1_50</color>
|
|
||||||
<color name="widgetTextSecondary">@android:color/system_neutral2_200</color>
|
|
||||||
<color name="widgetAccent">@android:color/system_accent1_200</color>
|
|
||||||
</resources>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="widgetBackground">#1C1B1F</color>
|
|
||||||
<color name="widgetTextPrimary">#E6E1E5</color>
|
|
||||||
<color name="widgetTextSecondary">#CAC4D0</color>
|
|
||||||
</resources>
|
|
||||||
@@ -1,8 +1,17 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<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">
|
<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>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
</style>
|
</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">
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="widgetBackground">@android:color/system_neutral1_10</color>
|
|
||||||
<color name="widgetTextPrimary">@android:color/system_neutral1_900</color>
|
|
||||||
<color name="widgetTextSecondary">@android:color/system_neutral2_700</color>
|
|
||||||
<color name="widgetAccent">@android:color/system_accent1_600</color>
|
|
||||||
</resources>
|
|
||||||
@@ -1,8 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#000000</color>
|
<color name="ic_launcher_background">#000000</color>
|
||||||
<color name="widgetBackground">#F3F3F3</color>
|
|
||||||
<color name="widgetTextPrimary">#1C1B1F</color>
|
|
||||||
<color name="widgetTextSecondary">#49454F</color>
|
|
||||||
<color name="widgetAccent">#1DB954</color>
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,8 +1,17 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<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">
|
<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>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
</style>
|
</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">
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
</style>
|
</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,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:initialLayout="@layout/widget_download_queue"
|
|
||||||
android:minWidth="250dp"
|
|
||||||
android:minHeight="40dp"
|
|
||||||
android:resizeMode="horizontal"
|
|
||||||
android:updatePeriodMillis="0"
|
|
||||||
android:widgetCategory="home_screen" />
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
package com.zarz.spotiflac
|
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertFalse
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class NativeFinalizationPolicyTest {
|
|
||||||
@Test
|
|
||||||
fun matchesSharedCrossPipelineQualityCases() {
|
|
||||||
val stream = checkNotNull(
|
|
||||||
javaClass.getResourceAsStream("/finalization_quality_cases.tsv"),
|
|
||||||
)
|
|
||||||
stream.bufferedReader().useLines { lines ->
|
|
||||||
for (line in lines) {
|
|
||||||
if (line.isBlank() || line.startsWith("#")) continue
|
|
||||||
val fields = line.split('\t')
|
|
||||||
assertEquals("invalid shared fixture: $line", 6, fields.size)
|
|
||||||
val actual = NativeFinalizationPolicy.qualityVariantFilenameLabel(
|
|
||||||
measuredQuality = fields[4],
|
|
||||||
bitDepth = fields[1].toIntOrNull(),
|
|
||||||
sampleRate = fields[2].toIntOrNull(),
|
|
||||||
bitrateKbps = fields[3].toIntOrNull(),
|
|
||||||
audioCodec = fields[0],
|
|
||||||
)
|
|
||||||
val expected = fields[5].takeUnless { it == "<null>" }
|
|
||||||
assertEquals("shared fixture: $line", expected, actual)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun codecAliasesDriveLossyAndLosslessDecisions() {
|
|
||||||
assertEquals("aac", NativeFinalizationPolicy.normalizeAudioCodec("mp4a"))
|
|
||||||
assertEquals("eac3", NativeFinalizationPolicy.normalizeAudioCodec("ec-3"))
|
|
||||||
assertEquals("opus", NativeFinalizationPolicy.normalizeAudioCodec("ogg"))
|
|
||||||
assertTrue(NativeFinalizationPolicy.isLossyAudioCodec("ac-4"))
|
|
||||||
assertTrue(NativeFinalizationPolicy.isLosslessAudioCodec("pcm_s24le"))
|
|
||||||
assertTrue(NativeFinalizationPolicy.isLosslessAudioCodec("ALAC"))
|
|
||||||
assertFalse(NativeFinalizationPolicy.isLosslessAudioCodec("aac"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun displayQualityUsesMeasuredLosslessSpecifications() {
|
|
||||||
assertEquals(
|
|
||||||
"24-bit/96kHz",
|
|
||||||
NativeFinalizationPolicy.displayAudioQuality(
|
|
||||||
filePath = "/music/track.flac",
|
|
||||||
fileName = "track.flac",
|
|
||||||
bitDepth = 24,
|
|
||||||
sampleRate = 96000,
|
|
||||||
bitrateKbps = null,
|
|
||||||
audioCodec = "flac",
|
|
||||||
storedQuality = "HI_RES",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
"24-bit/44.1kHz",
|
|
||||||
NativeFinalizationPolicy.displayAudioQuality(
|
|
||||||
filePath = "/music/track.flac",
|
|
||||||
fileName = "track.flac",
|
|
||||||
bitDepth = 24,
|
|
||||||
sampleRate = 44100,
|
|
||||||
bitrateKbps = null,
|
|
||||||
audioCodec = "flac",
|
|
||||||
storedQuality = "LOSSLESS",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun displayQualityPreservesUsefulLossyLabels() {
|
|
||||||
assertEquals(
|
|
||||||
"OPUS 192kbps",
|
|
||||||
NativeFinalizationPolicy.displayAudioQuality(
|
|
||||||
filePath = "/music/track.ogg",
|
|
||||||
fileName = "track.ogg",
|
|
||||||
bitDepth = null,
|
|
||||||
sampleRate = 48000,
|
|
||||||
bitrateKbps = 192,
|
|
||||||
audioCodec = "opus",
|
|
||||||
storedQuality = "HIGH",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
"AAC",
|
|
||||||
NativeFinalizationPolicy.displayAudioQuality(
|
|
||||||
filePath = "/music/track.m4a",
|
|
||||||
fileName = "track.m4a",
|
|
||||||
bitDepth = null,
|
|
||||||
sampleRate = 48000,
|
|
||||||
bitrateKbps = null,
|
|
||||||
audioCodec = "aac",
|
|
||||||
storedQuality = "LOSSLESS",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun qualityVariantLabelRejectsUnmeasuredPlaceholders() {
|
|
||||||
assertNull(
|
|
||||||
NativeFinalizationPolicy.qualityVariantFilenameLabel(
|
|
||||||
measuredQuality = "LOSSLESS",
|
|
||||||
bitDepth = null,
|
|
||||||
sampleRate = null,
|
|
||||||
bitrateKbps = null,
|
|
||||||
audioCodec = "flac",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
"24bit-96kHz",
|
|
||||||
NativeFinalizationPolicy.qualityVariantFilenameLabel(
|
|
||||||
measuredQuality = "24-bit/96kHz",
|
|
||||||
bitDepth = null,
|
|
||||||
sampleRate = null,
|
|
||||||
bitrateKbps = null,
|
|
||||||
audioCodec = "flac",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
"320kbps",
|
|
||||||
NativeFinalizationPolicy.qualityVariantFilenameLabel(
|
|
||||||
measuredQuality = "MP3 320kbps",
|
|
||||||
bitDepth = null,
|
|
||||||
sampleRate = null,
|
|
||||||
bitrateKbps = null,
|
|
||||||
audioCodec = "mp3",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun finalQualityLabelReplacesOnlyTheStagingToken() {
|
|
||||||
assertEquals(
|
|
||||||
"Artist - Track - 24bit-96kHz.flac",
|
|
||||||
NativeFinalizationPolicy.applyQualityVariantFilenameLabel(
|
|
||||||
fileName = "Artist - Track - pending.flac",
|
|
||||||
stagingLabel = "pending",
|
|
||||||
qualityLabel = "24bit-96kHz",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
"Artist - Track - 24bit-96kHz.flac",
|
|
||||||
NativeFinalizationPolicy.applyQualityVariantFilenameLabel(
|
|
||||||
fileName = "Artist - Track.flac",
|
|
||||||
stagingLabel = "",
|
|
||||||
qualityLabel = "24bit-96kHz",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun measuredQualityIsAddedOnlyAfterCleanNameCollision() {
|
|
||||||
val stagedName = "Artist - Track - qv_ab12cd34.flac"
|
|
||||||
assertEquals(
|
|
||||||
"Artist - Track.flac",
|
|
||||||
NativeFinalizationPolicy.removeQualityVariantStagingLabel(
|
|
||||||
fileName = stagedName,
|
|
||||||
stagingLabel = "qv_ab12cd34",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
"Artist - Track.flac",
|
|
||||||
NativeFinalizationPolicy.resolveQualityVariantFilename(
|
|
||||||
fileName = stagedName,
|
|
||||||
stagingLabel = "qv_ab12cd34",
|
|
||||||
qualityLabel = "24bit-96kHz",
|
|
||||||
collisionOnly = true,
|
|
||||||
cleanNameExists = false,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
"Artist - Track - 24bit-96kHz.flac",
|
|
||||||
NativeFinalizationPolicy.resolveQualityVariantFilename(
|
|
||||||
fileName = stagedName,
|
|
||||||
stagingLabel = "qv_ab12cd34",
|
|
||||||
qualityLabel = "24bit-96kHz",
|
|
||||||
collisionOnly = true,
|
|
||||||
cleanNameExists = true,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun deferredSafNamingNeverPublishesTheNativeCacheName() {
|
|
||||||
val logicalName = NativeFinalizationPolicy.logicalOutputFileName(
|
|
||||||
deferredSafPublish = true,
|
|
||||||
resultSafFileName = "Sunidhi Chauhan - Aisa Jadoo - qv_ab12cd34.flac",
|
|
||||||
requestSafFileName = "fallback.flac",
|
|
||||||
currentFileName = "native_saf_work_603020549715656640.m4a",
|
|
||||||
)
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
"Sunidhi Chauhan - Aisa Jadoo - 16bit-44.1kHz.flac",
|
|
||||||
NativeFinalizationPolicy.applyQualityVariantFilenameLabel(
|
|
||||||
fileName = logicalName,
|
|
||||||
stagingLabel = "qv_ab12cd34",
|
|
||||||
qualityLabel = "16bit-44.1kHz",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun nonSafNamingStillFollowsTheCurrentConvertedFile() {
|
|
||||||
assertEquals(
|
|
||||||
"converted.flac",
|
|
||||||
NativeFinalizationPolicy.logicalOutputFileName(
|
|
||||||
deferredSafPublish = false,
|
|
||||||
resultSafFileName = "ignored.flac",
|
|
||||||
requestSafFileName = "ignored-too.flac",
|
|
||||||
currentFileName = "converted.flac",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun decryptionExtensionAndIndexTagsHaveStableFallbacks() {
|
|
||||||
assertEquals(
|
|
||||||
".m4a",
|
|
||||||
NativeFinalizationPolicy.resolvePreferredDecryptionExtension(
|
|
||||||
inputPath = "/music/encrypted.bin",
|
|
||||||
requested = "M4A",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
".flac",
|
|
||||||
NativeFinalizationPolicy.resolvePreferredDecryptionExtension(
|
|
||||||
inputPath = "/music/encrypted.m4a",
|
|
||||||
requested = "",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertEquals("3/12", NativeFinalizationPolicy.formatIndexTag(3, 12))
|
|
||||||
assertEquals("3", NativeFinalizationPolicy.formatIndexTag(3, 0))
|
|
||||||
assertEquals("0", NativeFinalizationPolicy.formatIndexTag(0, 12))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +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 NativeReplayGainPolicyTest {
|
|
||||||
@Test
|
|
||||||
fun completeAlbumRequiresEveryCompletedRequestEntry() {
|
|
||||||
val eligible = NativeReplayGainPolicy.eligibleEntryIndexes(
|
|
||||||
entryAlbumKeys = listOf("album:a", "album:a"),
|
|
||||||
statuses = mapOf("one" to "completed", "two" to "completed"),
|
|
||||||
requestAlbumKeys = mapOf("one" to "album:a", "two" to "album:a"),
|
|
||||||
)
|
|
||||||
|
|
||||||
assertEquals(listOf(0, 1), eligible)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun incompleteOrFailedAlbumIsBlocked() {
|
|
||||||
assertTrue(
|
|
||||||
NativeReplayGainPolicy.eligibleEntryIndexes(
|
|
||||||
entryAlbumKeys = listOf("album:a"),
|
|
||||||
statuses = mapOf("one" to "completed", "two" to "downloading"),
|
|
||||||
requestAlbumKeys = mapOf("one" to "album:a", "two" to "album:a"),
|
|
||||||
).isEmpty(),
|
|
||||||
)
|
|
||||||
assertTrue(
|
|
||||||
NativeReplayGainPolicy.eligibleEntryIndexes(
|
|
||||||
entryAlbumKeys = listOf("album:a", "album:a"),
|
|
||||||
statuses = mapOf("one" to "completed", "two" to "failed"),
|
|
||||||
requestAlbumKeys = mapOf("one" to "album:a", "two" to "album:a"),
|
|
||||||
).isEmpty(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun singleTrackAndBlankAlbumKeysAreNeverWrittenAsAlbumGain() {
|
|
||||||
val eligible = NativeReplayGainPolicy.eligibleEntryIndexes(
|
|
||||||
entryAlbumKeys = listOf("album:single", "", ""),
|
|
||||||
statuses = mapOf(
|
|
||||||
"single" to "completed",
|
|
||||||
"blank-one" to "completed",
|
|
||||||
"blank-two" to "completed",
|
|
||||||
),
|
|
||||||
requestAlbumKeys = mapOf(
|
|
||||||
"single" to "album:single",
|
|
||||||
"blank-one" to "",
|
|
||||||
"blank-two" to "",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assertTrue(eligible.isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun albumsAreEvaluatedIndependently() {
|
|
||||||
val eligible = NativeReplayGainPolicy.eligibleEntryIndexes(
|
|
||||||
entryAlbumKeys = listOf("album:a", "album:a", "album:b", "album:b"),
|
|
||||||
statuses = mapOf(
|
|
||||||
"a1" to "completed",
|
|
||||||
"a2" to "completed",
|
|
||||||
"b1" to "completed",
|
|
||||||
"b2" to "queued",
|
|
||||||
),
|
|
||||||
requestAlbumKeys = mapOf(
|
|
||||||
"a1" to "album:a",
|
|
||||||
"a2" to "album:a",
|
|
||||||
"b1" to "album:b",
|
|
||||||
"b2" to "album:b",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assertEquals(listOf(0, 1), eligible)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun pendingWorkOnlyIncludesRunnableStatuses() {
|
|
||||||
assertTrue(
|
|
||||||
NativeReplayGainPolicy.hasPendingWork(
|
|
||||||
mapOf("one" to "completed", "two" to "finalizing"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertFalse(
|
|
||||||
NativeReplayGainPolicy.hasPendingWork(
|
|
||||||
mapOf("one" to "completed", "two" to "failed"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
# codec bit_depth sample_rate bitrate_kbps measured_quality expected_label
|
|
||||||
flac 24 96000 LOSSLESS 24bit-96kHz
|
|
||||||
flac LOSSLESS <null>
|
|
||||||
flac 24-bit/44.1kHz 24bit-44.1kHz
|
|
||||||
mp3 320 HIGH 320kbps
|
|
||||||
opus OPUS 192kbps 192kbps
|
|
||||||
|
@@ -11,8 +11,8 @@ subprojects {
|
|||||||
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
|
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
|
||||||
compileOptions {
|
compileOptions {
|
||||||
isCoreLibraryDesugaringEnabled = true
|
isCoreLibraryDesugaringEnabled = true
|
||||||
sourceCompatibility = JavaVersion.VERSION_25
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_25
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable multidex for all subprojects
|
// Enable multidex for all subprojects
|
||||||
@@ -27,7 +27,7 @@ subprojects {
|
|||||||
|
|
||||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||||
compilerOptions {
|
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=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
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
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
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
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-all.zip
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "9.3.1" apply false
|
id("com.android.application") version "8.13.2" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.4.10" apply false
|
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "SpotiFLAC Mobile Source",
|
"name": "SpotiFLAC Source",
|
||||||
"identifier": "com.zarzet.spotiflac.source",
|
"identifier": "com.zarzet.spotiflac.source",
|
||||||
"subtitle": "FLAC Downloader for iOS",
|
"subtitle": "FLAC Downloader for iOS",
|
||||||
"apps": [
|
"apps": [
|
||||||
{
|
{
|
||||||
"name": "SpotiFLAC Mobile",
|
"name": "SpotiFLAC",
|
||||||
"bundleIdentifier": "com.zarzet.spotiflac",
|
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||||
"developerName": "zarzet",
|
"developerName": "zarzet",
|
||||||
"version": "4.8.0",
|
"version": "3.9.0",
|
||||||
"versionDate": "2026-07-25",
|
"versionDate": "2026-03-25",
|
||||||
"downloadURL": "https://github.com/spotiflacapp/SpotiFLAC-Mobile/releases/download/v4.8.0/SpotiFLAC-v4.8.0-ios-unsigned.ipa",
|
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.9.0/SpotiFLAC-v3.9.0-ios-unsigned.ipa",
|
||||||
"localizedDescription": "SpotiFLAC Mobile is written in Flutter and uses installable extensions for metadata and audio downloads.",
|
"localizedDescription": "Mobile version of SpotiFLAC written in Flutter. Download Tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
|
||||||
"iconURL": "https://raw.githubusercontent.com/spotiflacapp/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
||||||
"size": 37939793
|
"size": 34477323
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 539 KiB After Width: | Height: | Size: 539 KiB |
|
Before Width: | Height: | Size: 811 KiB After Width: | Height: | Size: 811 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 70 KiB |
@@ -5,7 +5,7 @@
|
|||||||
# Template for the changelog body
|
# Template for the changelog body
|
||||||
body = """
|
body = """
|
||||||
{%- macro remote_url() -%}
|
{%- macro remote_url() -%}
|
||||||
https://github.com/spotiflacapp/SpotiFLAC-Mobile
|
https://github.com/zarzet/SpotiFLAC-Mobile
|
||||||
{%- endmacro -%}
|
{%- endmacro -%}
|
||||||
|
|
||||||
{% if version %}\
|
{% if version %}\
|
||||||
@@ -99,5 +99,5 @@ tag_pattern = "v[0-9].*"
|
|||||||
sort_commits = "newest"
|
sort_commits = "newest"
|
||||||
|
|
||||||
[remote.github]
|
[remote.github]
|
||||||
owner = "spotiflacapp"
|
owner = "zarzet"
|
||||||
repo = "SpotiFLAC-Mobile"
|
repo = "SpotiFLAC-Mobile"
|
||||||
|
|||||||
@@ -3,19 +3,17 @@ files:
|
|||||||
translation: /lib/l10n/arb/app_%locale%.arb
|
translation: /lib/l10n/arb/app_%locale%.arb
|
||||||
languages_mapping:
|
languages_mapping:
|
||||||
locale:
|
locale:
|
||||||
# Keys MUST be the project's Crowdin language ids; values are the
|
# Short codes for single-variant languages
|
||||||
# %locale% suffix used in app_%locale%.arb (underscores so Flutter
|
|
||||||
# gen-l10n parses them — hyphenated filenames break gen-l10n).
|
|
||||||
# Locales below the 70% translation threshold (ar, hi, nl,
|
|
||||||
# zh-CN, zh-TW) are unmapped so `crowdin pull` doesn't recreate
|
|
||||||
# their arb files; re-add a mapping line when one qualifies.
|
|
||||||
de: de
|
de: de
|
||||||
es-ES: es_ES
|
es: es
|
||||||
fr: fr
|
fr: fr
|
||||||
|
hi: hi
|
||||||
id: id
|
id: id
|
||||||
ja: ja
|
ja: ja
|
||||||
ko: ko
|
ko: ko
|
||||||
pt-PT: pt_PT
|
nl: nl
|
||||||
|
pt: pt
|
||||||
ru: ru
|
ru: ru
|
||||||
tr: tr
|
# Full codes for Chinese variants
|
||||||
uk: uk
|
zh-CN: zh_CN
|
||||||
|
zh-TW: zh_TW
|
||||||
|
|||||||
@@ -1,242 +0,0 @@
|
|||||||
# SpotiFLAC Mobile Extension Development
|
|
||||||
|
|
||||||
This guide defines the extension package and manifest contract implemented by
|
|
||||||
the current SpotiFLAC Mobile codebase. The expanded runtime API reference is
|
|
||||||
available at <https://spotiflac.zarz.moe/docs>.
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
Create a directory with these two root files:
|
|
||||||
|
|
||||||
```text
|
|
||||||
my-extension/
|
|
||||||
├── manifest.json
|
|
||||||
└── index.js
|
|
||||||
```
|
|
||||||
|
|
||||||
Use the current camel-case manifest schema:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "my-extension",
|
|
||||||
"displayName": "My Extension",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "What this extension provides",
|
|
||||||
"homepage": "https://github.com/you/my-extension",
|
|
||||||
"type": ["metadata_provider"],
|
|
||||||
"minAppVersion": "4.2.3",
|
|
||||||
"permissions": {
|
|
||||||
"network": ["api.example.com", "*.example.com"],
|
|
||||||
"storage": false,
|
|
||||||
"file": false
|
|
||||||
},
|
|
||||||
"settings": []
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Register the implementation from `index.js`:
|
|
||||||
|
|
||||||
```js
|
|
||||||
registerExtension({
|
|
||||||
searchTracks: async function (query, limit) {
|
|
||||||
const response = await http.get(
|
|
||||||
"https://api.example.com/search?q=" +
|
|
||||||
encodeURIComponent(query) +
|
|
||||||
"&limit=" +
|
|
||||||
String(limit)
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
tracks: response.data.items.map((item) => ({
|
|
||||||
id: String(item.id),
|
|
||||||
name: item.title,
|
|
||||||
artist: item.artist,
|
|
||||||
album_name: item.album,
|
|
||||||
duration_ms: item.duration_ms,
|
|
||||||
cover_url: item.cover_url
|
|
||||||
})),
|
|
||||||
total: response.data.total
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Package the contents—not their parent directory—as ZIP:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd my-extension
|
|
||||||
zip -r ../my-extension.sflx manifest.json index.js
|
|
||||||
```
|
|
||||||
|
|
||||||
An extension package is a plain ZIP archive renamed to `.sflx`. The longer
|
|
||||||
`.spotiflac-ext` suffix is the legacy alias; both are accepted everywhere
|
|
||||||
(manual import, repo downloads) and the layout is identical. Use `.sflx` for
|
|
||||||
new packages.
|
|
||||||
|
|
||||||
`manifest.json` and `index.js` must be unique files at the archive root.
|
|
||||||
SpotiFLAC Mobile rejects traversal paths, symlinks, duplicate paths, oversized
|
|
||||||
manifests, and archives whose extracted size exceeds the safety limit.
|
|
||||||
|
|
||||||
## Manifest contract
|
|
||||||
|
|
||||||
The parser lives in
|
|
||||||
[`go_backend/extension_manifest.go`](../go_backend/extension_manifest.go).
|
|
||||||
Use these exact field names:
|
|
||||||
|
|
||||||
| Field | Required | Contract |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `name` | yes | Stable lowercase ID matching `^[a-z0-9][a-z0-9._-]{0,127}$` |
|
|
||||||
| `displayName` | recommended | Human-readable Store and settings label |
|
|
||||||
| `version` | yes | Numeric dotted version used by upgrade comparison |
|
|
||||||
| `description` | yes | Human-readable purpose |
|
|
||||||
| `type` | yes | Array containing `metadata_provider`, `download_provider`, or `lyrics_provider` |
|
|
||||||
| `permissions` | yes | Capability object described below |
|
|
||||||
| `homepage`, `icon`, `minAppVersion` | no | `icon` is a path inside the package |
|
|
||||||
| `settings` | no | Extension settings shown by the app |
|
|
||||||
| `qualityOptions` | download provider | Download quality IDs passed to `download()` |
|
|
||||||
| `searchBehavior` | no | Generic search-tab behavior |
|
|
||||||
| `urlHandler` | no | URL matching declarations |
|
|
||||||
| `trackMatching` | no | Generic matching strategy |
|
|
||||||
| `postProcessing` | no | Generic post-processing hooks |
|
|
||||||
| `serviceHealth` | no | Health checks shown by the app |
|
|
||||||
| `signedSession` | no | Signed-session bootstrap contract |
|
|
||||||
| `requiredRuntimeFeatures` | no | Runtime feature requirements |
|
|
||||||
| `capabilities` | no | Generic extension capability declarations |
|
|
||||||
|
|
||||||
The behavior flags currently supported are `skipMetadataEnrichment`,
|
|
||||||
`skipLyrics`, `stopProviderFallback`, and `skipBuiltInFallback`. New
|
|
||||||
extension-specific behavior must be added as a generic manifest capability;
|
|
||||||
the host must not branch on a particular provider ID.
|
|
||||||
|
|
||||||
Extensions that depend on canonical gateway/provider error ownership should
|
|
||||||
declare `requiredRuntimeFeatures: ["signedSession@2"]`. Version 2 only mutates
|
|
||||||
gateway session state for the exact `SESSION_INVALID/bootstrap_session` or
|
|
||||||
`VERIFY_REQUIRED/verify` contracts, exposes canonical error fields to JS, and
|
|
||||||
keeps provider-owned failures separate. `403 REQUEST_AUTH_INVALID` preserves
|
|
||||||
the session and only retries once when a newer session generation is already
|
|
||||||
available. BYOA
|
|
||||||
reauthentication remains a separate provider action. An active generation that
|
|
||||||
receives canonical `428 VERIFY_REQUIRED` is blocked in the shared coordinator,
|
|
||||||
so later requests join the same verification flow instead of hitting the
|
|
||||||
gateway repeatedly.
|
|
||||||
|
|
||||||
Extensions that depend on provider retry modes should require
|
|
||||||
`signedSession@3`. The runtime exposes `retryMode` and only auto-retries
|
|
||||||
`PROVIDER_UNAVAILABLE` when `retryable: true` is paired with
|
|
||||||
`retry_mode: "same_operation"`. Every such retry is newly signed with a fresh
|
|
||||||
timestamp and nonce. `new_ticket`, `poll_existing`, `none`, missing, and unknown
|
|
||||||
modes are returned to the extension without automatic replay.
|
|
||||||
|
|
||||||
Do not use legacy spellings such as `display_name`, `types`,
|
|
||||||
`permissions.network.domains`, or an object for `permissions.network`.
|
|
||||||
|
|
||||||
### Permissions
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"network": ["api.example.com", "*.cdn.example.com"],
|
|
||||||
"storage": true,
|
|
||||||
"file": false,
|
|
||||||
"allowHttp": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `network` is an array of allowed host names. HTTPS is required unless
|
|
||||||
`allowHttp` is explicitly enabled.
|
|
||||||
- `storage` is required for extension storage and signed-session state.
|
|
||||||
- `file` is required for file and raw FFmpeg capabilities.
|
|
||||||
|
|
||||||
Request only what the extension needs. The runtime denies undeclared network,
|
|
||||||
storage, and file access.
|
|
||||||
|
|
||||||
## Downloading files
|
|
||||||
|
|
||||||
Extensions with `permissions.file: true` can stream a remote file into their
|
|
||||||
allowed output path:
|
|
||||||
|
|
||||||
```js
|
|
||||||
const result = file.download(downloadUrl, outputPath, {
|
|
||||||
headers: {
|
|
||||||
"User-Agent": "My Extension/1.0"
|
|
||||||
},
|
|
||||||
onProgress: function (written, total) {
|
|
||||||
log.debug("Downloaded", written, "of", total, "bytes");
|
|
||||||
},
|
|
||||||
resume: true
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
The third argument is optional and supports:
|
|
||||||
|
|
||||||
| Option | Type | Default | Contract |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `headers` | object | `{}` | Additional request headers. Do not set `Range` when using runtime-managed resume. |
|
|
||||||
| `onProgress` | function | none | Called as `onProgress(writtenBytes, totalBytes)` when the total is known. |
|
|
||||||
| `trackItemBytes` | boolean | `true` | Publishes byte progress to the host download queue. The legacy alias `track_item_bytes` is also accepted. |
|
|
||||||
| `resume` | boolean | `false` | Allows up to three mid-body Range resumes for the normal streaming mode. |
|
|
||||||
| `chunked` | boolean or positive number | `false` | Uses sequential Range requests. `true` selects 1 MiB chunks; a positive number sets the chunk size in bytes. |
|
|
||||||
|
|
||||||
`resume` is deliberately opt-in. It is attempted only when the server returns
|
|
||||||
a strong `ETag` or `Last-Modified` validator. Resumed responses must return the
|
|
||||||
expected `206 Content-Range`; if the server returns `200`, the staged file is
|
|
||||||
truncated and restarted from byte zero. Enable it only when the origin
|
|
||||||
guarantees that the same URL and validator identify byte-identical content
|
|
||||||
across retries and network changes. A CDN can otherwise splice bytes from two
|
|
||||||
different objects into one apparently successful file.
|
|
||||||
|
|
||||||
Use `chunked` for origins that require bounded Range requests, such as some
|
|
||||||
media CDNs. Chunked mode has its own per-chunk retries and does not use the
|
|
||||||
`resume` option. In every mode SpotiFLAC Mobile writes to a staged sibling file
|
|
||||||
and publishes the final path only after the download completes successfully.
|
|
||||||
|
|
||||||
## Store registry integrity
|
|
||||||
|
|
||||||
Repository maintainers should publish a SHA-256 digest for every package:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"extensions": [
|
|
||||||
{
|
|
||||||
"id": "my-extension",
|
|
||||||
"name": "my-extension",
|
|
||||||
"display_name": "My Extension",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "What this extension provides",
|
|
||||||
"category": "metadata",
|
|
||||||
"download_url": "https://example.com/my-extension.sflx",
|
|
||||||
"sha256": "64-lowercase-hex-characters"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Generate the digest after building the package:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sha256sum my-extension.sflx
|
|
||||||
```
|
|
||||||
|
|
||||||
Store downloads with a published digest are written to a temporary file,
|
|
||||||
hashed, and only moved into place after the digest matches. A mismatch aborts
|
|
||||||
installation and preserves any previously cached package. Legacy registry
|
|
||||||
entries without `sha256` remain compatible but cannot provide package
|
|
||||||
integrity verification.
|
|
||||||
|
|
||||||
A checksum authenticates a package only as strongly as the HTTPS registry that
|
|
||||||
publishes it. A manually imported `.spotiflac-ext` or `.sflx` package has no
|
|
||||||
registry trust context, so install manual packages only from a publisher you
|
|
||||||
trust.
|
|
||||||
|
|
||||||
## Compatibility checklist
|
|
||||||
|
|
||||||
Before publishing:
|
|
||||||
|
|
||||||
1. Validate that `manifest.json` uses the exact current field names.
|
|
||||||
2. Keep `manifest.json` and `index.js` at the archive root.
|
|
||||||
3. Declare every network host and runtime permission used.
|
|
||||||
4. Set `minAppVersion` when relying on a recently added capability.
|
|
||||||
5. Test install, enable, disable, upgrade, and removal.
|
|
||||||
6. Publish the package SHA-256 in the repository registry.
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -16,8 +16,9 @@ const (
|
|||||||
apeTagFlagHeader = 1 << 29 // bit 29: this is the header, not the footer
|
apeTagFlagHeader = 1 << 29 // bit 29: this is the header, not the footer
|
||||||
apeTagFlagReadOnly = 1 << 0
|
apeTagFlagReadOnly = 1 << 0
|
||||||
// Item flags: bits 1-2 encode content type
|
// Item flags: bits 1-2 encode content type
|
||||||
// (00: UTF-8 text, 01: binary data, 10: external link)
|
apeItemFlagUTF8 = 0 << 1 // 00: UTF-8 text
|
||||||
apeItemFlagBinary = 1 << 1
|
apeItemFlagBinary = 1 << 1 // 01: binary data
|
||||||
|
apeItemFlagLink = 2 << 1 // 10: external link
|
||||||
)
|
)
|
||||||
|
|
||||||
// APETagItem represents a single key-value item in an APEv2 tag.
|
// APETagItem represents a single key-value item in an APEv2 tag.
|
||||||
@@ -55,6 +56,7 @@ func ReadAPETags(filePath string) (*APETag, error) {
|
|||||||
return nil, fmt.Errorf("file too small for APE tag")
|
return nil, fmt.Errorf("file too small for APE tag")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try to find APE tag footer at the end of file.
|
||||||
// The footer is the last 32 bytes before any ID3v1 tag (128 bytes).
|
// The footer is the last 32 bytes before any ID3v1 tag (128 bytes).
|
||||||
tag, err := readAPETagAtOffset(f, fileSize, fileSize-apeTagHeaderSize)
|
tag, err := readAPETagAtOffset(f, fileSize, fileSize-apeTagHeaderSize)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -253,6 +255,7 @@ func findExistingAPETagSize(filePath string) (int64, error) {
|
|||||||
|
|
||||||
tagSize := int64(binary.LittleEndian.Uint32(footer[12:16]))
|
tagSize := int64(binary.LittleEndian.Uint32(footer[12:16]))
|
||||||
|
|
||||||
|
// Check if there's also a header (tagSize only covers items + footer)
|
||||||
hasHeader := (flags & (1 << 31)) != 0 // bit 31 = tag contains header
|
hasHeader := (flags & (1 << 31)) != 0 // bit 31 = tag contains header
|
||||||
totalSize := tagSize
|
totalSize := tagSize
|
||||||
if hasHeader {
|
if hasHeader {
|
||||||
@@ -313,6 +316,7 @@ func marshalAPETag(tag *APETag) ([]byte, error) {
|
|||||||
footerFlags := uint32(1 << 31)
|
footerFlags := uint32(1 << 31)
|
||||||
footer := buildAPEHeaderFooter(version, tagSize, itemCount, footerFlags)
|
footer := buildAPEHeaderFooter(version, tagSize, itemCount, footerFlags)
|
||||||
|
|
||||||
|
// Final layout: header + items + footer
|
||||||
result := make([]byte, 0, len(header)+len(itemsData)+len(footer))
|
result := make([]byte, 0, len(header)+len(itemsData)+len(footer))
|
||||||
result = append(result, header...)
|
result = append(result, header...)
|
||||||
result = append(result, itemsData...)
|
result = append(result, itemsData...)
|
||||||
@@ -507,6 +511,7 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
|
|||||||
// deletion: the caller sends an empty value which is not serialized into
|
// deletion: the caller sends an empty value which is not serialized into
|
||||||
// newItems, but the old value must still be dropped.
|
// newItems, but the old value must still be dropped.
|
||||||
func MergeAPEItems(existing, newItems []APETagItem, overrideKeys map[string]struct{}) []APETagItem {
|
func MergeAPEItems(existing, newItems []APETagItem, overrideKeys map[string]struct{}) []APETagItem {
|
||||||
|
// Build a set of keys being updated (upper-case for case-insensitive match)
|
||||||
combined := make(map[string]struct{}, len(newItems)+len(overrideKeys))
|
combined := make(map[string]struct{}, len(newItems)+len(overrideKeys))
|
||||||
for k := range overrideKeys {
|
for k := range overrideKeys {
|
||||||
combined[strings.ToUpper(k)] = struct{}{}
|
combined[strings.ToUpper(k)] = struct{}{}
|
||||||
@@ -534,6 +539,7 @@ func ReadAPETagsFromReader(r io.ReaderAt, fileSize int64) (*APETag, error) {
|
|||||||
return nil, fmt.Errorf("file too small for APE tag")
|
return nil, fmt.Errorf("file too small for APE tag")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try footer at end of file
|
||||||
footer := make([]byte, apeTagHeaderSize)
|
footer := make([]byte, apeTagHeaderSize)
|
||||||
if _, err := r.ReadAt(footer, fileSize-apeTagHeaderSize); err != nil {
|
if _, err := r.ReadAt(footer, fileSize-apeTagHeaderSize); err != nil {
|
||||||
return nil, fmt.Errorf("failed to read APE footer: %w", err)
|
return nil, fmt.Errorf("failed to read APE footer: %w", err)
|
||||||
@@ -562,7 +568,7 @@ func ReadAPETagsFromReader(r io.ReaderAt, fileSize int64) (*APETag, error) {
|
|||||||
return nil, fmt.Errorf("no APEv2 tag found")
|
return nil, fmt.Errorf("no APEv2 tag found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseAPETagFromFooter(r io.ReaderAt, _, footerOffset int64, footer []byte) (*APETag, error) {
|
func parseAPETagFromFooter(r io.ReaderAt, fileSize, footerOffset int64, footer []byte) (*APETag, error) {
|
||||||
version := binary.LittleEndian.Uint32(footer[8:12])
|
version := binary.LittleEndian.Uint32(footer[8:12])
|
||||||
tagSize := binary.LittleEndian.Uint32(footer[12:16])
|
tagSize := binary.LittleEndian.Uint32(footer[12:16])
|
||||||
itemCount := binary.LittleEndian.Uint32(footer[16:20])
|
itemCount := binary.LittleEndian.Uint32(footer[16:20])
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,9 @@ import (
|
|||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -183,7 +185,7 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
|
|||||||
case "TCR":
|
case "TCR":
|
||||||
metadata.Copyright = value
|
metadata.Copyright = value
|
||||||
case "ULT":
|
case "ULT":
|
||||||
if v := extractLangTextFrame(frameData); v != "" && metadata.Lyrics == "" {
|
if v := extractLyricsFrame(frameData); v != "" && metadata.Lyrics == "" {
|
||||||
metadata.Lyrics = v
|
metadata.Lyrics = v
|
||||||
}
|
}
|
||||||
case "TXX":
|
case "TXX":
|
||||||
@@ -304,11 +306,11 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
|
|||||||
case "TCOP":
|
case "TCOP":
|
||||||
metadata.Copyright = value
|
metadata.Copyright = value
|
||||||
case "COMM":
|
case "COMM":
|
||||||
if v := extractLangTextFrame(frameData); v != "" {
|
if v := extractCommentFrame(frameData); v != "" {
|
||||||
metadata.Comment = v
|
metadata.Comment = v
|
||||||
}
|
}
|
||||||
case "USLT":
|
case "USLT":
|
||||||
if v := extractLangTextFrame(frameData); v != "" && metadata.Lyrics == "" {
|
if v := extractLyricsFrame(frameData); v != "" && metadata.Lyrics == "" {
|
||||||
metadata.Lyrics = v
|
metadata.Lyrics = v
|
||||||
}
|
}
|
||||||
case "TXXX":
|
case "TXXX":
|
||||||
@@ -388,9 +390,7 @@ func extractTextFrame(data []byte) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractLangTextFrame decodes ID3 frames with an encoding byte, 3-byte
|
func extractCommentFrame(data []byte) string {
|
||||||
// language code, and null-terminated descriptor before the text (COMM, USLT).
|
|
||||||
func extractLangTextFrame(data []byte) string {
|
|
||||||
if len(data) < 5 {
|
if len(data) < 5 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -425,6 +425,42 @@ func extractLangTextFrame(data []byte) string {
|
|||||||
return extractTextFrame(framed)
|
return extractTextFrame(framed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractLyricsFrame(data []byte) string {
|
||||||
|
if len(data) < 5 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
encoding := data[0]
|
||||||
|
rest := data[4:]
|
||||||
|
|
||||||
|
var text []byte
|
||||||
|
switch encoding {
|
||||||
|
case 1, 2:
|
||||||
|
for i := 0; i+1 < len(rest); i += 2 {
|
||||||
|
if rest[i] == 0 && rest[i+1] == 0 {
|
||||||
|
text = rest[i+2:]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
idx := bytes.IndexByte(rest, 0)
|
||||||
|
if idx >= 0 && idx+1 < len(rest) {
|
||||||
|
text = rest[idx+1:]
|
||||||
|
} else {
|
||||||
|
text = rest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(text) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
framed := make([]byte, 1+len(text))
|
||||||
|
framed[0] = encoding
|
||||||
|
copy(framed[1:], text)
|
||||||
|
return extractTextFrame(framed)
|
||||||
|
}
|
||||||
|
|
||||||
func extractUserTextFrame(data []byte) (string, string) {
|
func extractUserTextFrame(data []byte) (string, string) {
|
||||||
if len(data) < 2 {
|
if len(data) < 2 {
|
||||||
return "", ""
|
return "", ""
|
||||||
@@ -545,6 +581,11 @@ func cleanGenre(genre string) string {
|
|||||||
return genre
|
return genre
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseTrackNumber(s string) int {
|
||||||
|
num, _ := parseIndexPair(s)
|
||||||
|
return num
|
||||||
|
}
|
||||||
|
|
||||||
func parseIndexPair(s string) (int, int) {
|
func parseIndexPair(s string) (int, int) {
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
if s == "" {
|
if s == "" {
|
||||||
@@ -772,6 +813,414 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
|||||||
return quality, nil
|
return quality, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ReadOggVorbisComments(filePath string) (*AudioMetadata, error) {
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
metadata := &AudioMetadata{}
|
||||||
|
|
||||||
|
packets, err := collectOggPackets(file, 30, 80)
|
||||||
|
if err != nil && len(packets) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
streamType := detectOggStreamType(packets)
|
||||||
|
for _, pkt := range packets {
|
||||||
|
if streamType == oggStreamOpus {
|
||||||
|
if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" {
|
||||||
|
parseVorbisComments(pkt[8:], metadata)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if streamType == oggStreamVorbis || streamType == oggStreamUnknown {
|
||||||
|
if len(pkt) > 7 && pkt[0] == 0x03 && string(pkt[1:7]) == "vorbis" {
|
||||||
|
parseVorbisComments(pkt[7:], metadata)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if streamType == oggStreamUnknown {
|
||||||
|
if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" {
|
||||||
|
parseVorbisComments(pkt[8:], metadata)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.Title == "" && metadata.Artist == "" {
|
||||||
|
return nil, fmt.Errorf("no Vorbis comments found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type oggPage struct {
|
||||||
|
headerType byte
|
||||||
|
segmentTable []byte
|
||||||
|
data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func readOggPageWithHeader(file *os.File) (*oggPage, error) {
|
||||||
|
header := make([]byte, 27)
|
||||||
|
if _, err := io.ReadFull(file, header); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(header[0:4]) != "OggS" {
|
||||||
|
return nil, fmt.Errorf("not an Ogg page")
|
||||||
|
}
|
||||||
|
|
||||||
|
headerType := header[5]
|
||||||
|
numSegments := int(header[26])
|
||||||
|
|
||||||
|
segmentTable := make([]byte, numSegments)
|
||||||
|
if _, err := io.ReadFull(file, segmentTable); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var pageSize int
|
||||||
|
for _, seg := range segmentTable {
|
||||||
|
pageSize += int(seg)
|
||||||
|
}
|
||||||
|
|
||||||
|
pageData := make([]byte, pageSize)
|
||||||
|
if _, err := io.ReadFull(file, pageData); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &oggPage{
|
||||||
|
headerType: headerType,
|
||||||
|
segmentTable: segmentTable,
|
||||||
|
data: pageData,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectOggPackets(file *os.File, maxPackets, maxPages int) ([][]byte, error) {
|
||||||
|
const maxPacketSize = 10 * 1024 * 1024
|
||||||
|
var packets [][]byte
|
||||||
|
var cur []byte
|
||||||
|
skipPacket := false
|
||||||
|
|
||||||
|
for pageNum := 0; pageNum < maxPages && len(packets) < maxPackets; pageNum++ {
|
||||||
|
page, err := readOggPageWithHeader(file)
|
||||||
|
if err != nil {
|
||||||
|
if len(packets) > 0 {
|
||||||
|
return packets, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if page.headerType&0x01 == 0 && len(cur) > 0 {
|
||||||
|
cur = nil
|
||||||
|
skipPacket = false
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := 0
|
||||||
|
for _, seg := range page.segmentTable {
|
||||||
|
segLen := int(seg)
|
||||||
|
if offset+segLen > len(page.data) {
|
||||||
|
return packets, fmt.Errorf("invalid ogg segment size")
|
||||||
|
}
|
||||||
|
|
||||||
|
if skipPacket {
|
||||||
|
offset += segLen
|
||||||
|
if segLen < 255 {
|
||||||
|
skipPacket = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cur)+segLen > maxPacketSize {
|
||||||
|
cur = nil
|
||||||
|
skipPacket = true
|
||||||
|
offset += segLen
|
||||||
|
if segLen < 255 {
|
||||||
|
skipPacket = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cur = append(cur, page.data[offset:offset+segLen]...)
|
||||||
|
offset += segLen
|
||||||
|
|
||||||
|
if segLen < 255 {
|
||||||
|
if len(cur) > 0 {
|
||||||
|
packets = append(packets, cur)
|
||||||
|
}
|
||||||
|
cur = nil
|
||||||
|
if len(packets) >= maxPackets {
|
||||||
|
return packets, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return packets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type oggStreamType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
oggStreamUnknown oggStreamType = iota
|
||||||
|
oggStreamOpus
|
||||||
|
oggStreamVorbis
|
||||||
|
)
|
||||||
|
|
||||||
|
func detectOggStreamType(packets [][]byte) oggStreamType {
|
||||||
|
for _, p := range packets {
|
||||||
|
if len(p) >= 8 && string(p[0:8]) == "OpusHead" {
|
||||||
|
return oggStreamOpus
|
||||||
|
}
|
||||||
|
if len(p) > 7 && p[0] == 0x01 && string(p[1:7]) == "vorbis" {
|
||||||
|
return oggStreamVorbis
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return oggStreamUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
||||||
|
if len(data) < 4 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := bytes.NewReader(data)
|
||||||
|
artistValues := make([]string, 0, 1)
|
||||||
|
albumArtistValues := make([]string, 0, 1)
|
||||||
|
|
||||||
|
var vendorLen uint32
|
||||||
|
if err := binary.Read(reader, binary.LittleEndian, &vendorLen); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if vendorLen > uint32(len(data)-4) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vendor := make([]byte, vendorLen)
|
||||||
|
if _, err := reader.Read(vendor); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var commentCount uint32
|
||||||
|
if err := binary.Read(reader, binary.LittleEndian, &commentCount); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := uint32(0); i < commentCount && i < 100; i++ {
|
||||||
|
var commentLen uint32
|
||||||
|
if err := binary.Read(reader, binary.LittleEndian, &commentLen); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining := uint32(reader.Len())
|
||||||
|
if commentLen > remaining {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if commentLen > 512*1024 {
|
||||||
|
reader.Seek(int64(commentLen), io.SeekCurrent)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
comment := make([]byte, commentLen)
|
||||||
|
if _, err := reader.Read(comment); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(string(comment), "=", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
key := strings.ToUpper(parts[0])
|
||||||
|
value := parts[1]
|
||||||
|
|
||||||
|
switch key {
|
||||||
|
case "TITLE":
|
||||||
|
metadata.Title = value
|
||||||
|
case "ARTIST":
|
||||||
|
artistValues = append(artistValues, value)
|
||||||
|
case "ALBUMARTIST", "ALBUM_ARTIST", "ALBUM ARTIST":
|
||||||
|
albumArtistValues = append(albumArtistValues, value)
|
||||||
|
case "ALBUM":
|
||||||
|
metadata.Album = value
|
||||||
|
case "DATE", "YEAR":
|
||||||
|
metadata.Date = value
|
||||||
|
if len(value) >= 4 {
|
||||||
|
metadata.Year = value[:4]
|
||||||
|
}
|
||||||
|
case "GENRE":
|
||||||
|
metadata.Genre = value
|
||||||
|
case "TRACKNUMBER", "TRACK":
|
||||||
|
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||||
|
case "DISCNUMBER", "DISC":
|
||||||
|
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||||
|
case "ISRC":
|
||||||
|
metadata.ISRC = value
|
||||||
|
case "COMPOSER":
|
||||||
|
metadata.Composer = value
|
||||||
|
case "COMMENT", "DESCRIPTION":
|
||||||
|
metadata.Comment = value
|
||||||
|
case "LYRICS", "UNSYNCEDLYRICS":
|
||||||
|
if metadata.Lyrics == "" {
|
||||||
|
metadata.Lyrics = value
|
||||||
|
}
|
||||||
|
case "ORGANIZATION", "LABEL", "PUBLISHER":
|
||||||
|
metadata.Label = value
|
||||||
|
case "COPYRIGHT":
|
||||||
|
metadata.Copyright = value
|
||||||
|
case "REPLAYGAIN_TRACK_GAIN":
|
||||||
|
metadata.ReplayGainTrackGain = value
|
||||||
|
case "REPLAYGAIN_TRACK_PEAK":
|
||||||
|
metadata.ReplayGainTrackPeak = value
|
||||||
|
case "REPLAYGAIN_ALBUM_GAIN":
|
||||||
|
metadata.ReplayGainAlbumGain = value
|
||||||
|
case "REPLAYGAIN_ALBUM_PEAK":
|
||||||
|
metadata.ReplayGainAlbumPeak = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(artistValues) > 0 {
|
||||||
|
metadata.Artist = joinVorbisCommentValues(artistValues)
|
||||||
|
}
|
||||||
|
if len(albumArtistValues) > 0 {
|
||||||
|
metadata.AlbumArtist = joinVorbisCommentValues(albumArtistValues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetOggQuality(filePath string) (*OggQuality, error) {
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
quality := &OggQuality{}
|
||||||
|
|
||||||
|
packets, err := collectOggPackets(file, 5, 10)
|
||||||
|
if err != nil && len(packets) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
streamType := detectOggStreamType(packets)
|
||||||
|
if streamType == oggStreamUnknown {
|
||||||
|
if strings.HasSuffix(strings.ToLower(filePath), ".opus") {
|
||||||
|
streamType = oggStreamOpus
|
||||||
|
} else {
|
||||||
|
streamType = oggStreamVorbis
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isOpus := streamType == oggStreamOpus
|
||||||
|
var preSkip int
|
||||||
|
|
||||||
|
if isOpus {
|
||||||
|
for _, pkt := range packets {
|
||||||
|
if len(pkt) >= 19 && string(pkt[0:8]) == "OpusHead" {
|
||||||
|
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
|
||||||
|
if quality.SampleRate == 0 {
|
||||||
|
quality.SampleRate = 48000
|
||||||
|
}
|
||||||
|
preSkip = int(binary.LittleEndian.Uint16(pkt[10:12]))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, pkt := range packets {
|
||||||
|
if len(pkt) > 29 && pkt[0] == 0x01 && string(pkt[1:7]) == "vorbis" {
|
||||||
|
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stat, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return quality, nil
|
||||||
|
}
|
||||||
|
fileSize := stat.Size()
|
||||||
|
|
||||||
|
granule := readLastOggGranulePosition(file, fileSize)
|
||||||
|
if granule > 0 {
|
||||||
|
if isOpus {
|
||||||
|
totalSamples := granule - int64(preSkip)
|
||||||
|
if totalSamples > 0 {
|
||||||
|
durationSec := float64(totalSamples) / 48000.0
|
||||||
|
if durationSec > 0 {
|
||||||
|
quality.Duration = int(math.Round(durationSec))
|
||||||
|
quality.Bitrate = int(float64(fileSize*8) / durationSec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if quality.SampleRate > 0 {
|
||||||
|
durationSec := float64(granule) / float64(quality.SampleRate)
|
||||||
|
if durationSec > 0 {
|
||||||
|
quality.Duration = int(math.Round(durationSec))
|
||||||
|
quality.Bitrate = int(float64(fileSize*8) / durationSec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if quality.Bitrate <= 0 && quality.Duration > 0 {
|
||||||
|
quality.Bitrate = int(fileSize * 8 / int64(quality.Duration))
|
||||||
|
}
|
||||||
|
if quality.Duration > 24*60*60 {
|
||||||
|
quality.Duration = 0
|
||||||
|
quality.Bitrate = 0
|
||||||
|
}
|
||||||
|
if quality.Bitrate > 0 && quality.Bitrate < 8000 {
|
||||||
|
quality.Bitrate = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return quality, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
|
||||||
|
searchSize := int64(65536)
|
||||||
|
if searchSize > fileSize {
|
||||||
|
searchSize = fileSize
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := make([]byte, searchSize)
|
||||||
|
offset := fileSize - searchSize
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
n, err := file.ReadAt(buf, offset)
|
||||||
|
if err != nil && n == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
buf = buf[:n]
|
||||||
|
|
||||||
|
for i := n - 4; i >= 0; i-- {
|
||||||
|
if buf[i] != 'O' || buf[i+1] != 'g' || buf[i+2] != 'g' || buf[i+3] != 'S' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i+27 > n {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
version := buf[i+4]
|
||||||
|
headerType := buf[i+5]
|
||||||
|
if version != 0 || headerType > 0x07 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
segmentCount := int(buf[i+26])
|
||||||
|
headerLen := 27 + segmentCount
|
||||||
|
if i+headerLen > n {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
payloadLen := 0
|
||||||
|
for s := 0; s < segmentCount; s++ {
|
||||||
|
payloadLen += int(buf[i+27+s])
|
||||||
|
}
|
||||||
|
if i+headerLen+payloadLen > n {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return int64(binary.LittleEndian.Uint64(buf[i+6 : i+14]))
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
var id3v1Genres = []string{
|
var id3v1Genres = []string{
|
||||||
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge",
|
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge",
|
||||||
"Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B",
|
"Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B",
|
||||||
@@ -801,3 +1250,439 @@ var id3v1Genres = []string{
|
|||||||
"Contemporary Christian", "Christian Rock", "Merengue", "Salsa",
|
"Contemporary Christian", "Christian Rock", "Merengue", "Salsa",
|
||||||
"Thrash Metal", "Anime", "J-Pop", "Synthpop",
|
"Thrash Metal", "Anime", "J-Pop", "Synthpop",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractMP3CoverArt(filePath string) ([]byte, string, error) {
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
header := make([]byte, 10)
|
||||||
|
if _, err := io.ReadFull(file, header); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(header[0:3]) != "ID3" {
|
||||||
|
return nil, "", fmt.Errorf("no ID3v2 header")
|
||||||
|
}
|
||||||
|
|
||||||
|
majorVersion := header[3]
|
||||||
|
size := int(header[6])<<21 | int(header[7])<<14 | int(header[8])<<7 | int(header[9])
|
||||||
|
|
||||||
|
tagData := make([]byte, size)
|
||||||
|
if _, err := io.ReadFull(file, tagData); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
pos := 0
|
||||||
|
var frameIDLen, headerLen int
|
||||||
|
if majorVersion == 2 {
|
||||||
|
frameIDLen = 3
|
||||||
|
headerLen = 6
|
||||||
|
} else {
|
||||||
|
frameIDLen = 4
|
||||||
|
headerLen = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
for pos+headerLen < len(tagData) {
|
||||||
|
frameID := string(tagData[pos : pos+frameIDLen])
|
||||||
|
if frameID[0] == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
var frameSize int
|
||||||
|
switch majorVersion {
|
||||||
|
case 2:
|
||||||
|
frameSize = int(tagData[pos+3])<<16 | int(tagData[pos+4])<<8 | int(tagData[pos+5])
|
||||||
|
case 4:
|
||||||
|
frameSize = int(tagData[pos+4])<<21 | int(tagData[pos+5])<<14 | int(tagData[pos+6])<<7 | int(tagData[pos+7])
|
||||||
|
default:
|
||||||
|
frameSize = int(tagData[pos+4])<<24 | int(tagData[pos+5])<<16 | int(tagData[pos+6])<<8 | int(tagData[pos+7])
|
||||||
|
}
|
||||||
|
|
||||||
|
if frameSize <= 0 || pos+headerLen+frameSize > len(tagData) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frameIDLen == 4 && frameID == "APIC") || (frameIDLen == 3 && frameID == "PIC") {
|
||||||
|
frameData := tagData[pos+headerLen : pos+headerLen+frameSize]
|
||||||
|
imageData, mimeType := parseAPICFrame(frameData, majorVersion)
|
||||||
|
if len(imageData) > 0 {
|
||||||
|
return imageData, mimeType, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pos += headerLen + frameSize
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, "", fmt.Errorf("no cover art found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAPICFrame(data []byte, version byte) ([]byte, string) {
|
||||||
|
if len(data) < 4 {
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
pos := 0
|
||||||
|
encoding := data[pos]
|
||||||
|
pos++
|
||||||
|
|
||||||
|
var mimeType string
|
||||||
|
if version == 2 {
|
||||||
|
if pos+3 > len(data) {
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
format := string(data[pos : pos+3])
|
||||||
|
pos += 3
|
||||||
|
switch format {
|
||||||
|
case "JPG":
|
||||||
|
mimeType = "image/jpeg"
|
||||||
|
case "PNG":
|
||||||
|
mimeType = "image/png"
|
||||||
|
default:
|
||||||
|
mimeType = "image/jpeg"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
end := pos
|
||||||
|
for end < len(data) && data[end] != 0 {
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
mimeType = string(data[pos:end])
|
||||||
|
pos = end + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if pos >= len(data) {
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
pos++
|
||||||
|
|
||||||
|
if encoding == 0 || encoding == 3 {
|
||||||
|
for pos < len(data) && data[pos] != 0 {
|
||||||
|
pos++
|
||||||
|
}
|
||||||
|
pos++
|
||||||
|
} else {
|
||||||
|
for pos+1 < len(data) {
|
||||||
|
if data[pos] == 0 && data[pos+1] == 0 {
|
||||||
|
pos += 2
|
||||||
|
break
|
||||||
|
}
|
||||||
|
pos++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if pos >= len(data) {
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return data[pos:], mimeType
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractOggCoverArt(filePath string) ([]byte, string, error) {
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
packets, err := collectOggPackets(file, 30, 80)
|
||||||
|
if err != nil && len(packets) == 0 {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
streamType := detectOggStreamType(packets)
|
||||||
|
for _, pkt := range packets {
|
||||||
|
var comments []byte
|
||||||
|
if streamType == oggStreamOpus {
|
||||||
|
if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" {
|
||||||
|
comments = pkt[8:]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(pkt) > 7 && pkt[0] == 0x03 && string(pkt[1:7]) == "vorbis" {
|
||||||
|
comments = pkt[7:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(comments) == 0 && streamType == oggStreamUnknown {
|
||||||
|
if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" {
|
||||||
|
comments = pkt[8:]
|
||||||
|
} else if len(pkt) > 7 && pkt[0] == 0x03 && string(pkt[1:7]) == "vorbis" {
|
||||||
|
comments = pkt[7:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(comments) > 0 {
|
||||||
|
imageData, mimeType := extractPictureFromVorbisComments(comments)
|
||||||
|
if len(imageData) > 0 {
|
||||||
|
return imageData, mimeType, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, "", fmt.Errorf("no cover art found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractPictureFromVorbisComments(data []byte) ([]byte, string) {
|
||||||
|
if len(data) < 8 {
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := bytes.NewReader(data)
|
||||||
|
|
||||||
|
var vendorLen uint32
|
||||||
|
if err := binary.Read(reader, binary.LittleEndian, &vendorLen); err != nil {
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
if vendorLen > uint32(len(data)-4) {
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
reader.Seek(int64(vendorLen), io.SeekCurrent)
|
||||||
|
|
||||||
|
var commentCount uint32
|
||||||
|
if err := binary.Read(reader, binary.LittleEndian, &commentCount); err != nil {
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := uint32(0); i < commentCount && i < 100; i++ {
|
||||||
|
var commentLen uint32
|
||||||
|
if err := binary.Read(reader, binary.LittleEndian, &commentLen); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if commentLen > 10000000 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
comment := make([]byte, commentLen)
|
||||||
|
if _, err := reader.Read(comment); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
key := "METADATA_BLOCK_PICTURE="
|
||||||
|
if len(comment) > len(key) && strings.ToUpper(string(comment[:len(key)])) == key {
|
||||||
|
b64Data := comment[len(key):]
|
||||||
|
decoded := make([]byte, base64StdDecodeLen(len(b64Data)))
|
||||||
|
n, err := base64StdDecode(decoded, b64Data)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
decoded = decoded[:n]
|
||||||
|
|
||||||
|
imageData, mimeType := parseFLACPictureBlock(decoded)
|
||||||
|
if len(imageData) > 0 {
|
||||||
|
return imageData, mimeType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFLACPictureBlock(data []byte) ([]byte, string) {
|
||||||
|
if len(data) < 32 {
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := bytes.NewReader(data)
|
||||||
|
|
||||||
|
var pictureType uint32
|
||||||
|
binary.Read(reader, binary.BigEndian, &pictureType)
|
||||||
|
|
||||||
|
var mimeLen uint32
|
||||||
|
binary.Read(reader, binary.BigEndian, &mimeLen)
|
||||||
|
if mimeLen > 256 {
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
mimeBytes := make([]byte, mimeLen)
|
||||||
|
reader.Read(mimeBytes)
|
||||||
|
mimeType := string(mimeBytes)
|
||||||
|
|
||||||
|
var descLen uint32
|
||||||
|
binary.Read(reader, binary.BigEndian, &descLen)
|
||||||
|
if descLen > 10000 {
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.Seek(int64(descLen), io.SeekCurrent)
|
||||||
|
|
||||||
|
reader.Seek(16, io.SeekCurrent)
|
||||||
|
|
||||||
|
var dataLen uint32
|
||||||
|
binary.Read(reader, binary.BigEndian, &dataLen)
|
||||||
|
if dataLen > 10000000 {
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
imageData := make([]byte, dataLen)
|
||||||
|
reader.Read(imageData)
|
||||||
|
|
||||||
|
return imageData, mimeType
|
||||||
|
}
|
||||||
|
|
||||||
|
func base64StdDecodeLen(n int) int {
|
||||||
|
return n * 6 / 8
|
||||||
|
}
|
||||||
|
|
||||||
|
func base64StdDecode(dst, src []byte) (int, error) {
|
||||||
|
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||||
|
|
||||||
|
decodeMap := make([]byte, 256)
|
||||||
|
for i := range decodeMap {
|
||||||
|
decodeMap[i] = 0xFF
|
||||||
|
}
|
||||||
|
for i := 0; i < len(alphabet); i++ {
|
||||||
|
decodeMap[alphabet[i]] = byte(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
si, di := 0, 0
|
||||||
|
for si < len(src) {
|
||||||
|
for si < len(src) && (src[si] == '\n' || src[si] == '\r' || src[si] == ' ' || src[si] == '\t') {
|
||||||
|
si++
|
||||||
|
}
|
||||||
|
if si >= len(src) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
var vals [4]byte
|
||||||
|
var valCount int
|
||||||
|
for valCount < 4 && si < len(src) {
|
||||||
|
c := src[si]
|
||||||
|
si++
|
||||||
|
if c == '=' {
|
||||||
|
vals[valCount] = 0
|
||||||
|
valCount++
|
||||||
|
} else if c == '\n' || c == '\r' || c == ' ' || c == '\t' {
|
||||||
|
continue
|
||||||
|
} else if decodeMap[c] != 0xFF {
|
||||||
|
vals[valCount] = decodeMap[c]
|
||||||
|
valCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if valCount < 2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if di < len(dst) {
|
||||||
|
dst[di] = vals[0]<<2 | vals[1]>>4
|
||||||
|
di++
|
||||||
|
}
|
||||||
|
if valCount >= 3 && di < len(dst) {
|
||||||
|
dst[di] = vals[1]<<4 | vals[2]>>2
|
||||||
|
di++
|
||||||
|
}
|
||||||
|
if valCount >= 4 && di < len(dst) {
|
||||||
|
dst[di] = vals[2]<<6 | vals[3]
|
||||||
|
di++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return di, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractAnyCoverArt(filePath string) ([]byte, string, error) {
|
||||||
|
return extractAnyCoverArtWithHint(filePath, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, string, error) {
|
||||||
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
if ext == "" {
|
||||||
|
ext = strings.ToLower(filepath.Ext(displayNameHint))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ext {
|
||||||
|
case ".flac":
|
||||||
|
data, err := ExtractCoverArt(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
mimeType := "image/jpeg"
|
||||||
|
if len(data) > 8 && string(data[1:4]) == "PNG" {
|
||||||
|
mimeType = "image/png"
|
||||||
|
}
|
||||||
|
return data, mimeType, nil
|
||||||
|
|
||||||
|
case ".mp3":
|
||||||
|
return extractMP3CoverArt(filePath)
|
||||||
|
|
||||||
|
case ".opus", ".ogg":
|
||||||
|
return extractOggCoverArt(filePath)
|
||||||
|
|
||||||
|
case ".m4a":
|
||||||
|
data, err := extractCoverFromM4A(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
mimeType := "image/jpeg"
|
||||||
|
if len(data) >= 8 &&
|
||||||
|
data[0] == 0x89 &&
|
||||||
|
data[1] == 0x50 &&
|
||||||
|
data[2] == 0x4E &&
|
||||||
|
data[3] == 0x47 {
|
||||||
|
mimeType = "image/png"
|
||||||
|
}
|
||||||
|
return data, mimeType, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, "", fmt.Errorf("unsupported format: %s", ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveCoverToCache(filePath, cacheDir string) (string, error) {
|
||||||
|
return SaveCoverToCacheWithHintAndKey(filePath, "", cacheDir, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveCoverToCacheWithHint(filePath, displayNameHint, cacheDir string) (string, error) {
|
||||||
|
return SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveLibraryCoverCacheKey(filePath, explicitKey string) string {
|
||||||
|
explicitKey = strings.TrimSpace(explicitKey)
|
||||||
|
if explicitKey != "" {
|
||||||
|
return explicitKey
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheKey := filePath
|
||||||
|
if stat, err := os.Stat(filePath); err == nil {
|
||||||
|
cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano())
|
||||||
|
}
|
||||||
|
return cacheKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, coverCacheKey string) (string, error) {
|
||||||
|
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
|
||||||
|
hash := hashString(cacheKey)
|
||||||
|
|
||||||
|
jpgPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.jpg", hash))
|
||||||
|
pngPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.png", hash))
|
||||||
|
|
||||||
|
if _, err := os.Stat(jpgPath); err == nil {
|
||||||
|
return jpgPath, nil
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(pngPath); err == nil {
|
||||||
|
return pngPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
imageData, mimeType, err := extractAnyCoverArtWithHint(filePath, displayNameHint)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(cacheDir, 0755); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create cache dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cachePath string
|
||||||
|
if strings.Contains(mimeType, "png") {
|
||||||
|
cachePath = pngPath
|
||||||
|
} else {
|
||||||
|
cachePath = jpgPath
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(cachePath, imageData, 0644); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write cover: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachePath, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,402 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/binary"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func extractMP3CoverArt(filePath string) ([]byte, string, error) {
|
|
||||||
file, err := os.Open(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
header := make([]byte, 10)
|
|
||||||
if _, err := io.ReadFull(file, header); err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
if string(header[0:3]) != "ID3" {
|
|
||||||
return nil, "", fmt.Errorf("no ID3v2 header")
|
|
||||||
}
|
|
||||||
|
|
||||||
majorVersion := header[3]
|
|
||||||
size := int(header[6])<<21 | int(header[7])<<14 | int(header[8])<<7 | int(header[9])
|
|
||||||
|
|
||||||
tagData := make([]byte, size)
|
|
||||||
if _, err := io.ReadFull(file, tagData); err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
pos := 0
|
|
||||||
var frameIDLen, headerLen int
|
|
||||||
if majorVersion == 2 {
|
|
||||||
frameIDLen = 3
|
|
||||||
headerLen = 6
|
|
||||||
} else {
|
|
||||||
frameIDLen = 4
|
|
||||||
headerLen = 10
|
|
||||||
}
|
|
||||||
|
|
||||||
for pos+headerLen < len(tagData) {
|
|
||||||
frameID := string(tagData[pos : pos+frameIDLen])
|
|
||||||
if frameID[0] == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
var frameSize int
|
|
||||||
switch majorVersion {
|
|
||||||
case 2:
|
|
||||||
frameSize = int(tagData[pos+3])<<16 | int(tagData[pos+4])<<8 | int(tagData[pos+5])
|
|
||||||
case 4:
|
|
||||||
frameSize = int(tagData[pos+4])<<21 | int(tagData[pos+5])<<14 | int(tagData[pos+6])<<7 | int(tagData[pos+7])
|
|
||||||
default:
|
|
||||||
frameSize = int(tagData[pos+4])<<24 | int(tagData[pos+5])<<16 | int(tagData[pos+6])<<8 | int(tagData[pos+7])
|
|
||||||
}
|
|
||||||
|
|
||||||
if frameSize <= 0 || pos+headerLen+frameSize > len(tagData) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if (frameIDLen == 4 && frameID == "APIC") || (frameIDLen == 3 && frameID == "PIC") {
|
|
||||||
frameData := tagData[pos+headerLen : pos+headerLen+frameSize]
|
|
||||||
imageData, mimeType := parseAPICFrame(frameData, majorVersion)
|
|
||||||
if len(imageData) > 0 {
|
|
||||||
return imageData, mimeType, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pos += headerLen + frameSize
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, "", fmt.Errorf("no cover art found")
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseAPICFrame(data []byte, version byte) ([]byte, string) {
|
|
||||||
if len(data) < 4 {
|
|
||||||
return nil, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
pos := 0
|
|
||||||
encoding := data[pos]
|
|
||||||
pos++
|
|
||||||
|
|
||||||
var mimeType string
|
|
||||||
if version == 2 {
|
|
||||||
if pos+3 > len(data) {
|
|
||||||
return nil, ""
|
|
||||||
}
|
|
||||||
format := string(data[pos : pos+3])
|
|
||||||
pos += 3
|
|
||||||
switch format {
|
|
||||||
case "JPG":
|
|
||||||
mimeType = "image/jpeg"
|
|
||||||
case "PNG":
|
|
||||||
mimeType = "image/png"
|
|
||||||
default:
|
|
||||||
mimeType = "image/jpeg"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
end := pos
|
|
||||||
for end < len(data) && data[end] != 0 {
|
|
||||||
end++
|
|
||||||
}
|
|
||||||
mimeType = string(data[pos:end])
|
|
||||||
pos = end + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if pos >= len(data) {
|
|
||||||
return nil, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
pos++
|
|
||||||
|
|
||||||
if encoding == 0 || encoding == 3 {
|
|
||||||
for pos < len(data) && data[pos] != 0 {
|
|
||||||
pos++
|
|
||||||
}
|
|
||||||
pos++
|
|
||||||
} else {
|
|
||||||
for pos+1 < len(data) {
|
|
||||||
if data[pos] == 0 && data[pos+1] == 0 {
|
|
||||||
pos += 2
|
|
||||||
break
|
|
||||||
}
|
|
||||||
pos++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if pos >= len(data) {
|
|
||||||
return nil, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return data[pos:], mimeType
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractOggCoverArt(filePath string) ([]byte, string, error) {
|
|
||||||
file, err := os.Open(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
packets, err := collectOggPackets(file, 30, 80)
|
|
||||||
if err != nil && len(packets) == 0 {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
streamType := detectOggStreamType(packets)
|
|
||||||
for _, pkt := range packets {
|
|
||||||
var comments []byte
|
|
||||||
if streamType == oggStreamOpus {
|
|
||||||
if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" {
|
|
||||||
comments = pkt[8:]
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if len(pkt) > 7 && pkt[0] == 0x03 && string(pkt[1:7]) == "vorbis" {
|
|
||||||
comments = pkt[7:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(comments) == 0 && streamType == oggStreamUnknown {
|
|
||||||
if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" {
|
|
||||||
comments = pkt[8:]
|
|
||||||
} else if len(pkt) > 7 && pkt[0] == 0x03 && string(pkt[1:7]) == "vorbis" {
|
|
||||||
comments = pkt[7:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(comments) > 0 {
|
|
||||||
imageData, mimeType := extractPictureFromVorbisComments(comments)
|
|
||||||
if len(imageData) > 0 {
|
|
||||||
return imageData, mimeType, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, "", fmt.Errorf("no cover art found")
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractPictureFromVorbisComments(data []byte) ([]byte, string) {
|
|
||||||
if len(data) < 8 {
|
|
||||||
return nil, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
reader := bytes.NewReader(data)
|
|
||||||
|
|
||||||
var vendorLen uint32
|
|
||||||
if err := binary.Read(reader, binary.LittleEndian, &vendorLen); err != nil {
|
|
||||||
return nil, ""
|
|
||||||
}
|
|
||||||
if vendorLen > uint32(len(data)-4) {
|
|
||||||
return nil, ""
|
|
||||||
}
|
|
||||||
reader.Seek(int64(vendorLen), io.SeekCurrent)
|
|
||||||
|
|
||||||
var commentCount uint32
|
|
||||||
if err := binary.Read(reader, binary.LittleEndian, &commentCount); err != nil {
|
|
||||||
return nil, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := uint32(0); i < commentCount && i < 100; i++ {
|
|
||||||
var commentLen uint32
|
|
||||||
if err := binary.Read(reader, binary.LittleEndian, &commentLen); err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if commentLen > 10000000 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
comment := make([]byte, commentLen)
|
|
||||||
if _, err := reader.Read(comment); err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
key := "METADATA_BLOCK_PICTURE="
|
|
||||||
if len(comment) > len(key) && strings.ToUpper(string(comment[:len(key)])) == key {
|
|
||||||
cleaned := strings.Map(func(r rune) rune {
|
|
||||||
switch r {
|
|
||||||
case '\n', '\r', ' ', '\t':
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
return r
|
|
||||||
}, string(comment[len(key):]))
|
|
||||||
decoded, err := base64.StdEncoding.DecodeString(cleaned)
|
|
||||||
if err != nil {
|
|
||||||
decoded, err = base64.RawStdEncoding.DecodeString(cleaned)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
imageData, mimeType := parseFLACPictureBlock(decoded)
|
|
||||||
if len(imageData) > 0 {
|
|
||||||
return imageData, mimeType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseFLACPictureBlock(data []byte) ([]byte, string) {
|
|
||||||
if len(data) < 32 {
|
|
||||||
return nil, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
reader := bytes.NewReader(data)
|
|
||||||
|
|
||||||
var pictureType uint32
|
|
||||||
binary.Read(reader, binary.BigEndian, &pictureType)
|
|
||||||
|
|
||||||
var mimeLen uint32
|
|
||||||
binary.Read(reader, binary.BigEndian, &mimeLen)
|
|
||||||
if mimeLen > 256 {
|
|
||||||
return nil, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
mimeBytes := make([]byte, mimeLen)
|
|
||||||
reader.Read(mimeBytes)
|
|
||||||
mimeType := string(mimeBytes)
|
|
||||||
|
|
||||||
var descLen uint32
|
|
||||||
binary.Read(reader, binary.BigEndian, &descLen)
|
|
||||||
if descLen > 10000 {
|
|
||||||
return nil, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
reader.Seek(int64(descLen), io.SeekCurrent)
|
|
||||||
|
|
||||||
reader.Seek(16, io.SeekCurrent)
|
|
||||||
|
|
||||||
var dataLen uint32
|
|
||||||
binary.Read(reader, binary.BigEndian, &dataLen)
|
|
||||||
if dataLen > 10000000 {
|
|
||||||
return nil, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
imageData := make([]byte, dataLen)
|
|
||||||
reader.Read(imageData)
|
|
||||||
|
|
||||||
return imageData, mimeType
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, string, error) {
|
|
||||||
ext := strings.ToLower(filepath.Ext(filePath))
|
|
||||||
if ext == "" {
|
|
||||||
ext = strings.ToLower(filepath.Ext(displayNameHint))
|
|
||||||
}
|
|
||||||
|
|
||||||
switch ext {
|
|
||||||
case ".flac":
|
|
||||||
data, err := ExtractCoverArt(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
mimeType := "image/jpeg"
|
|
||||||
if len(data) > 8 && string(data[1:4]) == "PNG" {
|
|
||||||
mimeType = "image/png"
|
|
||||||
}
|
|
||||||
return data, mimeType, nil
|
|
||||||
|
|
||||||
case ".mp3":
|
|
||||||
return extractMP3CoverArt(filePath)
|
|
||||||
|
|
||||||
case ".opus", ".ogg":
|
|
||||||
return extractOggCoverArt(filePath)
|
|
||||||
|
|
||||||
case ".m4a":
|
|
||||||
data, err := extractCoverFromM4A(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
mimeType := "image/jpeg"
|
|
||||||
if len(data) >= 8 &&
|
|
||||||
data[0] == 0x89 &&
|
|
||||||
data[1] == 0x50 &&
|
|
||||||
data[2] == 0x4E &&
|
|
||||||
data[3] == 0x47 {
|
|
||||||
mimeType = "image/png"
|
|
||||||
}
|
|
||||||
return data, mimeType, nil
|
|
||||||
|
|
||||||
case ".wav", ".aiff", ".aif", ".aifc":
|
|
||||||
return extractWAVAIFFCover(filePath)
|
|
||||||
|
|
||||||
default:
|
|
||||||
return nil, "", fmt.Errorf("unsupported format: %s", ext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveLibraryCoverCacheKey(filePath, explicitKey string) string {
|
|
||||||
explicitKey = strings.TrimSpace(explicitKey)
|
|
||||||
if explicitKey != "" {
|
|
||||||
return explicitKey
|
|
||||||
}
|
|
||||||
|
|
||||||
cacheKey := filePath
|
|
||||||
if stat, err := os.Stat(filePath); err == nil {
|
|
||||||
cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano())
|
|
||||||
}
|
|
||||||
return cacheKey
|
|
||||||
}
|
|
||||||
|
|
||||||
func libraryCoverCachePaths(cacheDir, cacheKey string) (string, string) {
|
|
||||||
hash := hashString(cacheKey)
|
|
||||||
jpgPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.jpg", hash))
|
|
||||||
pngPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.png", hash))
|
|
||||||
return jpgPath, pngPath
|
|
||||||
}
|
|
||||||
|
|
||||||
func existingLibraryCoverCachePath(cacheDir, cacheKey string) string {
|
|
||||||
jpgPath, pngPath := libraryCoverCachePaths(cacheDir, cacheKey)
|
|
||||||
|
|
||||||
if _, err := os.Stat(jpgPath); err == nil {
|
|
||||||
return jpgPath
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(pngPath); err == nil {
|
|
||||||
return pngPath
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveLibraryCoverDataToCache(cacheDir, cacheKey string, imageData []byte, mimeType string) (string, error) {
|
|
||||||
if existing := existingLibraryCoverCachePath(cacheDir, cacheKey); existing != "" {
|
|
||||||
return existing, nil
|
|
||||||
}
|
|
||||||
if len(imageData) == 0 {
|
|
||||||
return "", fmt.Errorf("cover data is empty")
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(cacheDir, 0755); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create cache dir: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
jpgPath, pngPath := libraryCoverCachePaths(cacheDir, cacheKey)
|
|
||||||
cachePath := jpgPath
|
|
||||||
if strings.Contains(mimeType, "png") {
|
|
||||||
cachePath = pngPath
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(cachePath, imageData, 0644); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to write cover: %w", err)
|
|
||||||
}
|
|
||||||
return cachePath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, coverCacheKey string) (string, error) {
|
|
||||||
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
|
|
||||||
if existing := existingLibraryCoverCachePath(cacheDir, cacheKey); existing != "" {
|
|
||||||
return existing, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
imageData, mimeType, err := extractAnyCoverArtWithHint(filePath, displayNameHint)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return saveLibraryCoverDataToCache(cacheDir, cacheKey, imageData, mimeType)
|
|
||||||
}
|
|
||||||
@@ -1,446 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/binary"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"math"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ReadOggVorbisComments(filePath string) (*AudioMetadata, error) {
|
|
||||||
file, err := os.Open(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
metadata := &AudioMetadata{}
|
|
||||||
|
|
||||||
packets, err := collectOggPackets(file, 30, 80)
|
|
||||||
if err != nil && len(packets) == 0 {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
streamType := detectOggStreamType(packets)
|
|
||||||
for _, pkt := range packets {
|
|
||||||
if streamType == oggStreamOpus {
|
|
||||||
if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" {
|
|
||||||
parseVorbisComments(pkt[8:], metadata)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if streamType == oggStreamVorbis || streamType == oggStreamUnknown {
|
|
||||||
if len(pkt) > 7 && pkt[0] == 0x03 && string(pkt[1:7]) == "vorbis" {
|
|
||||||
parseVorbisComments(pkt[7:], metadata)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if streamType == oggStreamUnknown {
|
|
||||||
if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" {
|
|
||||||
parseVorbisComments(pkt[8:], metadata)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if metadata.Title == "" && metadata.Artist == "" {
|
|
||||||
return nil, fmt.Errorf("no Vorbis comments found")
|
|
||||||
}
|
|
||||||
|
|
||||||
return metadata, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type oggPage struct {
|
|
||||||
headerType byte
|
|
||||||
segmentTable []byte
|
|
||||||
data []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
func readOggPageWithHeader(file *os.File) (*oggPage, error) {
|
|
||||||
header := make([]byte, 27)
|
|
||||||
if _, err := io.ReadFull(file, header); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if string(header[0:4]) != "OggS" {
|
|
||||||
return nil, fmt.Errorf("not an Ogg page")
|
|
||||||
}
|
|
||||||
|
|
||||||
headerType := header[5]
|
|
||||||
numSegments := int(header[26])
|
|
||||||
|
|
||||||
segmentTable := make([]byte, numSegments)
|
|
||||||
if _, err := io.ReadFull(file, segmentTable); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var pageSize int
|
|
||||||
for _, seg := range segmentTable {
|
|
||||||
pageSize += int(seg)
|
|
||||||
}
|
|
||||||
|
|
||||||
pageData := make([]byte, pageSize)
|
|
||||||
if _, err := io.ReadFull(file, pageData); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &oggPage{
|
|
||||||
headerType: headerType,
|
|
||||||
segmentTable: segmentTable,
|
|
||||||
data: pageData,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectOggPackets(file *os.File, maxPackets, maxPages int) ([][]byte, error) {
|
|
||||||
const maxPacketSize = 10 * 1024 * 1024
|
|
||||||
var packets [][]byte
|
|
||||||
var cur []byte
|
|
||||||
skipPacket := false
|
|
||||||
|
|
||||||
for pageNum := 0; pageNum < maxPages && len(packets) < maxPackets; pageNum++ {
|
|
||||||
page, err := readOggPageWithHeader(file)
|
|
||||||
if err != nil {
|
|
||||||
if len(packets) > 0 {
|
|
||||||
return packets, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if page.headerType&0x01 == 0 && len(cur) > 0 {
|
|
||||||
cur = nil
|
|
||||||
skipPacket = false
|
|
||||||
}
|
|
||||||
|
|
||||||
offset := 0
|
|
||||||
for _, seg := range page.segmentTable {
|
|
||||||
segLen := int(seg)
|
|
||||||
if offset+segLen > len(page.data) {
|
|
||||||
return packets, fmt.Errorf("invalid ogg segment size")
|
|
||||||
}
|
|
||||||
|
|
||||||
if skipPacket {
|
|
||||||
offset += segLen
|
|
||||||
if segLen < 255 {
|
|
||||||
skipPacket = false
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(cur)+segLen > maxPacketSize {
|
|
||||||
cur = nil
|
|
||||||
skipPacket = true
|
|
||||||
offset += segLen
|
|
||||||
if segLen < 255 {
|
|
||||||
skipPacket = false
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
cur = append(cur, page.data[offset:offset+segLen]...)
|
|
||||||
offset += segLen
|
|
||||||
|
|
||||||
if segLen < 255 {
|
|
||||||
if len(cur) > 0 {
|
|
||||||
packets = append(packets, cur)
|
|
||||||
}
|
|
||||||
cur = nil
|
|
||||||
if len(packets) >= maxPackets {
|
|
||||||
return packets, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return packets, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type oggStreamType int
|
|
||||||
|
|
||||||
const (
|
|
||||||
oggStreamUnknown oggStreamType = iota
|
|
||||||
oggStreamOpus
|
|
||||||
oggStreamVorbis
|
|
||||||
)
|
|
||||||
|
|
||||||
func detectOggStreamType(packets [][]byte) oggStreamType {
|
|
||||||
for _, p := range packets {
|
|
||||||
if len(p) >= 8 && string(p[0:8]) == "OpusHead" {
|
|
||||||
return oggStreamOpus
|
|
||||||
}
|
|
||||||
if len(p) > 7 && p[0] == 0x01 && string(p[1:7]) == "vorbis" {
|
|
||||||
return oggStreamVorbis
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return oggStreamUnknown
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
|
||||||
if len(data) < 4 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
reader := bytes.NewReader(data)
|
|
||||||
artistValues := make([]string, 0, 1)
|
|
||||||
albumArtistValues := make([]string, 0, 1)
|
|
||||||
|
|
||||||
var vendorLen uint32
|
|
||||||
if err := binary.Read(reader, binary.LittleEndian, &vendorLen); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if vendorLen > uint32(len(data)-4) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
vendor := make([]byte, vendorLen)
|
|
||||||
if _, err := reader.Read(vendor); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var commentCount uint32
|
|
||||||
if err := binary.Read(reader, binary.LittleEndian, &commentCount); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := uint32(0); i < commentCount && i < 100; i++ {
|
|
||||||
var commentLen uint32
|
|
||||||
if err := binary.Read(reader, binary.LittleEndian, &commentLen); err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
remaining := uint32(reader.Len())
|
|
||||||
if commentLen > remaining {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if commentLen > 512*1024 {
|
|
||||||
reader.Seek(int64(commentLen), io.SeekCurrent)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
comment := make([]byte, commentLen)
|
|
||||||
if _, err := reader.Read(comment); err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.SplitN(string(comment), "=", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
key := strings.ToUpper(parts[0])
|
|
||||||
value := parts[1]
|
|
||||||
|
|
||||||
switch key {
|
|
||||||
case "TITLE":
|
|
||||||
metadata.Title = value
|
|
||||||
case "ARTIST":
|
|
||||||
artistValues = append(artistValues, value)
|
|
||||||
case "ALBUMARTIST", "ALBUM_ARTIST", "ALBUM ARTIST":
|
|
||||||
albumArtistValues = append(albumArtistValues, value)
|
|
||||||
case "ALBUM":
|
|
||||||
metadata.Album = value
|
|
||||||
case "DATE", "YEAR":
|
|
||||||
metadata.Date = value
|
|
||||||
if len(value) >= 4 {
|
|
||||||
metadata.Year = value[:4]
|
|
||||||
}
|
|
||||||
case "GENRE":
|
|
||||||
metadata.Genre = value
|
|
||||||
case "TRACKNUMBER", "TRACK":
|
|
||||||
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
|
||||||
case "DISCNUMBER", "DISC":
|
|
||||||
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
|
||||||
case "ISRC":
|
|
||||||
metadata.ISRC = value
|
|
||||||
case "COMPOSER":
|
|
||||||
metadata.Composer = value
|
|
||||||
case "COMMENT", "DESCRIPTION":
|
|
||||||
metadata.Comment = value
|
|
||||||
case "LYRICS", "UNSYNCEDLYRICS":
|
|
||||||
if metadata.Lyrics == "" {
|
|
||||||
metadata.Lyrics = value
|
|
||||||
}
|
|
||||||
case "ORGANIZATION", "LABEL", "PUBLISHER":
|
|
||||||
metadata.Label = value
|
|
||||||
case "COPYRIGHT":
|
|
||||||
metadata.Copyright = value
|
|
||||||
case "REPLAYGAIN_TRACK_GAIN":
|
|
||||||
metadata.ReplayGainTrackGain = value
|
|
||||||
case "REPLAYGAIN_TRACK_PEAK":
|
|
||||||
metadata.ReplayGainTrackPeak = value
|
|
||||||
case "REPLAYGAIN_ALBUM_GAIN":
|
|
||||||
metadata.ReplayGainAlbumGain = value
|
|
||||||
case "REPLAYGAIN_ALBUM_PEAK":
|
|
||||||
metadata.ReplayGainAlbumPeak = value
|
|
||||||
// Opus gain tags (RFC 7845): Q7.8 fixed point on the R128 -23 LUFS
|
|
||||||
// reference. Exposed as ReplayGain 2 dB (-18 LUFS reference) so
|
|
||||||
// consumers see one representation; explicit REPLAYGAIN_* wins.
|
|
||||||
case "R128_TRACK_GAIN":
|
|
||||||
if metadata.ReplayGainTrackGain == "" {
|
|
||||||
if db, ok := r128ToReplayGainDb(value); ok {
|
|
||||||
metadata.ReplayGainTrackGain = db
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "R128_ALBUM_GAIN":
|
|
||||||
if metadata.ReplayGainAlbumGain == "" {
|
|
||||||
if db, ok := r128ToReplayGainDb(value); ok {
|
|
||||||
metadata.ReplayGainAlbumGain = db
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(artistValues) > 0 {
|
|
||||||
metadata.Artist = joinVorbisCommentValues(artistValues)
|
|
||||||
}
|
|
||||||
if len(albumArtistValues) > 0 {
|
|
||||||
metadata.AlbumArtist = joinVorbisCommentValues(albumArtistValues)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// r128ToReplayGainDb converts an R128_*_GAIN value (integer, 1/256 dB steps,
|
|
||||||
// -23 LUFS reference) to a ReplayGain 2 dB string (-18 LUFS reference):
|
|
||||||
// rg = q/256 + 5. Inverse of the writer's replayGainDbToR128.
|
|
||||||
func r128ToReplayGainDb(raw string) (string, bool) {
|
|
||||||
q, err := strconv.Atoi(strings.TrimSpace(raw))
|
|
||||||
if err != nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%.2f dB", float64(q)/256.0+5.0), true
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetOggQuality(filePath string) (*OggQuality, error) {
|
|
||||||
file, err := os.Open(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
quality := &OggQuality{}
|
|
||||||
|
|
||||||
packets, err := collectOggPackets(file, 5, 10)
|
|
||||||
if err != nil && len(packets) == 0 {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
streamType := detectOggStreamType(packets)
|
|
||||||
if streamType == oggStreamUnknown {
|
|
||||||
if strings.HasSuffix(strings.ToLower(filePath), ".opus") {
|
|
||||||
streamType = oggStreamOpus
|
|
||||||
} else {
|
|
||||||
streamType = oggStreamVorbis
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isOpus := streamType == oggStreamOpus
|
|
||||||
var preSkip int
|
|
||||||
|
|
||||||
if isOpus {
|
|
||||||
for _, pkt := range packets {
|
|
||||||
if len(pkt) >= 19 && string(pkt[0:8]) == "OpusHead" {
|
|
||||||
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
|
|
||||||
if quality.SampleRate == 0 {
|
|
||||||
quality.SampleRate = 48000
|
|
||||||
}
|
|
||||||
preSkip = int(binary.LittleEndian.Uint16(pkt[10:12]))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for _, pkt := range packets {
|
|
||||||
if len(pkt) > 29 && pkt[0] == 0x01 && string(pkt[1:7]) == "vorbis" {
|
|
||||||
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stat, err := file.Stat()
|
|
||||||
if err != nil {
|
|
||||||
return quality, nil
|
|
||||||
}
|
|
||||||
fileSize := stat.Size()
|
|
||||||
|
|
||||||
granule := readLastOggGranulePosition(file, fileSize)
|
|
||||||
if granule > 0 {
|
|
||||||
if isOpus {
|
|
||||||
totalSamples := granule - int64(preSkip)
|
|
||||||
if totalSamples > 0 {
|
|
||||||
durationSec := float64(totalSamples) / 48000.0
|
|
||||||
if durationSec > 0 {
|
|
||||||
quality.Duration = int(math.Round(durationSec))
|
|
||||||
quality.Bitrate = int(float64(fileSize*8) / durationSec)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if quality.SampleRate > 0 {
|
|
||||||
durationSec := float64(granule) / float64(quality.SampleRate)
|
|
||||||
if durationSec > 0 {
|
|
||||||
quality.Duration = int(math.Round(durationSec))
|
|
||||||
quality.Bitrate = int(float64(fileSize*8) / durationSec)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if quality.Bitrate <= 0 && quality.Duration > 0 {
|
|
||||||
quality.Bitrate = int(fileSize * 8 / int64(quality.Duration))
|
|
||||||
}
|
|
||||||
if quality.Duration > 24*60*60 {
|
|
||||||
quality.Duration = 0
|
|
||||||
quality.Bitrate = 0
|
|
||||||
}
|
|
||||||
if quality.Bitrate > 0 && quality.Bitrate < 8000 {
|
|
||||||
quality.Bitrate = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return quality, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
|
|
||||||
searchSize := int64(65536)
|
|
||||||
if searchSize > fileSize {
|
|
||||||
searchSize = fileSize
|
|
||||||
}
|
|
||||||
|
|
||||||
buf := make([]byte, searchSize)
|
|
||||||
offset := fileSize - searchSize
|
|
||||||
if offset < 0 {
|
|
||||||
offset = 0
|
|
||||||
}
|
|
||||||
n, err := file.ReadAt(buf, offset)
|
|
||||||
if err != nil && n == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
buf = buf[:n]
|
|
||||||
|
|
||||||
for i := n - 4; i >= 0; i-- {
|
|
||||||
if buf[i] != 'O' || buf[i+1] != 'g' || buf[i+2] != 'g' || buf[i+3] != 'S' {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if i+27 > n {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
version := buf[i+4]
|
|
||||||
headerType := buf[i+5]
|
|
||||||
if version != 0 || headerType > 0x07 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
segmentCount := int(buf[i+26])
|
|
||||||
headerLen := 27 + segmentCount
|
|
||||||
if i+headerLen > n {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
payloadLen := 0
|
|
||||||
for s := 0; s < segmentCount; s++ {
|
|
||||||
payloadLen += int(buf[i+27+s])
|
|
||||||
}
|
|
||||||
if i+headerLen+payloadLen > n {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return int64(binary.LittleEndian.Uint64(buf[i+6 : i+14]))
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,174 +9,70 @@ import (
|
|||||||
// ErrDownloadCancelled is returned when a download is cancelled by the user.
|
// ErrDownloadCancelled is returned when a download is cancelled by the user.
|
||||||
var ErrDownloadCancelled = errors.New("download cancelled")
|
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 {
|
type cancelEntry struct {
|
||||||
ctx context.Context
|
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
canceled bool
|
canceled bool
|
||||||
refs int
|
|
||||||
}
|
|
||||||
|
|
||||||
type cancelRegistry struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
entries map[string]*cancelEntry
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
downloadCancels = &cancelRegistry{entries: make(map[string]*cancelEntry)}
|
cancelMu sync.Mutex
|
||||||
extensionRequestCancels = &cancelRegistry{entries: make(map[string]*cancelEntry)}
|
cancelMap = make(map[string]*cancelEntry)
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *cancelRegistry) init(id string) context.Context {
|
func initDownloadCancel(itemID string) context.Context {
|
||||||
if id == "" {
|
if itemID == "" {
|
||||||
return context.Background()
|
return context.Background()
|
||||||
}
|
}
|
||||||
|
|
||||||
r.mu.Lock()
|
cancelMu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer cancelMu.Unlock()
|
||||||
|
|
||||||
if entry, ok := r.entries[id]; 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())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
r.entries[id] = &cancelEntry{
|
cancelMap[itemID] = &cancelEntry{
|
||||||
ctx: ctx,
|
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
canceled: false,
|
canceled: false,
|
||||||
refs: 1,
|
|
||||||
}
|
}
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *cancelRegistry) context(id string) context.Context {
|
|
||||||
if id == "" {
|
|
||||||
return context.Background()
|
|
||||||
}
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
if entry, ok := r.entries[id]; ok && entry.ctx != nil {
|
|
||||||
return entry.ctx
|
|
||||||
}
|
|
||||||
return context.Background()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *cancelRegistry) requestCancel(id string) {
|
|
||||||
if id == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
r.mu.Lock()
|
|
||||||
if entry, ok := r.entries[id]; ok {
|
|
||||||
entry.canceled = true
|
|
||||||
if entry.cancel != nil {
|
|
||||||
entry.cancel()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
r.entries[id] = &cancelEntry{canceled: true}
|
|
||||||
}
|
|
||||||
r.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *cancelRegistry) isCancelled(id string) bool {
|
|
||||||
if id == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
r.mu.Lock()
|
|
||||||
entry, ok := r.entries[id]
|
|
||||||
canceled := ok && entry.canceled
|
|
||||||
r.mu.Unlock()
|
|
||||||
return canceled
|
|
||||||
}
|
|
||||||
|
|
||||||
// resetIfIdle removes a cancellation entry that has no active work 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 (r *cancelRegistry) resetIfIdle(id string) {
|
|
||||||
if id == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
r.mu.Lock()
|
|
||||||
if entry, ok := r.entries[id]; ok && entry.refs <= 0 {
|
|
||||||
delete(r.entries, id)
|
|
||||||
}
|
|
||||||
r.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *cancelRegistry) release(id string) {
|
|
||||||
if id == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
r.mu.Lock()
|
|
||||||
if entry, ok := r.entries[id]; ok {
|
|
||||||
entry.refs--
|
|
||||||
if entry.refs <= 0 {
|
|
||||||
delete(r.entries, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
r.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func initDownloadCancel(itemID string) context.Context {
|
|
||||||
return downloadCancels.init(itemID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func downloadCancelContext(itemID string) context.Context {
|
|
||||||
return downloadCancels.context(itemID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cancelDownload(itemID string) {
|
func cancelDownload(itemID string) {
|
||||||
if itemID == "" {
|
if itemID == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
downloadCancels.requestCancel(itemID)
|
|
||||||
|
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)
|
RemoveItemProgress(itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func isDownloadCancelled(itemID string) bool {
|
func isDownloadCancelled(itemID string) bool {
|
||||||
return downloadCancels.isCancelled(itemID)
|
if itemID == "" {
|
||||||
}
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func resetDownloadCancel(itemID string) {
|
cancelMu.Lock()
|
||||||
downloadCancels.resetIfIdle(itemID)
|
entry, ok := cancelMap[itemID]
|
||||||
|
canceled := ok && entry.canceled
|
||||||
|
cancelMu.Unlock()
|
||||||
|
return canceled
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearDownloadCancel(itemID string) {
|
func clearDownloadCancel(itemID string) {
|
||||||
downloadCancels.release(itemID)
|
if itemID == "" {
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
func initExtensionRequestCancel(requestID string) context.Context {
|
cancelMu.Lock()
|
||||||
return extensionRequestCancels.init(requestID)
|
delete(cancelMap, itemID)
|
||||||
}
|
cancelMu.Unlock()
|
||||||
|
|
||||||
func extensionRequestCancelContext(requestID string) context.Context {
|
|
||||||
return extensionRequestCancels.context(requestID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cancelExtensionRequest(requestID string) {
|
|
||||||
extensionRequestCancels.requestCancel(requestID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isExtensionRequestCancelled(requestID string) bool {
|
|
||||||
return extensionRequestCancels.isCancelled(requestID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func clearExtensionRequestCancel(requestID string) {
|
|
||||||
extensionRequestCancels.release(requestID)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
|
||||||
_ "image/jpeg"
|
|
||||||
_ "image/png"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -20,16 +14,11 @@ const (
|
|||||||
spotifySizeMax = "ab67616d000082c1"
|
spotifySizeMax = "ab67616d000082c1"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Square CDN covers using this path shape may return an image whose decoded
|
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
|
||||||
// dimensions differ from the dimensions advertised in the URL. Max-quality
|
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
||||||
// selection therefore probes both useful high-resolution variants and checks
|
|
||||||
// the image headers instead of trusting the filename.
|
|
||||||
var squareCoverSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
|
||||||
|
|
||||||
var tidalSizeRegex = regexp.MustCompile(`/\d+x\d+\.jpg$`)
|
var tidalSizeRegex = regexp.MustCompile(`/\d+x\d+\.jpg$`)
|
||||||
|
|
||||||
var qobuzSizeRegex = regexp.MustCompile(`_\d+\.jpg$`)
|
|
||||||
|
|
||||||
func convertSmallToMedium(imageURL string) string {
|
func convertSmallToMedium(imageURL string) string {
|
||||||
if strings.Contains(imageURL, spotifySize300) {
|
if strings.Contains(imageURL, spotifySize300) {
|
||||||
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||||
@@ -49,237 +38,18 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
|||||||
GoLog("[Cover] Upgraded 300x300 → 640x640")
|
GoLog("[Cover] Upgraded 300x300 → 640x640")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !maxQuality {
|
if maxQuality {
|
||||||
GoLog("[Cover] Final URL: %s", downloadURL)
|
maxURL := upgradeToMaxQuality(downloadURL)
|
||||||
data, err := fetchCoverCached(downloadURL)
|
if maxURL != downloadURL {
|
||||||
if err != nil {
|
downloadURL = maxURL
|
||||||
return nil, err
|
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
|
||||||
}
|
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
|
||||||
return append([]byte(nil), data...), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
candidates := maxQualityCoverCandidateURLs(downloadURL)
|
|
||||||
data, selectedURL, width, height, err := fetchBestCoverCandidate(candidates)
|
|
||||||
if err != nil {
|
|
||||||
// A CDN can reject an upgraded size while the provider-supplied URL is
|
|
||||||
// still valid. Preserve that URL as the final fallback.
|
|
||||||
if len(candidates) == 1 && candidates[0] == downloadURL {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
data, err = fetchCoverCached(downloadURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
selectedURL = downloadURL
|
|
||||||
width, height = coverDimensions(data)
|
|
||||||
}
|
|
||||||
GoLog("[Cover] Selected URL: %s (%dx%d, %d KB)", selectedURL, width, height, len(data)/1024)
|
|
||||||
// Cached bytes are shared across goroutines and must never be mutated;
|
|
||||||
// hand callers their own copy.
|
|
||||||
return append([]byte(nil), data...), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type fetchedCoverCandidate struct {
|
|
||||||
url string
|
|
||||||
data []byte
|
|
||||||
width, height int
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func maxQualityCoverCandidateURLs(coverURL string) []string {
|
|
||||||
upgraded := upgradeToMaxQuality(coverURL)
|
|
||||||
candidates := []string{upgraded}
|
|
||||||
// This is deliberately based on the URL capability rather than a provider
|
|
||||||
// or extension ID. Any metadata source returning the same square-cover URL
|
|
||||||
// shape receives the same verified candidate selection.
|
|
||||||
if squareCoverSizeRegex.MatchString(coverURL) {
|
|
||||||
candidate1500 := squareCoverSizeRegex.ReplaceAllString(
|
|
||||||
coverURL,
|
|
||||||
"/1500x1500-000000-80-0-0.jpg",
|
|
||||||
)
|
|
||||||
if candidate1500 != upgraded {
|
|
||||||
candidates = append(candidates, candidate1500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return uniqueNonEmptyStrings(candidates)
|
|
||||||
}
|
|
||||||
|
|
||||||
func uniqueNonEmptyStrings(values []string) []string {
|
|
||||||
seen := make(map[string]struct{}, len(values))
|
|
||||||
result := make([]string, 0, len(values))
|
|
||||||
for _, value := range values {
|
|
||||||
value = strings.TrimSpace(value)
|
|
||||||
if value == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, exists := seen[value]; exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[value] = struct{}{}
|
|
||||||
result = append(result, value)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchBestCoverCandidate(urls []string) ([]byte, string, int, int, error) {
|
|
||||||
if len(urls) == 0 {
|
|
||||||
return nil, "", 0, 0, fmt.Errorf("no cover candidates available")
|
|
||||||
}
|
|
||||||
results := make(chan fetchedCoverCandidate, len(urls))
|
|
||||||
for _, candidateURL := range urls {
|
|
||||||
go func(url string) {
|
|
||||||
data, err := fetchCoverCached(url)
|
|
||||||
width, height := coverDimensions(data)
|
|
||||||
results <- fetchedCoverCandidate{
|
|
||||||
url: url, data: data, width: width, height: height, err: err,
|
|
||||||
}
|
|
||||||
}(candidateURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
var best *fetchedCoverCandidate
|
|
||||||
var firstErr error
|
|
||||||
for range urls {
|
|
||||||
candidate := <-results
|
|
||||||
if candidate.err != nil || len(candidate.data) == 0 {
|
|
||||||
if firstErr == nil {
|
|
||||||
firstErr = candidate.err
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if best == nil || coverCandidateBetter(candidate, *best) {
|
|
||||||
copy := candidate
|
|
||||||
best = ©
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if best == nil {
|
|
||||||
if firstErr == nil {
|
|
||||||
firstErr = fmt.Errorf("cover candidates returned no image data")
|
|
||||||
}
|
|
||||||
return nil, "", 0, 0, firstErr
|
|
||||||
}
|
|
||||||
return best.data, best.url, best.width, best.height, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func coverDimensions(data []byte) (int, int) {
|
|
||||||
if len(data) == 0 {
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
config, _, err := image.DecodeConfig(bytes.NewReader(data))
|
|
||||||
if err != nil || config.Width <= 0 || config.Height <= 0 {
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
return config.Width, config.Height
|
|
||||||
}
|
|
||||||
|
|
||||||
func coverCandidateBetter(candidate, current fetchedCoverCandidate) bool {
|
|
||||||
candidatePixels := int64(candidate.width) * int64(candidate.height)
|
|
||||||
currentPixels := int64(current.width) * int64(current.height)
|
|
||||||
if candidatePixels != currentPixels {
|
|
||||||
return candidatePixels > currentPixels
|
|
||||||
}
|
|
||||||
return len(candidate.data) > len(current.data)
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
GoLog("[Cover] Final URL: %s", downloadURL)
|
||||||
|
|
||||||
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
req, err := http.NewRequest("GET", downloadURL, nil)
|
||||||
@@ -302,8 +72,16 @@ func fetchCoverBytes(downloadURL string) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("failed to read cover data: %w", err)
|
return nil, fmt.Errorf("failed to read cover data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
width, height := coverDimensions(data)
|
sizeKB := len(data) / 1024
|
||||||
GoLog("[Cover] Downloaded %d KB (%dx%d)", len(data)/1024, width, height)
|
var resolution string
|
||||||
|
if sizeKB > 200 {
|
||||||
|
resolution = "~2000x2000 (hi-res)"
|
||||||
|
} else if sizeKB > 50 {
|
||||||
|
resolution = "~640x640"
|
||||||
|
} else {
|
||||||
|
resolution = "~300x300"
|
||||||
|
}
|
||||||
|
GoLog("[Cover] Downloaded %d KB (%s)", sizeKB, resolution)
|
||||||
|
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
@@ -313,8 +91,8 @@ func upgradeToMaxQuality(coverURL string) string {
|
|||||||
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if squareCoverSizeRegex.MatchString(coverURL) {
|
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||||
return upgradeSquareCover(coverURL)
|
return upgradeDeezerCover(coverURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(coverURL, "resources.tidal.com") {
|
if strings.Contains(coverURL, "resources.tidal.com") {
|
||||||
@@ -328,14 +106,14 @@ func upgradeToMaxQuality(coverURL string) string {
|
|||||||
return coverURL
|
return coverURL
|
||||||
}
|
}
|
||||||
|
|
||||||
func upgradeSquareCover(coverURL string) string {
|
func upgradeDeezerCover(coverURL string) string {
|
||||||
if !squareCoverSizeRegex.MatchString(coverURL) {
|
if !strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||||
return coverURL
|
return coverURL
|
||||||
}
|
}
|
||||||
|
|
||||||
upgraded := squareCoverSizeRegex.ReplaceAllString(coverURL, "/1900x1900-000000-80-0-0.jpg")
|
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
|
||||||
if upgraded != coverURL {
|
if upgraded != coverURL {
|
||||||
GoLog("[Cover] Square CDN: probing 1900x1900 and 1500x1500")
|
GoLog("[Cover] Deezer: upgraded to 1800x1800")
|
||||||
}
|
}
|
||||||
return upgraded
|
return upgraded
|
||||||
}
|
}
|
||||||
@@ -357,9 +135,23 @@ func upgradeQobuzCover(coverURL string) string {
|
|||||||
return coverURL
|
return coverURL
|
||||||
}
|
}
|
||||||
|
|
||||||
upgraded := qobuzSizeRegex.ReplaceAllString(coverURL, "_max.jpg")
|
upgraded := qobuzImageSizeRe.ReplaceAllString(coverURL, "_max.jpg")
|
||||||
if upgraded != coverURL {
|
if upgraded != coverURL {
|
||||||
GoLog("[Cover] Qobuz: upgraded to max resolution")
|
GoLog("[Cover] Qobuz: upgraded to max resolution")
|
||||||
}
|
}
|
||||||
return upgraded
|
return upgraded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
||||||
|
if imageURL == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
result := convertSmallToMedium(imageURL)
|
||||||
|
|
||||||
|
if maxQuality {
|
||||||
|
result = upgradeToMaxQuality(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||