mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 17:18:36 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3f9388ba8 | ||
|
|
c04050459a | ||
|
|
e761dd604f | ||
|
|
bad48ea0fd | ||
|
|
f7a0c8e993 | ||
|
|
7972fd64a4 | ||
|
|
7f4ace6d1b | ||
|
|
d4b617f49a | ||
|
|
ed60cafb90 | ||
|
|
19753ca005 | ||
|
|
611f7377f6 | ||
|
|
645a324a5f | ||
|
|
6a3a85f7c9 | ||
|
|
b3bbe93c1e | ||
|
|
15b1b757fd | ||
|
|
dc58353c07 | ||
|
|
3e41aa0eee | ||
|
|
d85c8f6a17 | ||
|
|
5357e14b06 | ||
|
|
49d0bd6a99 | ||
|
|
5228b0030b | ||
|
|
66928e259d | ||
|
|
113ad7ed9b | ||
|
|
177a176218 | ||
|
|
169a811162 | ||
|
|
76f7b9c025 | ||
|
|
12f2b12570 | ||
|
|
04a8700a65 | ||
|
|
43a8d08851 | ||
|
|
6f94aae0cc | ||
|
|
d4bed9c643 | ||
|
|
24b9d7ba80 | ||
|
|
cc93c5020d | ||
|
|
af28bbd492 | ||
|
|
1458b74d5b | ||
|
|
6e60f33fd0 | ||
|
|
4ee7558bca | ||
|
|
78670a6ea3 | ||
|
|
7230dd3df8 | ||
|
|
eed48a5fec | ||
|
|
1443be6a4f | ||
|
|
dc4c75363c | ||
|
|
309777cc71 | ||
|
|
d765320f10 | ||
|
|
40034d184f | ||
|
|
dbdbf0ca41 | ||
|
|
fa0c7b20b5 | ||
|
|
c6f7b22e43 | ||
|
|
834a8dde89 | ||
|
|
1e973d5ecb | ||
|
|
5b45ef80bf | ||
|
|
e7fbaf7b75 | ||
|
|
1f55523b64 | ||
|
|
452d9a76cb | ||
|
|
8dcf7fa998 | ||
|
|
662558e3e6 | ||
|
|
b1c18b95e7 | ||
|
|
1bf61884cd | ||
|
|
d7710e2a38 | ||
|
|
b2e4548204 | ||
|
|
7eb0ed2fdb | ||
|
|
1390376f09 | ||
|
|
650c360eb9 | ||
|
|
d1db2cdcc5 | ||
|
|
6c89a1e177 | ||
|
|
fb538e9517 | ||
|
|
209eb5f10e | ||
|
|
385afe290a | ||
|
|
b4f239c692 | ||
|
|
7bf6f32802 | ||
|
|
6368d1feee | ||
|
|
7f277f7a88 | ||
|
|
4c33f112ad | ||
|
|
80d9d87870 | ||
|
|
26570792a9 | ||
|
|
ad08b95c0f | ||
|
|
454ef53977 | ||
|
|
e4cd52f29f | ||
|
|
af6ca12e85 | ||
|
|
9b4a960324 | ||
|
|
1cdf4b4547 | ||
|
|
73846c40ca | ||
|
|
856e2cbb9e | ||
|
|
3b901461f6 | ||
|
|
ef3e019a3a | ||
|
|
eaafeef1bb | ||
|
|
50375b751e | ||
|
|
2a1c9e0904 | ||
|
|
0278fad278 | ||
|
|
efe703d60d | ||
|
|
55be8fc827 | ||
|
|
c42a1be76c | ||
|
|
ac56e76b62 | ||
|
|
9da3607932 | ||
|
|
e8a3fc58c7 | ||
|
|
52b1147275 | ||
|
|
8fe2662c0a | ||
|
|
23f18ab276 | ||
|
|
c9b1898cc0 | ||
|
|
e8290a5eb0 | ||
|
|
d0dca1a101 | ||
|
|
5ee7f733cf | ||
|
|
6424647472 | ||
|
|
4539b3fd67 | ||
|
|
bd2bd85c9e | ||
|
|
1b861f9ae8 | ||
|
|
8a95f684d2 | ||
|
|
358154c39e | ||
|
|
bfd44738e2 | ||
|
|
0b053d59b1 | ||
|
|
21bc31e0b6 | ||
|
|
ac90e7f517 | ||
|
|
4ab6388e18 | ||
|
|
5412037ac8 | ||
|
|
ea172ba17b | ||
|
|
b81bd5e8aa | ||
|
|
00d03de2b1 | ||
|
|
49bfefb750 | ||
|
|
9dc44ecd88 | ||
|
|
2dbf8f6f9b | ||
|
|
2ce793d68f | ||
|
|
cbee637501 | ||
|
|
1cda88cafc | ||
|
|
6fcfb67af2 | ||
|
|
183744c41e | ||
|
|
97b87472fe | ||
|
|
fa7ef84b39 | ||
|
|
42c266eafc | ||
|
|
74615a60b5 | ||
|
|
8d077ae2da | ||
|
|
11fb4365a2 | ||
|
|
424ca5d77e | ||
|
|
69830f6d46 | ||
|
|
cb8064a317 | ||
|
|
a21d5bcb33 | ||
|
|
50c8f0e454 | ||
|
|
97758908ca | ||
|
|
04a71d0211 | ||
|
|
d4f0b84615 | ||
|
|
901fa34d80 | ||
|
|
d38a368f63 | ||
|
|
984beb92e8 | ||
|
|
5ca04a8534 | ||
|
|
b31f20d2bb | ||
|
|
d67510fb36 | ||
|
|
d3ab52d65d | ||
|
|
3f576562ef | ||
|
|
c497449549 | ||
|
|
54dcdb92dd | ||
|
|
c9470ef751 | ||
|
|
48f01ba146 | ||
|
|
5f5186a23e | ||
|
|
b93ebc1e8d | ||
|
|
c4abbf8743 | ||
|
|
9b35c6dc42 | ||
|
|
1c95109b7b | ||
|
|
08d648cfa8 | ||
|
|
40dd9bb68f | ||
|
|
aa0eecb27e | ||
|
|
b956c57652 | ||
|
|
fbe0674b04 | ||
|
|
1705fb2a81 | ||
|
|
a5f2760023 | ||
|
|
20081b14bd | ||
|
|
d4c38b7550 | ||
|
|
c681b891eb | ||
|
|
c19477a4f4 | ||
|
|
1cfda41ef2 | ||
|
|
19787ed461 | ||
|
|
a7069a8f91 | ||
|
|
bb127d1cc2 | ||
|
|
927ca6e4ca | ||
|
|
66cf998fb2 | ||
|
|
93512fc3f7 | ||
|
|
555f0e8117 | ||
|
|
cbd8fd0342 | ||
|
|
60a74a7e0e | ||
|
|
37db310157 | ||
|
|
13fe8b4593 | ||
|
|
a5f96b57e6 | ||
|
|
f0a7a043ec | ||
|
|
58249d6a13 | ||
|
|
d9dc7ddf3a | ||
|
|
cc1ad1552c | ||
|
|
a3feb39e20 | ||
|
|
7676579659 | ||
|
|
bcb3729fa9 | ||
|
|
49a824c171 | ||
|
|
2a050f113b | ||
|
|
e4b99dd44b | ||
|
|
18584ab81f | ||
|
|
a4f7360336 | ||
|
|
16b44d2d3a | ||
|
|
71c08501e7 | ||
|
|
297cddf31d | ||
|
|
57eef4311f | ||
|
|
b5e5fb5cd2 | ||
|
|
9a9152f7f8 | ||
|
|
d9ceb8ac78 | ||
|
|
d6726f976a | ||
|
|
bbfc724ceb | ||
|
|
7d3a6f9780 | ||
|
|
19fccde3a3 | ||
|
|
2cc2ac6efd | ||
|
|
d1ede223cf |
@@ -0,0 +1,24 @@
|
|||||||
|
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,5 +1,9 @@
|
|||||||
* 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,30 +1,29 @@
|
|||||||
name: Bug Report
|
name: Bug Report
|
||||||
description: Report a bug or unexpected behavior
|
description: Report a reproducible bug or unexpected behavior
|
||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels: ["bug"]
|
labels: ["bug"]
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Thanks for taking the time to report a bug! Please fill out the form below.
|
Thanks for reporting a SpotiFLAC Mobile bug. Please provide enough detail for another person to reproduce it.
|
||||||
|
|
||||||
- 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 have searched existing issues and this bug hasn't been reported yet
|
- label: I searched existing issues and did not find this bug.
|
||||||
required: true
|
required: true
|
||||||
- label: I am using the latest version of SpotiFLAC (Stable Version)
|
- label: I can reproduce this on the release channel selected below.
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: description
|
id: description
|
||||||
attributes:
|
attributes:
|
||||||
label: Bug Description
|
label: Description
|
||||||
description: A clear and concise description of what the bug is
|
description: What happened, and what did you expect instead?
|
||||||
placeholder: Describe the bug...
|
placeholder: Include the visible error and any relevant context.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
@@ -32,29 +31,26 @@ body:
|
|||||||
id: steps
|
id: steps
|
||||||
attributes:
|
attributes:
|
||||||
label: Steps to Reproduce
|
label: Steps to Reproduce
|
||||||
description: Steps to reproduce the behavior
|
|
||||||
placeholder: |
|
placeholder: |
|
||||||
1. Go to '...'
|
1. Open ...
|
||||||
2. Click on '...'
|
2. Enable ...
|
||||||
3. See error
|
3. Tap ...
|
||||||
|
4. Observe ...
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: dropdown
|
||||||
id: expected
|
id: trigger
|
||||||
attributes:
|
attributes:
|
||||||
label: Expected Behavior
|
label: Area
|
||||||
description: What did you expect to happen?
|
options:
|
||||||
placeholder: Describe what you expected...
|
- App startup
|
||||||
validations:
|
- Search or navigation
|
||||||
required: true
|
- Download or verification
|
||||||
|
- Library or metadata
|
||||||
- type: textarea
|
- Playback or background service
|
||||||
id: actual
|
- Audio analysis
|
||||||
attributes:
|
- Other
|
||||||
label: Actual Behavior
|
|
||||||
description: What actually happened?
|
|
||||||
placeholder: Describe what actually happened...
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
@@ -62,62 +58,73 @@ body:
|
|||||||
id: version
|
id: version
|
||||||
attributes:
|
attributes:
|
||||||
label: App Version
|
label: App Version
|
||||||
description: Which version of SpotiFLAC are you using? (Check in Settings > About)
|
description: Find this in Settings > About.
|
||||||
placeholder: "e.g., v2.2.0"
|
placeholder: "e.g. 4.8.1 (139)"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: platform
|
id: channel
|
||||||
attributes:
|
attributes:
|
||||||
label: Platform
|
label: Release Channel
|
||||||
description: Which platform are you using?
|
|
||||||
options:
|
options:
|
||||||
- Android
|
- Stable
|
||||||
- iOS
|
- Beta
|
||||||
|
- Local or development build
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: device
|
id: device
|
||||||
attributes:
|
attributes:
|
||||||
label: Device & OS Version
|
label: Device and OS
|
||||||
description: What device and OS version are you using?
|
placeholder: "e.g. Samsung Galaxy S24, Android 16"
|
||||||
placeholder: "e.g., Samsung Galaxy S24, Android 14"
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: download-service
|
id: storage
|
||||||
attributes:
|
attributes:
|
||||||
label: Download Service
|
label: Download Storage
|
||||||
description: Which download service were you using when the bug occurred?
|
|
||||||
options:
|
options:
|
||||||
- Tidal
|
- SAF folder
|
||||||
- Qobuz
|
- App-specific folder
|
||||||
- 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 / Screenshots
|
label: Logs and Media
|
||||||
description: |
|
description: |
|
||||||
If applicable, add logs or screenshots to help explain your problem.
|
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.
|
||||||
|
placeholder: Paste logs or drag files here.
|
||||||
**To get logs:**
|
validations:
|
||||||
1. Go to Settings > Options > Detailed Logging (turn ON)
|
required: true
|
||||||
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
|
||||||
description: Any other context about the problem
|
placeholder: Settings, frequency, regression version, or anything else that may help.
|
||||||
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/zarzet/SpotiFLAC-Mobile#readme
|
url: https://github.com/spotiflacapp/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 extensions
|
about: Documentation for building SpotiFLAC Mobile extensions
|
||||||
|
|||||||
@@ -1,111 +1,124 @@
|
|||||||
name: Download Issue
|
name: Download Issue
|
||||||
description: Report issues with downloading specific tracks or albums
|
description: Report a wrong, missing, failed, or low-quality download
|
||||||
title: "[Download]: "
|
title: "[Download]: "
|
||||||
labels: ["download-issue"]
|
labels: ["download-issue"]
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Having trouble downloading a specific track or album? Please provide details below.
|
Download matching is extension-driven. Include the source item, provider order, and logs so the exact decision path can be inspected.
|
||||||
|
|
||||||
- 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 have tried downloading with a different service (Tidal/Qobuz/Amazon)
|
- label: I searched existing issues and did not find this problem.
|
||||||
required: true
|
required: true
|
||||||
- label: I am using the latest version of SpotiFLAC (Stable Version)
|
- label: I updated the affected extension and retried once.
|
||||||
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 not found on service
|
- Track or album not found
|
||||||
- Wrong track downloaded
|
- Wrong track downloaded
|
||||||
- Download fails/errors
|
- Download fails or stalls
|
||||||
- Metadata incorrect
|
- Metadata or cover is incorrect
|
||||||
- Audio quality issue
|
- Audio format or quality is incorrect
|
||||||
|
- Duplicate or replacement behavior
|
||||||
- Other
|
- Other
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: spotify-url
|
id: source-url
|
||||||
attributes:
|
attributes:
|
||||||
label: Spotify URL
|
label: Source URL
|
||||||
description: The Spotify URL of the track/album you're trying to download
|
description: URL pasted or opened in SpotiFLAC Mobile, if available.
|
||||||
placeholder: "https://open.spotify.com/track/..."
|
placeholder: "https://..."
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: track-info
|
id: track-info
|
||||||
attributes:
|
attributes:
|
||||||
label: Track Info
|
label: Track or Album
|
||||||
description: Artist name and track title
|
placeholder: "Artist - Track or album title"
|
||||||
placeholder: "Artist - Track Title"
|
validations:
|
||||||
|
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: download-service
|
id: storage
|
||||||
attributes:
|
attributes:
|
||||||
label: Download Service
|
label: Download Storage
|
||||||
description: Which service did you try to download from?
|
|
||||||
options:
|
options:
|
||||||
- Tidal
|
- SAF folder
|
||||||
- Qobuz
|
- App-specific folder
|
||||||
- Amazon Music
|
- Not sure
|
||||||
- All services
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: search-service
|
id: worker
|
||||||
attributes:
|
attributes:
|
||||||
label: Search Service
|
label: Download Worker
|
||||||
description: Which search service are you using?
|
|
||||||
options:
|
options:
|
||||||
- Spotify
|
- Native worker
|
||||||
- Deezer
|
- Dart worker
|
||||||
|
- Not sure
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: description
|
id: description
|
||||||
attributes:
|
attributes:
|
||||||
label: Description
|
label: What Happened?
|
||||||
description: Describe the issue in detail
|
description: For a wrong match, identify both the expected and downloaded recording.
|
||||||
placeholder: |
|
placeholder: Describe the result and the expected result.
|
||||||
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: version
|
id: environment
|
||||||
attributes:
|
attributes:
|
||||||
label: App Version
|
label: App Version, Device, and OS
|
||||||
description: Which version of SpotiFLAC are you using?
|
placeholder: "4.8.1 (139), device model, Android/iOS version"
|
||||||
placeholder: "e.g., v2.2.0"
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: screenshots
|
id: logs
|
||||||
attributes:
|
attributes:
|
||||||
label: Screenshots / Logs
|
label: Logs and Screenshots
|
||||||
description: |
|
description: Enable detailed logging, retry once, and export Settings > Logs. Remove credentials and personal data.
|
||||||
If applicable, add screenshots or logs.
|
placeholder: Paste logs or drag files here.
|
||||||
|
validations:
|
||||||
**To get logs:**
|
required: true
|
||||||
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 Extension API!
|
Thanks for helping improve the SpotiFLAC Mobile 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/zarzet/SpotiFLAC-Mobile/blob/main/docs/EXTENSION_DEVELOPMENT.md)
|
- label: I have read the [Extension Development Guide](https://github.com/spotiflacapp/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
|
||||||
- New Service Integration
|
- Extension System/API
|
||||||
- Metadata/Tagging
|
- Metadata/Tagging
|
||||||
- Performance
|
- Performance
|
||||||
- Settings/Configuration
|
- Settings/Configuration
|
||||||
|
|||||||
@@ -1,27 +1,42 @@
|
|||||||
## Description
|
## Summary
|
||||||
|
|
||||||
<!-- What does this PR do? Why is it needed? -->
|
<!-- What changed, why is it needed, and which user-visible behavior is affected? -->
|
||||||
|
|
||||||
## Related Issues
|
## Related Issues
|
||||||
|
|
||||||
<!-- Link related issues, e.g. "Fixes #123" -->
|
<!-- Use "Fixes #123" when this PR should close an issue. -->
|
||||||
|
|
||||||
## Type of Change
|
## Type of Change
|
||||||
|
|
||||||
- [ ] Bug fix
|
- [ ] Bug fix
|
||||||
- [ ] New feature
|
- [ ] Feature
|
||||||
- [ ] Refactor (no functional change)
|
- [ ] Performance improvement
|
||||||
- [ ] Documentation
|
- [ ] Refactor with no intended behavior change
|
||||||
- [ ] Other (describe below)
|
- [ ] Tests, build, or repository maintenance
|
||||||
|
- [ ] Documentation or localization
|
||||||
|
|
||||||
## Checklist
|
## Validation
|
||||||
|
|
||||||
- [ ] Code follows project conventions
|
<!-- Check only commands relevant to this change. Explain skipped checks below. -->
|
||||||
- [ ] `flutter analyze` and `flutter test` pass
|
|
||||||
- [ ] `go vet ./...` and `go test ./...` pass in `go_backend/` (if Go code changed)
|
|
||||||
- [ ] Documentation updated (if needed)
|
|
||||||
- [ ] Commit messages follow the [conventional commits](https://www.conventionalcommits.org) format
|
|
||||||
|
|
||||||
## Screenshots
|
- [ ] `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
|
||||||
|
|
||||||
<!-- For UI changes, add before/after screenshots. Delete this section otherwise. -->
|
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. -->
|
||||||
|
|||||||
+11
-12
@@ -37,6 +37,7 @@ jobs:
|
|||||||
- 'pubspec.lock'
|
- 'pubspec.lock'
|
||||||
- 'analysis_options.yaml'
|
- 'analysis_options.yaml'
|
||||||
- 'l10n.yaml'
|
- 'l10n.yaml'
|
||||||
|
- '.fvmrc'
|
||||||
- '.github/workflows/ci.yml'
|
- '.github/workflows/ci.yml'
|
||||||
go:
|
go:
|
||||||
- 'go_backend/**'
|
- 'go_backend/**'
|
||||||
@@ -51,6 +52,7 @@ jobs:
|
|||||||
- 'lib/services/platform_bridge.dart'
|
- 'lib/services/platform_bridge.dart'
|
||||||
- 'pubspec.yaml'
|
- 'pubspec.yaml'
|
||||||
- 'pubspec.lock'
|
- 'pubspec.lock'
|
||||||
|
- '.fvmrc'
|
||||||
- '.github/workflows/ci.yml'
|
- '.github/workflows/ci.yml'
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
@@ -67,6 +69,7 @@ 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
|
- name: Cache pub dependencies
|
||||||
@@ -102,8 +105,7 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
# Keep in sync with release.yml
|
go-version-file: go_backend/go.mod
|
||||||
go-version: "1.26.5"
|
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
@@ -134,30 +136,27 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "1.26.5"
|
go-version-file: go_backend/go.mod
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
- name: Setup Flutter
|
- name: Setup Flutter
|
||||||
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 Gradle
|
- name: Setup Gradle
|
||||||
uses: actions/cache@v5
|
uses: gradle/actions/setup-gradle@v5
|
||||||
with:
|
with:
|
||||||
path: |
|
gradle-version: "9.6.1"
|
||||||
~/.gradle/caches
|
|
||||||
~/.gradle/wrapper
|
|
||||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
|
||||||
restore-keys: gradle-${{ runner.os }}-
|
|
||||||
|
|
||||||
- name: Install Android SDK & NDK
|
- name: Install Android SDK & NDK
|
||||||
run: |
|
run: |
|
||||||
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
||||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \
|
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \
|
||||||
"ndk;29.0.14206865" \
|
"ndk;29.0.14206865" \
|
||||||
"platforms;android-37" \
|
"platforms;android-37.0" \
|
||||||
"build-tools;37.0.0"
|
"build-tools;37.0.0"
|
||||||
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV"
|
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
@@ -182,4 +181,4 @@ jobs:
|
|||||||
run: echo "flutter.sdk=$FLUTTER_ROOT" > android/local.properties
|
run: echo "flutter.sdk=$FLUTTER_ROOT" > android/local.properties
|
||||||
|
|
||||||
- name: Compile Kotlin and run native unit tests
|
- name: Compile Kotlin and run native unit tests
|
||||||
run: ./android/gradlew -p android :app:compileDebugKotlin :app:testDebugUnitTest
|
run: gradle -p android :app:compileDebugKotlin :app:testDebugUnitTest
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "1.26.5"
|
go-version-file: go_backend/go.mod
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
# Cache Gradle for faster builds
|
# Cache Gradle for faster builds
|
||||||
@@ -100,7 +100,7 @@ jobs:
|
|||||||
|
|
||||||
# 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.
|
# Keep the installed platform aligned with compileSdk/targetSdk.
|
||||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;29.0.14206865" "platforms;android-37" "build-tools;37.0.0"
|
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;29.0.14206865" "platforms;android-37.0" "build-tools;37.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
|
||||||
@@ -129,6 +129,7 @@ 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
|
- name: Cache pub dependencies
|
||||||
@@ -146,13 +147,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Build APK (Release - unsigned)
|
- name: Build APK (Release - unsigned)
|
||||||
run: |
|
run: |
|
||||||
flutter build apk --release --split-per-abi || true
|
flutter build apk --release --split-per-abi \
|
||||||
# Verify APKs were created
|
--target-platform android-arm,android-arm64
|
||||||
ls -la build/app/outputs/flutter-apk/
|
ls -la build/app/outputs/flutter-apk/
|
||||||
if [ ! -f "build/app/outputs/flutter-apk/app-arm64-v8a-release.apk" ]; then
|
test -f build/app/outputs/flutter-apk/app-arm64-v8a-release.apk
|
||||||
echo "ERROR: APK not found!"
|
test -f build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Sign APKs
|
- name: Sign APKs
|
||||||
uses: r0adkll/sign-android-release@v1
|
uses: r0adkll/sign-android-release@v1
|
||||||
@@ -168,12 +167,29 @@ 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
|
|
||||||
mv app-arm64-v8a-release-signed.apk SpotiFLAC-${VERSION}-arm64.apk || mv app-arm64-v8a-release.apk SpotiFLAC-${VERSION}-arm64.apk || true
|
rename_required_apk() {
|
||||||
mv app-armeabi-v7a-release-signed.apk SpotiFLAC-${VERSION}-arm32.apk || mv app-armeabi-v7a-release.apk SpotiFLAC-${VERSION}-arm32.apk || true
|
unsigned="$1"
|
||||||
mv app-release-signed.apk SpotiFLAC-${VERSION}-universal.apk || mv app-release.apk SpotiFLAC-${VERSION}-universal.apk || true
|
destination="$2"
|
||||||
|
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
|
||||||
@@ -198,7 +214,7 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "1.26.5"
|
go-version-file: go_backend/go.mod
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
# Cache CocoaPods
|
# Cache CocoaPods
|
||||||
@@ -274,6 +290,7 @@ 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
|
- name: Cache pub dependencies
|
||||||
@@ -474,7 +491,7 @@ jobs:
|
|||||||
|
|
||||||
jq --arg ver "$VERSION_NUM" \
|
jq --arg ver "$VERSION_NUM" \
|
||||||
--arg date "$DATE" \
|
--arg date "$DATE" \
|
||||||
--arg url "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa" \
|
--arg url "https://github.com/${{ github.repository }}/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
|
||||||
|
|||||||
+70
-63
@@ -1,91 +1,98 @@
|
|||||||
# OS files
|
# Operating-system files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
# IDE
|
# Editors and local assistants
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.iml
|
*.iml
|
||||||
.cursorignore
|
.cursorignore
|
||||||
.cursorrules
|
.cursorrules
|
||||||
|
|
||||||
# Kiro specs (development only)
|
|
||||||
.kiro/
|
.kiro/
|
||||||
|
.playwright-mcp/
|
||||||
|
.rtk/
|
||||||
|
.claude/settings.local.json
|
||||||
|
CLAUDE.md
|
||||||
|
AGENTS.md
|
||||||
|
|
||||||
# Design assets (banners, mockups)
|
# Flutter and Dart
|
||||||
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
|
||||||
|
|
||||||
# Go backend build artifacts
|
# Local JavaScript tooling (the app itself has no Node dependency)
|
||||||
go_backend/*.aar
|
/node_modules/
|
||||||
go_backend/*.jar
|
/bun.lock
|
||||||
go_backend/*.exe
|
/package-lock.json
|
||||||
go_backend/*.xcframework/
|
|
||||||
|
|
||||||
# Android
|
# Go backend build outputs
|
||||||
android/.gradle/
|
/go_backend/*.aar
|
||||||
android/app/libs/gobackend.aar
|
/go_backend/*.jar
|
||||||
android/app/libs/gobackend-sources.jar
|
/go_backend/*.exe
|
||||||
android/local.properties
|
/go_backend/*.xcframework/
|
||||||
android/*.iml
|
|
||||||
android/key.properties
|
|
||||||
android/*.jks
|
|
||||||
android/*.keystore
|
|
||||||
android/app/*.jks
|
|
||||||
|
|
||||||
# iOS
|
# Android build state and signing material
|
||||||
ios/Frameworks/
|
/android/.gradle/
|
||||||
ios/Pods/
|
/android/.kotlin/
|
||||||
ios/.symlinks/
|
/android/app/build/
|
||||||
ios/Flutter/Flutter.framework/
|
/android/app/libs/*.aar
|
||||||
ios/Flutter/Flutter.podspec
|
/android/app/libs/*-sources.jar
|
||||||
|
/android/local.properties
|
||||||
|
/android/*.iml
|
||||||
|
/android/key.properties
|
||||||
|
/android/*.jks
|
||||||
|
/android/*.keystore
|
||||||
|
/android/app/*.jks
|
||||||
|
|
||||||
# Extension folder
|
# iOS generated state
|
||||||
extension/*
|
/ios/Frameworks/
|
||||||
extension/v2/
|
/ios/Pods/
|
||||||
extension/v2/**
|
/ios/.symlinks/
|
||||||
|
/ios/Flutter/ephemeral/
|
||||||
|
/ios/Flutter/Flutter.framework/
|
||||||
|
/ios/Flutter/Flutter.podspec
|
||||||
|
*.xcarchive
|
||||||
|
DerivedData/
|
||||||
|
|
||||||
# Agent instructions
|
# Credentials and machine-local configuration
|
||||||
AGENTS.md
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.mobileprovision
|
||||||
|
*.p12
|
||||||
|
*.pem
|
||||||
|
|
||||||
# Temp/misc
|
# Local research and design material
|
||||||
.tmp/
|
/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
|
NUL
|
||||||
network_requests.txt
|
/network_requests.txt
|
||||||
*.bak
|
|
||||||
/AndroidManifest.xml
|
/AndroidManifest.xml
|
||||||
|
*.bak
|
||||||
# Log files
|
*.orig
|
||||||
|
*.swp
|
||||||
|
*.tmp
|
||||||
*.log
|
*.log
|
||||||
hs_err_*.log
|
hs_err_*.log
|
||||||
flutter_*.log
|
flutter_*.log
|
||||||
|
|
||||||
# Development tools
|
|
||||||
tool/
|
|
||||||
.claude/settings.local.json
|
|
||||||
.playwright-mcp/
|
|
||||||
.rtk/
|
|
||||||
CLAUDE.md
|
|
||||||
|
|
||||||
# FVM Version Cache
|
|
||||||
.fvm/
|
|
||||||
|
|||||||
+521
-13
@@ -1,5 +1,513 @@
|
|||||||
# 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
|
||||||
@@ -745,8 +1253,8 @@ Thank you for your understanding and continued support. This decision was made t
|
|||||||
|
|
||||||
### Highlights
|
### Highlights
|
||||||
|
|
||||||
- **Local Library Scanning** ([#117](https://github.com/zarzet/SpotiFLAC-Mobile/issues/117)): Scan existing music collection to detect duplicates (FLAC, M4A, MP3, Opus, OGG)
|
- **Local Library Scanning** ([#117](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/117)): Scan existing music collection to detect duplicates (FLAC, M4A, MP3, Opus, OGG)
|
||||||
- **Duplicate Detection** ([#117](https://github.com/zarzet/SpotiFLAC-Mobile/issues/117)): "In Library" badge on tracks matching by ISRC or track name + artist
|
- **Duplicate Detection** ([#117](https://github.com/spotiflacapp/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
|
||||||
@@ -814,7 +1322,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/zarzet/SpotiFLAC-Mobile/issues/96))
|
- **Clear All Queue Button**: Cancel all queued downloads with one tap ([#96](https://github.com/spotiflacapp/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)
|
||||||
@@ -831,10 +1339,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/zarzet/SpotiFLAC-Mobile/issues/108))
|
- **MP3 Download Error 403**: Fixed 403 Forbidden error when downloading MP3 files ([#108](https://github.com/spotiflacapp/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/zarzet/SpotiFLAC-Mobile/issues/112))
|
- **Deezer Pagination**: Fixed >25 tracks only showing first 25 ([#112](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/112))
|
||||||
- **Duplicate Embed Lyrics Setting**: Removed from Options page ([#110](https://github.com/zarzet/SpotiFLAC-Mobile/issues/110))
|
- **Duplicate Embed Lyrics Setting**: Removed from Options page ([#110](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/110))
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1047,7 +1555,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/zarzet/SpotiFLAC-Mobile/issues/76)): Dropdown menu in search bar for instant provider switching
|
- **Quick Search Provider Switcher** ([#76](https://github.com/spotiflacapp/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
|
||||||
@@ -1056,16 +1564,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/zarzet/SpotiFLAC-Mobile/issues/74)): New setting type for extension actions
|
- **Extension Button Setting Type** ([#74](https://github.com/spotiflacapp/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/zarzet/SpotiFLAC-Mobile/issues/75)): Downloaded tracks now include genre and record label information
|
- **Genre & Label Metadata** ([#75](https://github.com/spotiflacapp/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/zarzet/SpotiFLAC-Mobile/issues/69)): Optional MP3 download format with FLAC-to-MP3 conversion
|
- **MP3 Quality Option** ([#69](https://github.com/spotiflacapp/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
|
||||||
@@ -1091,12 +1599,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/zarzet/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/spotiflacapp/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/zarzet/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/spotiflacapp/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
|
||||||
@@ -1236,4 +1744,4 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
_For older versions, see [GitHub Releases_](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
_For older versions, see [GitHub Releases_](https://github.com/spotiflacapp/SpotiFLAC-Mobile/releases)
|
||||||
|
|||||||
+117
-239
@@ -1,289 +1,167 @@
|
|||||||
# Contributing to SpotiFLAC
|
# Contributing to SpotiFLAC Mobile
|
||||||
|
|
||||||
First off, thank you for considering contributing to SpotiFLAC! 🎉
|
Thank you for helping improve SpotiFLAC Mobile. Bug reports, focused pull
|
||||||
|
requests, documentation, and translations are all welcome.
|
||||||
|
|
||||||
This document provides guidelines and steps for contributing. Following these guidelines helps maintain code quality and ensures a smooth collaboration process.
|
Please follow the [Code of Conduct](CODE_OF_CONDUCT.md) when participating in
|
||||||
|
the project.
|
||||||
|
|
||||||
## Table of Contents
|
## Before You Start
|
||||||
|
|
||||||
- [Code of Conduct](#code-of-conduct)
|
- Search the [existing issues](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues)
|
||||||
- [How Can I Contribute?](#how-can-i-contribute)
|
before opening a new one.
|
||||||
- [Reporting Bugs](#reporting-bugs)
|
- Use the issue template that best matches the problem.
|
||||||
- [Suggesting Features](#suggesting-features)
|
- Keep pull requests focused. Separate unrelated fixes into separate PRs.
|
||||||
- [Code Contributions](#code-contributions)
|
- Never commit credentials, signing files, downloaded media, or generated build
|
||||||
- [Translations](#translations)
|
artifacts.
|
||||||
- [Development Setup](#development-setup)
|
|
||||||
- [Project Structure](#project-structure)
|
|
||||||
- [Coding Guidelines](#coding-guidelines)
|
|
||||||
- [Commit Guidelines](#commit-guidelines)
|
|
||||||
- [Pull Request Process](#pull-request-process)
|
|
||||||
|
|
||||||
## Code of Conduct
|
Translations are managed through the
|
||||||
|
[SpotiFLAC Mobile Crowdin project](https://crowdin.com/project/spotiflac-mobile).
|
||||||
|
The English source strings live in `lib/l10n/arb/app_en.arb`.
|
||||||
|
|
||||||
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.
|
## Toolchain
|
||||||
|
|
||||||
## How Can I Contribute?
|
The repository is the source of truth for tool versions:
|
||||||
|
|
||||||
### Reporting Bugs
|
- Flutter: `.fvmrc`
|
||||||
|
- 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
|
||||||
|
|
||||||
Before creating bug reports, please check the [existing issues](https://github.com/zarzet/SpotiFLAC-Mobile/issues) to avoid duplicates.
|
[FVM](https://fvm.app/) is recommended. If you do not use FVM, install the
|
||||||
|
exact Flutter version declared in `.fvmrc` and replace `fvm flutter` with
|
||||||
When creating a bug report, please use the bug report template and include:
|
`flutter` (and `fvm dart` with `dart`) in the commands below.
|
||||||
|
|
||||||
- **Clear and descriptive title**
|
|
||||||
- **Steps to reproduce** the issue
|
|
||||||
- **Expected behavior** vs **actual behavior**
|
|
||||||
- **Screenshots or screen recordings** if applicable
|
|
||||||
- **Device information** (model, OS version)
|
|
||||||
- **App version**
|
|
||||||
- **Logs** from Settings > About > View Logs
|
|
||||||
|
|
||||||
### Suggesting Features
|
|
||||||
|
|
||||||
Feature requests are welcome! Please use the feature request template and:
|
|
||||||
|
|
||||||
- **Check existing issues** to avoid duplicates
|
|
||||||
- **Describe the feature** clearly
|
|
||||||
- **Explain the use case** - why would this be useful?
|
|
||||||
- **Consider the scope** - is this a small enhancement or a major feature?
|
|
||||||
|
|
||||||
### Code Contributions
|
|
||||||
|
|
||||||
1. **Fork the repository** and create your branch from `main`
|
|
||||||
2. **Make your changes** following our coding guidelines
|
|
||||||
3. **Test your changes** thoroughly
|
|
||||||
4. **Submit a pull request** to the `main` branch
|
|
||||||
|
|
||||||
### Translations
|
|
||||||
|
|
||||||
We use [Crowdin](https://crowdin.com/project/spotiflac-mobile) for translations. To contribute:
|
|
||||||
|
|
||||||
1. Visit our [Crowdin project](https://crowdin.com/project/spotiflac-mobile)
|
|
||||||
2. Select your language or request a new one
|
|
||||||
3. Start translating!
|
|
||||||
|
|
||||||
Translation files are located in `lib/l10n/arb/`.
|
|
||||||
|
|
||||||
## Development Setup
|
## Development Setup
|
||||||
|
|
||||||
### Prerequisites
|
1. Fork and clone the repository:
|
||||||
|
|
||||||
- **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
|
||||||
```
|
|
||||||
|
|
||||||
2. **Add upstream remote**
|
|
||||||
```bash
|
|
||||||
git remote add upstream https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
|
git remote add upstream https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Use FVM (Flutter Version: 3.41.5)**
|
2. Install the pinned Flutter SDK and Dart dependencies:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
fvm use
|
fvm install
|
||||||
|
fvm flutter pub get
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Install dependencies**
|
3. Build the Go backend for Android. `ANDROID_NDK_HOME` must point to the NDK
|
||||||
```bash
|
version used by CI and `CGO_ENABLED` must be enabled.
|
||||||
flutter pub get
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Generate code** (for Riverpod, JSON serialization, etc.)
|
|
||||||
```bash
|
|
||||||
dart run build_runner build --delete-conflicting-outputs
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **Set up Go environment (Go Version: 1.25.9)**
|
|
||||||
|
|
||||||
Building the Go backend for Android requires the **Android NDK** (r29 is what CI uses). Make sure `ANDROID_NDK_HOME` points to it and `CGO_ENABLED=1`.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
|
||||||
gomobile init
|
|
||||||
cd go_backend
|
cd go_backend
|
||||||
|
go mod download
|
||||||
|
go install golang.org/x/mobile/cmd/gomobile
|
||||||
|
gomobile init
|
||||||
mkdir -p ../android/app/libs
|
mkdir -p ../android/app/libs
|
||||||
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
|
gomobile bind \
|
||||||
|
-target=android/arm,android/arm64 \
|
||||||
|
-androidapi 24 \
|
||||||
|
-o ../android/app/libs/gobackend.aar \
|
||||||
|
.
|
||||||
cd ..
|
cd ..
|
||||||
```
|
```
|
||||||
|
|
||||||
7. **Run the app**
|
Running `go install` from `go_backend/` uses the `x/mobile` version pinned by
|
||||||
|
`go.mod`. Do not replace it with `@latest` in project scripts.
|
||||||
|
|
||||||
|
4. Run the app:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
flutter run
|
fvm flutter run
|
||||||
```
|
```
|
||||||
|
|
||||||
### Building
|
For iOS, run `scripts/build_ios.sh` on macOS before opening
|
||||||
|
`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
|
||||||
# Debug build
|
fvm dart format --output=none --set-exit-if-changed lib test
|
||||||
flutter build apk --debug
|
fvm flutter analyze
|
||||||
|
fvm flutter test
|
||||||
# Release build
|
|
||||||
flutter build apk --release
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Structure
|
Go backend:
|
||||||
|
|
||||||
```
|
|
||||||
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
|
||||||
dart format .
|
cd go_backend
|
||||||
|
gofmt -w .
|
||||||
|
go vet ./...
|
||||||
|
go test ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
### Linting
|
Android native code, after building `gobackend.aar`:
|
||||||
|
|
||||||
Ensure your code passes all lints:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
flutter analyze
|
cd android
|
||||||
|
./gradlew :app:compileDebugKotlin :app:testDebugUnitTest
|
||||||
```
|
```
|
||||||
|
|
||||||
### State Management
|
For user-facing changes, add or update tests where practical and include
|
||||||
|
before/after screenshots for UI changes.
|
||||||
|
|
||||||
We use **Riverpod** for state management, with hand-written `Notifier`s
|
## Code and Commit Style
|
||||||
(no `riverpod_annotation` code generation). Follow this pattern:
|
|
||||||
|
|
||||||
```dart
|
- Follow `analysis_options.yaml`, `.editorconfig`, and existing module patterns.
|
||||||
class MyNotifier extends Notifier<MyState> {
|
- Keep user-facing strings in the localization files.
|
||||||
@override
|
- Prefer small functions and explicit error handling at platform boundaries.
|
||||||
MyState build() => MyState();
|
- Use [Conventional Commits](https://www.conventionalcommits.org/), for example:
|
||||||
|
|
||||||
// Methods to update state
|
```text
|
||||||
}
|
feat(download): add batch selection
|
||||||
|
fix(storage): handle revoked folder access
|
||||||
|
docs(contributing): refresh Android setup
|
||||||
|
```
|
||||||
|
|
||||||
final myProvider = NotifierProvider<MyNotifier, MyState>(MyNotifier.new);
|
## Pull Requests
|
||||||
```
|
|
||||||
|
|
||||||
### Localization
|
1. Create a branch from an up-to-date `main`.
|
||||||
|
2. Make one focused change and include tests or verification evidence.
|
||||||
|
3. Complete the pull request template, including any checks that were not run
|
||||||
|
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.
|
||||||
|
|
||||||
All user-facing strings should be localized:
|
When reporting a crash, include the SpotiFLAC Mobile version, release channel,
|
||||||
|
device/OS, exact reproduction steps, storage mode, and exported app logs. For a
|
||||||
```dart
|
cold-start Android crash, `adb logcat -b crash -d` is especially useful.
|
||||||
// Good
|
|
||||||
Text(AppLocalizations.of(context)!.downloadComplete)
|
|
||||||
|
|
||||||
// Bad
|
|
||||||
Text('Download Complete')
|
|
||||||
```
|
|
||||||
|
|
||||||
To add new strings:
|
|
||||||
1. Add the key to `lib/l10n/arb/app_en.arb`
|
|
||||||
2. Run `flutter gen-l10n`
|
|
||||||
|
|
||||||
## Commit Guidelines
|
|
||||||
|
|
||||||
We follow [Conventional Commits](https://www.conventionalcommits.org/):
|
|
||||||
|
|
||||||
```
|
|
||||||
<type>(<scope>): <description>
|
|
||||||
|
|
||||||
[optional body]
|
|
||||||
|
|
||||||
[optional footer(s)]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Types
|
|
||||||
|
|
||||||
- `feat`: New feature
|
|
||||||
- `fix`: Bug fix
|
|
||||||
- `docs`: Documentation changes
|
|
||||||
- `style`: Code style changes (formatting, etc.)
|
|
||||||
- `refactor`: Code refactoring
|
|
||||||
- `perf`: Performance improvements
|
|
||||||
- `test`: Adding or updating tests
|
|
||||||
- `chore`: Maintenance tasks
|
|
||||||
|
|
||||||
### Examples
|
|
||||||
|
|
||||||
```
|
|
||||||
feat(download): add batch download support
|
|
||||||
fix(ui): resolve overflow on small screens
|
|
||||||
docs: update contributing guidelines
|
|
||||||
chore(deps): update flutter_riverpod to 3.1.0
|
|
||||||
```
|
|
||||||
|
|
||||||
## Pull Request Process
|
|
||||||
|
|
||||||
1. **Update your fork**
|
|
||||||
```bash
|
|
||||||
git fetch upstream
|
|
||||||
git rebase upstream/main
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Create a feature branch**
|
|
||||||
```bash
|
|
||||||
git checkout -b feat/my-new-feature
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Make your changes** and commit following our guidelines
|
|
||||||
|
|
||||||
4. **Push to your fork**
|
|
||||||
```bash
|
|
||||||
git push origin feat/my-new-feature
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Create a Pull Request**
|
|
||||||
- Target the `main` branch
|
|
||||||
- Fill in the PR template
|
|
||||||
- Link related issues
|
|
||||||
|
|
||||||
6. **Address review feedback**
|
|
||||||
- Make requested changes
|
|
||||||
- Push additional commits
|
|
||||||
- Request re-review when ready
|
|
||||||
|
|
||||||
### PR Requirements
|
|
||||||
|
|
||||||
- [ ] Code follows project conventions
|
|
||||||
- [ ] All tests pass
|
|
||||||
- [ ] No new linting errors
|
|
||||||
- [ ] Documentation updated (if needed)
|
|
||||||
- [ ] Commit messages follow guidelines
|
|
||||||
- [ ] PR description is clear and complete
|
|
||||||
|
|
||||||
CI runs `flutter analyze`, `flutter test`, `go vet`, and `go test` on every pull request — make sure they pass locally before pushing.
|
|
||||||
|
|
||||||
## Questions?
|
|
||||||
|
|
||||||
If you have questions, feel free to:
|
|
||||||
|
|
||||||
- Open a [Discussion](https://github.com/spotiflacapp/SpotiFLAC-Mobile/discussions)
|
|
||||||
- Check existing [Issues](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues)
|
|
||||||
|
|
||||||
Thank you for contributing! 💚
|
|
||||||
|
|||||||
@@ -16,8 +16,7 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
[](https://github.com/spotiflacapp/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)
|
||||||
@@ -52,7 +51,22 @@ Extensions let the community add new music sources and features without waiting
|
|||||||
### Developing Extensions
|
### Developing Extensions
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Want to build your own extension? The [Extension Development Guide](https://spotiflac.zarz.moe/docs) has everything you need.
|
> Want to build your own extension? Start with the
|
||||||
|
> [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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -72,7 +86,10 @@ 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 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.
|
Starting from version 3.8.0, SpotiFLAC Mobile uses a decentralized extension
|
||||||
|
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>
|
||||||
|
|
||||||
@@ -104,7 +121,9 @@ 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 permission to save downloaded files to your device. On Android 13+, you may need to grant **All files access** under **Settings > Apps > SpotiFLAC > Permissions**.
|
The app needs a writable destination for downloaded files. On Android, choose a
|
||||||
|
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>
|
||||||
|
|
||||||
@@ -112,7 +131,8 @@ The app needs permission to save downloaded files to your device. On Android 13+
|
|||||||
<summary><b>Is this app safe?</b></summary>
|
<summary><b>Is this app safe?</b></summary>
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
Yes SpotiFLAC is open source and you can verify the code yourself. Each release is also scanned with VirusTotal (see badge above).
|
SpotiFLAC Mobile is open source, so its code and build workflows can be
|
||||||
|
inspected directly in this repository.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -125,13 +145,13 @@ Some countries have restricted access to certain streaming service APIs. If down
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Can I add SpotiFLAC to AltStore or SideStore?</b></summary>
|
<summary><b>Can I add SpotiFLAC Mobile 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/zarzet/SpotiFLAC-Mobile/refs/heads/main/apps.json
|
https://raw.githubusercontent.com/spotiflacapp/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.
|
||||||
@@ -139,7 +159,7 @@ In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> If SpotiFLAC is useful to you, consider supporting development:
|
> If SpotiFLAC Mobile is useful to you, consider supporting development:
|
||||||
>
|
>
|
||||||
> [](https://ko-fi.com/zarzet)
|
> [](https://ko-fi.com/zarzet)
|
||||||
|
|
||||||
@@ -149,8 +169,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/zarzet/SpotiFLAC-Mobile/graphs/contributors">
|
<a href="https://github.com/spotiflacapp/SpotiFLAC-Mobile/graphs/contributors">
|
||||||
<img src="https://contrib.rocks/image?repo=zarzet/SpotiFLAC-Mobile" />
|
<img src="https://contrib.rocks/image?repo=spotiflacapp/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.
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ linter:
|
|||||||
type_annotate_public_apis: 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.
|
||||||
|
unreachable_from_main: true
|
||||||
|
|
||||||
# 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
|
||||||
|
|||||||
@@ -118,6 +118,17 @@
|
|||||||
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
|
<service
|
||||||
android:name="com.ryanheise.audioservice.AudioService"
|
android:name="com.ryanheise.audioservice.AudioService"
|
||||||
android:foregroundServiceType="mediaPlayback"
|
android:foregroundServiceType="mediaPlayback"
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,13 +67,13 @@ class DownloadService : Service() {
|
|||||||
const val EXTRA_SETTINGS_JSON = "settings_json"
|
const val EXTRA_SETTINGS_JSON = "settings_json"
|
||||||
const val EXTRA_REQUESTS_PATH = "requests_path"
|
const val EXTRA_REQUESTS_PATH = "requests_path"
|
||||||
const val EXTRA_SETTINGS_PATH = "settings_path"
|
const val EXTRA_SETTINGS_PATH = "settings_path"
|
||||||
private const val NATIVE_WORKER_STATE_FILE = "native_download_worker_state.json"
|
internal const val NATIVE_WORKER_STATE_FILE = "native_download_worker_state.json"
|
||||||
private const val NATIVE_WORKER_PROGRESS_FILE = "native_download_worker_progress.json"
|
internal const val NATIVE_WORKER_PROGRESS_FILE = "native_download_worker_progress.json"
|
||||||
private const val NATIVE_REPLAYGAIN_JOURNAL_FILE = "native_replaygain_journal.json"
|
internal const val NATIVE_REPLAYGAIN_JOURNAL_FILE = "native_replaygain_journal.json"
|
||||||
private const val NATIVE_WORKER_CONTRACT_VERSION = NativeDownloadFinalizer.NATIVE_WORKER_CONTRACT_VERSION
|
internal const val NATIVE_WORKER_CONTRACT_VERSION = NativeDownloadFinalizer.NATIVE_WORKER_CONTRACT_VERSION
|
||||||
private const val NOTIFICATION_PERCENT_TOTAL = 10_000L
|
internal const val NOTIFICATION_PERCENT_TOTAL = 10_000L
|
||||||
private val NATIVE_WORKER_STATE_FILE_LOCK = Any()
|
internal val NATIVE_WORKER_STATE_FILE_LOCK = Any()
|
||||||
private val NATIVE_REPLAYGAIN_JOURNAL_FILE_LOCK = Any()
|
internal val NATIVE_REPLAYGAIN_JOURNAL_FILE_LOCK = Any()
|
||||||
|
|
||||||
private var isRunning = false
|
private var isRunning = false
|
||||||
|
|
||||||
@@ -165,8 +165,8 @@ class DownloadService : Service() {
|
|||||||
// re-parsing the full state file — which grows with every completed
|
// re-parsing the full state file — which grows with every completed
|
||||||
// item (results embed history rows and lyrics) — once the caller has
|
// item (results embed history rows and lyrics) — once the caller has
|
||||||
// already consumed that items payload.
|
// already consumed that items payload.
|
||||||
@Volatile private var lastStateHeaderJson: String? = null
|
@Volatile internal var lastStateHeaderJson: String? = null
|
||||||
@Volatile private var lastStateHeaderSerial = 0L
|
@Volatile internal var lastStateHeaderSerial = 0L
|
||||||
|
|
||||||
fun getNativeWorkerSnapshot(context: Context, sinceStateSerial: Long = 0L): String {
|
fun getNativeWorkerSnapshot(context: Context, sinceStateSerial: Long = 0L): String {
|
||||||
synchronized(NATIVE_WORKER_STATE_FILE_LOCK) {
|
synchronized(NATIVE_WORKER_STATE_FILE_LOCK) {
|
||||||
@@ -253,7 +253,7 @@ class DownloadService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class NativeDownloadRequest(
|
internal data class NativeDownloadRequest(
|
||||||
val itemId: String,
|
val itemId: String,
|
||||||
val requestJson: String,
|
val requestJson: String,
|
||||||
val trackName: String,
|
val trackName: String,
|
||||||
@@ -261,7 +261,7 @@ class DownloadService : Service() {
|
|||||||
val itemJson: String
|
val itemJson: String
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class NativeWorkerItem(
|
internal data class NativeWorkerItem(
|
||||||
val itemId: String,
|
val itemId: String,
|
||||||
val trackName: String,
|
val trackName: String,
|
||||||
val artistName: String,
|
val artistName: String,
|
||||||
@@ -274,38 +274,41 @@ class DownloadService : Service() {
|
|||||||
var resultJson: JSONObject? = null
|
var resultJson: JSONObject? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class NativeWorkerCounts(
|
internal data class NativeWorkerCounts(
|
||||||
val total: Int,
|
val total: Int,
|
||||||
val completed: Int,
|
val completed: Int,
|
||||||
val failed: Int,
|
val failed: Int,
|
||||||
val skipped: Int
|
val skipped: Int
|
||||||
)
|
)
|
||||||
|
|
||||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
internal val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private var nativeWorkerJob: Job? = null
|
internal var nativeWorkerJob: Job? = null
|
||||||
private var wakeLock: PowerManager.WakeLock? = null
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
private var currentTrackName = ""
|
private var currentTrackName = ""
|
||||||
private var currentArtistName = ""
|
private var currentArtistName = ""
|
||||||
private var currentStatus = "preparing"
|
internal var currentStatus = "preparing"
|
||||||
private var queueCount = 0
|
private var queueCount = 0
|
||||||
private var lastProgress = 0L
|
// Signature of the last home-screen widget push; keeps widget updates
|
||||||
private var lastTotal = 0L
|
// event-driven (track/status/queue changes, 25% steps), never per byte.
|
||||||
private var nativeWorkerRunId = ""
|
private var widgetSignature = ""
|
||||||
|
internal var lastProgress = 0L
|
||||||
|
internal var lastTotal = 0L
|
||||||
|
internal var nativeWorkerRunId = ""
|
||||||
@Volatile private var nativeWorkerCurrentItemId = ""
|
@Volatile private var nativeWorkerCurrentItemId = ""
|
||||||
private val nativeWorkerItems = mutableListOf<NativeWorkerItem>()
|
internal val nativeWorkerItems = mutableListOf<NativeWorkerItem>()
|
||||||
private val nativeReplayGainEntries = mutableListOf<JSONObject>()
|
internal val nativeReplayGainEntries = mutableListOf<JSONObject>()
|
||||||
private val nativeReplayGainRequestAlbumKeys = mutableMapOf<String, String>()
|
internal val nativeReplayGainRequestAlbumKeys = mutableMapOf<String, String>()
|
||||||
private val snapshotWriteLock = Any()
|
internal val snapshotWriteLock = Any()
|
||||||
private val snapshotWriteSerial = AtomicLong(0L)
|
internal val snapshotWriteSerial = AtomicLong(0L)
|
||||||
private var latestCommittedStateSnapshotSerial = 0L
|
internal var latestCommittedStateSnapshotSerial = 0L
|
||||||
private var latestCommittedProgressSnapshotSerial = 0L
|
internal var latestCommittedProgressSnapshotSerial = 0L
|
||||||
@Volatile private var nativeWorkerPaused = false
|
@Volatile private var nativeWorkerPaused = false
|
||||||
@Volatile private var nativeWorkerNetworkPaused = false
|
@Volatile internal var nativeWorkerNetworkPaused = false
|
||||||
@Volatile private var nativeWorkerVerificationPaused = false
|
@Volatile internal var nativeWorkerVerificationPaused = false
|
||||||
@Volatile private var nativeWorkerCancelRequested = false
|
@Volatile private var nativeWorkerCancelRequested = false
|
||||||
private var nativeWorkerDownloadNetworkMode = "any"
|
internal var nativeWorkerDownloadNetworkMode = "any"
|
||||||
private var nativeWorkerNetworkCallback: ConnectivityManager.NetworkCallback? = null
|
internal var nativeWorkerNetworkCallback: ConnectivityManager.NetworkCallback? = null
|
||||||
private val nativeWorkerWifiNetworks = mutableSetOf<Network>()
|
internal val nativeWorkerWifiNetworks = mutableSetOf<Network>()
|
||||||
// Bumped every time a new native queue replaces the current one. A worker
|
// Bumped every time a new native queue replaces the current one. A worker
|
||||||
// coroutine that observes a different generation than its own must stop
|
// coroutine that observes a different generation than its own must stop
|
||||||
// without touching the snapshot or the service lifecycle: cancel() alone
|
// without touching the snapshot or the service lifecycle: cancel() alone
|
||||||
@@ -507,6 +510,7 @@ class DownloadService : Service() {
|
|||||||
} else {
|
} else {
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
}
|
}
|
||||||
|
pushWidgetState(0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startNativeWorker(requestsJson: String, settingsJson: String) {
|
private fun startNativeWorker(requestsJson: String, settingsJson: String) {
|
||||||
@@ -618,18 +622,18 @@ class DownloadService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isNativeWorkerPaused(): Boolean =
|
internal fun isNativeWorkerPaused(): Boolean =
|
||||||
nativeWorkerPaused ||
|
nativeWorkerPaused ||
|
||||||
nativeWorkerNetworkPaused ||
|
nativeWorkerNetworkPaused ||
|
||||||
nativeWorkerVerificationPaused
|
nativeWorkerVerificationPaused
|
||||||
|
|
||||||
private fun nativeWorkerPauseMessage(): String = when {
|
internal fun nativeWorkerPauseMessage(): String = when {
|
||||||
nativeWorkerVerificationPaused -> "Verification required"
|
nativeWorkerVerificationPaused -> "Verification required"
|
||||||
nativeWorkerNetworkPaused -> "Waiting for Wi-Fi"
|
nativeWorkerNetworkPaused -> "Waiting for Wi-Fi"
|
||||||
else -> "Paused"
|
else -> "Paused"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cancelActiveNativeItemForPause() {
|
internal fun cancelActiveNativeItemForPause() {
|
||||||
var itemIdToCancel = ""
|
var itemIdToCancel = ""
|
||||||
synchronized(nativeWorkerItems) {
|
synchronized(nativeWorkerItems) {
|
||||||
val activeItem = nativeWorkerItems.firstOrNull {
|
val activeItem = nativeWorkerItems.firstOrNull {
|
||||||
@@ -655,146 +659,6 @@ class DownloadService : Service() {
|
|||||||
NativeDownloadFinalizer.cancelActiveWork()
|
NativeDownloadFinalizer.cancelActiveWork()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun 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}",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun 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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun 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) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseNativeDownloadRequests(requestsJson: String): List<NativeDownloadRequest> {
|
private fun parseNativeDownloadRequests(requestsJson: String): List<NativeDownloadRequest> {
|
||||||
val array = JSONArray(requestsJson)
|
val array = JSONArray(requestsJson)
|
||||||
val requests = ArrayList<NativeDownloadRequest>(array.length())
|
val requests = ArrayList<NativeDownloadRequest>(array.length())
|
||||||
@@ -1085,7 +949,11 @@ class DownloadService : Service() {
|
|||||||
settingsJson = settingsJson,
|
settingsJson = settingsJson,
|
||||||
includeItems = true,
|
includeItems = true,
|
||||||
)
|
)
|
||||||
scheduleNativeVerificationNotification(generation)
|
// Publish immediately. If Flutter is alive it will
|
||||||
|
// replace this same notification ID while owning
|
||||||
|
// the interactive challenge; if Flutter is
|
||||||
|
// suspended, the native alert remains visible.
|
||||||
|
showNativeVerificationRequired()
|
||||||
updateNotification(0L, 0L)
|
updateNotification(0L, 0L)
|
||||||
retryCurrentRequest = true
|
retryCurrentRequest = true
|
||||||
} else {
|
} else {
|
||||||
@@ -1178,489 +1046,6 @@ class DownloadService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildEligibleNativeAlbumReplayGain(
|
|
||||||
entries: List<JSONObject>,
|
|
||||||
statuses: Map<String, String>,
|
|
||||||
requestKeys: Map<String, String>
|
|
||||||
): JSONArray {
|
|
||||||
val blockedKeys = mutableSetOf<String>()
|
|
||||||
val expectedCompletedByKey = mutableMapOf<String, Int>()
|
|
||||||
for ((itemId, key) in requestKeys) {
|
|
||||||
when (statuses[itemId]) {
|
|
||||||
"completed" -> expectedCompletedByKey[key] = (expectedCompletedByKey[key] ?: 0) + 1
|
|
||||||
"failed", "skipped", "queued", "downloading", "finalizing" -> blockedKeys.add(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val entriesByKey = entries.groupBy { it.optString("album_key", "") }
|
|
||||||
val eligible = JSONArray()
|
|
||||||
for ((key, group) in entriesByKey) {
|
|
||||||
if (key.isBlank() || blockedKeys.contains(key) || group.size <= 1) continue
|
|
||||||
val expected = expectedCompletedByKey[key] ?: continue
|
|
||||||
if (group.size != expected) continue
|
|
||||||
for (entry in group) eligible.put(entry)
|
|
||||||
}
|
|
||||||
return eligible
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hasPendingNativeAlbumReplayGainWork(statuses: Map<String, String>): Boolean {
|
|
||||||
return statuses.values.any {
|
|
||||||
it == "queued" || it == "downloading" || it == "finalizing"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun 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(NATIVE_REPLAYGAIN_JOURNAL_FILE_LOCK) {
|
|
||||||
val file = AtomicFile(File(filesDir, 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun 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
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearNativeReplayGainJournal() {
|
|
||||||
synchronized(NATIVE_REPLAYGAIN_JOURNAL_FILE_LOCK) {
|
|
||||||
try {
|
|
||||||
AtomicFile(File(filesDir, NATIVE_REPLAYGAIN_JOURNAL_FILE)).delete()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun flushNativeAlbumReplayGainJournalIfComplete() {
|
|
||||||
val root = synchronized(NATIVE_REPLAYGAIN_JOURNAL_FILE_LOCK) {
|
|
||||||
try {
|
|
||||||
val file = File(filesDir, 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun 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", 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(NATIVE_WORKER_STATE_FILE_LOCK) {
|
|
||||||
val targetFileName = if (includeItems) {
|
|
||||||
NATIVE_WORKER_STATE_FILE
|
|
||||||
} else {
|
|
||||||
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) {
|
|
||||||
lastStateHeaderJson = headerCandidate
|
|
||||||
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}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun 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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun readNativeWorkerRunIdFromSnapshotFile(): String {
|
|
||||||
return try {
|
|
||||||
synchronized(NATIVE_WORKER_STATE_FILE_LOCK) {
|
|
||||||
val file = File(filesDir, 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) {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateNativeWorkerItem(itemId: String, updater: (NativeWorkerItem) -> Unit) {
|
|
||||||
synchronized(nativeWorkerItems) {
|
|
||||||
nativeWorkerItems.firstOrNull { it.itemId == itemId }?.let(updater)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun 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 * NOTIFICATION_PERCENT_TOTAL).toLong()
|
|
||||||
.coerceIn(0L, NOTIFICATION_PERCENT_TOTAL)
|
|
||||||
lastProgress = percentProgress
|
|
||||||
lastTotal = NOTIFICATION_PERCENT_TOTAL
|
|
||||||
updateNotification(percentProgress, NOTIFICATION_PERCENT_TOTAL)
|
|
||||||
} else {
|
|
||||||
lastProgress = 0L
|
|
||||||
lastTotal = 0L
|
|
||||||
updateNotification(0L, 0L)
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun nativeWorkerCounts(): 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 NativeWorkerCounts(
|
|
||||||
total = total,
|
|
||||||
completed = completed,
|
|
||||||
failed = failed,
|
|
||||||
skipped = skipped
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun nativeWorkerItemIds(): JSONArray {
|
|
||||||
val array = JSONArray()
|
|
||||||
synchronized(nativeWorkerItems) {
|
|
||||||
for (item in nativeWorkerItems) {
|
|
||||||
array.put(item.itemId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return array
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun nativeWorkerItemsSnapshot(includeStatic: Boolean): JSONArray {
|
|
||||||
val array = JSONArray()
|
|
||||||
synchronized(nativeWorkerItems) {
|
|
||||||
for (item in nativeWorkerItems) {
|
|
||||||
array.put(nativeWorkerItemSnapshotLocked(item, includeStatic))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return array
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun nativeWorkerItemSnapshotLocked(item: 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
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
private fun ensureWakeLock() {
|
private fun ensureWakeLock() {
|
||||||
val existingWakeLock = wakeLock
|
val existingWakeLock = wakeLock
|
||||||
if (existingWakeLock?.isHeld == true) {
|
if (existingWakeLock?.isHeld == true) {
|
||||||
@@ -1719,6 +1104,12 @@ class DownloadService : Service() {
|
|||||||
nativeWorkerNetworkPaused = false
|
nativeWorkerNetworkPaused = false
|
||||||
nativeWorkerJob = null
|
nativeWorkerJob = null
|
||||||
isRunning = false
|
isRunning = false
|
||||||
|
widgetSignature = ""
|
||||||
|
try {
|
||||||
|
DownloadQueueWidgetProvider.push(this, running = false)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w("DownloadService", "Widget clear failed: ${e.message}")
|
||||||
|
}
|
||||||
releaseWakeLock()
|
releaseWakeLock()
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
stopSelf()
|
stopSelf()
|
||||||
@@ -1731,13 +1122,51 @@ class DownloadService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateNotification(progress: Long, total: Long) {
|
internal fun updateNotification(progress: Long, total: Long) {
|
||||||
if (!isRunning) return
|
if (!isRunning) return
|
||||||
ensureWakeLock()
|
ensureWakeLock()
|
||||||
|
|
||||||
val notification = buildNotification(progress, total)
|
val notification = buildNotification(progress, total)
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
manager.notify(NOTIFICATION_ID, notification)
|
manager.notify(NOTIFICATION_ID, notification)
|
||||||
|
pushWidgetState(progress, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pushWidgetState(progress: Long, total: Long) {
|
||||||
|
val percent = if (total > 0) {
|
||||||
|
((progress * 100) / total).toInt().coerceIn(0, 100)
|
||||||
|
} else {
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
val bucket = if (percent < 0) -1 else percent / 25
|
||||||
|
val signature = "$currentTrackName|$currentStatus|$queueCount|$bucket"
|
||||||
|
if (signature == widgetSignature) return
|
||||||
|
widgetSignature = signature
|
||||||
|
|
||||||
|
val subtitle = when (currentStatus) {
|
||||||
|
"verification_required" -> "Verification required"
|
||||||
|
"rate_limited" -> "Rate limited, retrying..."
|
||||||
|
"waiting_wifi" -> "Waiting for Wi-Fi..."
|
||||||
|
"finalizing" -> "Finalizing..."
|
||||||
|
else -> buildString {
|
||||||
|
append(currentArtistName)
|
||||||
|
if (queueCount > 1) {
|
||||||
|
if (isNotEmpty()) append(" • ")
|
||||||
|
append("$queueCount in queue")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
DownloadQueueWidgetProvider.push(
|
||||||
|
this,
|
||||||
|
running = true,
|
||||||
|
title = currentTrackName.ifEmpty { "Downloading..." },
|
||||||
|
subtitle = subtitle,
|
||||||
|
percent = percent,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w("DownloadService", "Widget update failed: ${e.message}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildNotification(progress: Long, total: Long): Notification {
|
private fun buildNotification(progress: Long, total: Long): Notification {
|
||||||
@@ -1842,21 +1271,6 @@ class DownloadService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun scheduleNativeVerificationNotification(generation: Long) {
|
|
||||||
serviceScope.launch {
|
|
||||||
// Give an active Flutter poller time to take ownership of the
|
|
||||||
// verification flow. If Flutter is suspended, the service remains
|
|
||||||
// paused and publishes the alert itself.
|
|
||||||
delay(2_000L)
|
|
||||||
if (generation == nativeWorkerGeneration &&
|
|
||||||
nativeWorkerVerificationPaused &&
|
|
||||||
!nativeWorkerCancelRequested
|
|
||||||
) {
|
|
||||||
showNativeVerificationRequired()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showNativeVerificationRequired() {
|
private fun showNativeVerificationRequired() {
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
this,
|
this,
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,471 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,289 @@
|
|||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
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", "") }
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,489 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -27,6 +27,11 @@ object SafDownloadHandler {
|
|||||||
private val safNameLocks = java.util.concurrent.ConcurrentHashMap<String, Any>()
|
private val safNameLocks = java.util.concurrent.ConcurrentHashMap<String, Any>()
|
||||||
|
|
||||||
data class UniqueWriteResult(val uri: String, val fileName: String)
|
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(
|
private fun <T> withSafNameLock(
|
||||||
treeUriStr: String,
|
treeUriStr: String,
|
||||||
@@ -352,6 +357,79 @@ object SafDownloadHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
private fun sanitizeFilenamePreservingSuffix(fileName: String, suffix: String): String {
|
||||||
val sanitized = sanitizeFilename(fileName)
|
val sanitized = sanitizeFilename(fileName)
|
||||||
val trimmedSuffix = suffix.trim()
|
val trimmedSuffix = suffix.trim()
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="widgetBackground">#1C1B1F</color>
|
||||||
|
<color name="widgetTextPrimary">#E6E1E5</color>
|
||||||
|
<color name="widgetTextSecondary">#CAC4D0</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?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,4 +1,8 @@
|
|||||||
<?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>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?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" />
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# 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
|
||||||
|
@@ -7,12 +7,12 @@
|
|||||||
"name": "SpotiFLAC Mobile",
|
"name": "SpotiFLAC Mobile",
|
||||||
"bundleIdentifier": "com.zarzet.spotiflac",
|
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||||
"developerName": "zarzet",
|
"developerName": "zarzet",
|
||||||
"version": "4.7.1",
|
"version": "4.8.0",
|
||||||
"versionDate": "2026-07-01",
|
"versionDate": "2026-07-25",
|
||||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.7.1/SpotiFLAC-v4.7.1-ios-unsigned.ipa",
|
"downloadURL": "https://github.com/spotiflacapp/SpotiFLAC-Mobile/releases/download/v4.8.0/SpotiFLAC-v4.8.0-ios-unsigned.ipa",
|
||||||
"localizedDescription": "SpotiFLAC Mobile is written in Flutter. Download tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
|
"localizedDescription": "SpotiFLAC Mobile is written in Flutter and uses installable extensions for metadata and audio downloads.",
|
||||||
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
"iconURL": "https://raw.githubusercontent.com/spotiflacapp/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
||||||
"size": 37455821
|
"size": 37939793
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
+2
-2
@@ -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/zarzet/SpotiFLAC-Mobile
|
https://github.com/spotiflacapp/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 = "zarzet"
|
owner = "spotiflacapp"
|
||||||
repo = "SpotiFLAC-Mobile"
|
repo = "SpotiFLAC-Mobile"
|
||||||
|
|||||||
+3
-5
@@ -6,18 +6,16 @@ files:
|
|||||||
# Keys MUST be the project's Crowdin language ids; values are the
|
# Keys MUST be the project's Crowdin language ids; values are the
|
||||||
# %locale% suffix used in app_%locale%.arb (underscores so Flutter
|
# %locale% suffix used in app_%locale%.arb (underscores so Flutter
|
||||||
# gen-l10n parses them — hyphenated filenames break gen-l10n).
|
# gen-l10n parses them — hyphenated filenames break gen-l10n).
|
||||||
ar: ar
|
# 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: es_ES
|
||||||
fr: fr
|
fr: fr
|
||||||
hi: hi
|
|
||||||
id: id
|
id: id
|
||||||
ja: ja
|
ja: ja
|
||||||
ko: ko
|
ko: ko
|
||||||
nl: nl
|
|
||||||
pt-PT: pt_PT
|
pt-PT: pt_PT
|
||||||
ru: ru
|
ru: ru
|
||||||
tr: tr
|
tr: tr
|
||||||
uk: uk
|
uk: uk
|
||||||
zh-CN: zh_CN
|
|
||||||
zh-TW: zh_TW
|
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
# 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.
|
||||||
@@ -2,13 +2,10 @@ package gobackend
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -775,440 +772,6 @@ 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
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
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",
|
||||||
@@ -1238,393 +801,3 @@ 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 {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,402 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
+95
-135
@@ -20,23 +20,25 @@ type cancelEntry struct {
|
|||||||
refs int
|
refs int
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
type cancelRegistry struct {
|
||||||
cancelMu sync.Mutex
|
mu sync.Mutex
|
||||||
cancelMap = make(map[string]*cancelEntry)
|
entries map[string]*cancelEntry
|
||||||
|
}
|
||||||
|
|
||||||
extensionRequestCancelMu sync.Mutex
|
var (
|
||||||
extensionRequestCancelMap = make(map[string]*cancelEntry)
|
downloadCancels = &cancelRegistry{entries: make(map[string]*cancelEntry)}
|
||||||
|
extensionRequestCancels = &cancelRegistry{entries: make(map[string]*cancelEntry)}
|
||||||
)
|
)
|
||||||
|
|
||||||
func initDownloadCancel(itemID string) context.Context {
|
func (r *cancelRegistry) init(id string) context.Context {
|
||||||
if itemID == "" {
|
if id == "" {
|
||||||
return context.Background()
|
return context.Background()
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelMu.Lock()
|
r.mu.Lock()
|
||||||
defer cancelMu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
if entry, ok := cancelMap[itemID]; ok {
|
if entry, ok := r.entries[id]; ok {
|
||||||
if entry.ctx == nil {
|
if entry.ctx == nil {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
entry.ctx = ctx
|
entry.ctx = ctx
|
||||||
@@ -50,7 +52,7 @@ func initDownloadCancel(itemID string) context.Context {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
cancelMap[itemID] = &cancelEntry{
|
r.entries[id] = &cancelEntry{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
canceled: false,
|
canceled: false,
|
||||||
@@ -59,164 +61,122 @@ func initDownloadCancel(itemID string) context.Context {
|
|||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
func downloadCancelContext(itemID string) context.Context {
|
func (r *cancelRegistry) context(id string) context.Context {
|
||||||
if itemID == "" {
|
if id == "" {
|
||||||
return context.Background()
|
return context.Background()
|
||||||
}
|
}
|
||||||
cancelMu.Lock()
|
r.mu.Lock()
|
||||||
defer cancelMu.Unlock()
|
defer r.mu.Unlock()
|
||||||
if entry, ok := cancelMap[itemID]; ok && entry.ctx != nil {
|
if entry, ok := r.entries[id]; ok && entry.ctx != nil {
|
||||||
return entry.ctx
|
return entry.ctx
|
||||||
}
|
}
|
||||||
return context.Background()
|
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 {
|
||||||
if itemID == "" {
|
return downloadCancels.isCancelled(itemID)
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelMu.Lock()
|
|
||||||
entry, ok := cancelMap[itemID]
|
|
||||||
canceled := ok && entry.canceled
|
|
||||||
cancelMu.Unlock()
|
|
||||||
return canceled
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// resetDownloadCancel removes a cancellation entry that has no active
|
|
||||||
// download attached (refs <= 0). Such entries exist to catch an item that is
|
|
||||||
// just about to start, but if the item never starts the flag lingers and the
|
|
||||||
// next explicit retry would consume it and abort immediately.
|
|
||||||
func resetDownloadCancel(itemID string) {
|
func resetDownloadCancel(itemID string) {
|
||||||
if itemID == "" {
|
downloadCancels.resetIfIdle(itemID)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelMu.Lock()
|
|
||||||
if entry, ok := cancelMap[itemID]; ok && entry.refs <= 0 {
|
|
||||||
delete(cancelMap, itemID)
|
|
||||||
}
|
|
||||||
cancelMu.Unlock()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearDownloadCancel(itemID string) {
|
func clearDownloadCancel(itemID string) {
|
||||||
if itemID == "" {
|
downloadCancels.release(itemID)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelMu.Lock()
|
|
||||||
if entry, ok := cancelMap[itemID]; ok {
|
|
||||||
entry.refs--
|
|
||||||
if entry.refs <= 0 {
|
|
||||||
delete(cancelMap, itemID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cancelMu.Unlock()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func initExtensionRequestCancel(requestID string) context.Context {
|
func initExtensionRequestCancel(requestID string) context.Context {
|
||||||
if requestID == "" {
|
return extensionRequestCancels.init(requestID)
|
||||||
return context.Background()
|
|
||||||
}
|
|
||||||
|
|
||||||
extensionRequestCancelMu.Lock()
|
|
||||||
defer extensionRequestCancelMu.Unlock()
|
|
||||||
|
|
||||||
if entry, ok := extensionRequestCancelMap[requestID]; ok {
|
|
||||||
if entry.ctx == nil {
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
entry.ctx = ctx
|
|
||||||
entry.cancel = cancel
|
|
||||||
if entry.canceled && entry.cancel != nil {
|
|
||||||
entry.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
entry.refs++
|
|
||||||
return entry.ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
extensionRequestCancelMap[requestID] = &cancelEntry{
|
|
||||||
ctx: ctx,
|
|
||||||
cancel: cancel,
|
|
||||||
canceled: false,
|
|
||||||
refs: 1,
|
|
||||||
}
|
|
||||||
return ctx
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func extensionRequestCancelContext(requestID string) context.Context {
|
func extensionRequestCancelContext(requestID string) context.Context {
|
||||||
if requestID == "" {
|
return extensionRequestCancels.context(requestID)
|
||||||
return context.Background()
|
|
||||||
}
|
|
||||||
extensionRequestCancelMu.Lock()
|
|
||||||
defer extensionRequestCancelMu.Unlock()
|
|
||||||
if entry, ok := extensionRequestCancelMap[requestID]; ok && entry.ctx != nil {
|
|
||||||
return entry.ctx
|
|
||||||
}
|
|
||||||
return context.Background()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancelExtensionRequest(requestID string) {
|
func cancelExtensionRequest(requestID string) {
|
||||||
if requestID == "" {
|
extensionRequestCancels.requestCancel(requestID)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
extensionRequestCancelMu.Lock()
|
|
||||||
if entry, ok := extensionRequestCancelMap[requestID]; ok {
|
|
||||||
entry.canceled = true
|
|
||||||
if entry.cancel != nil {
|
|
||||||
entry.cancel()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
extensionRequestCancelMap[requestID] = &cancelEntry{canceled: true}
|
|
||||||
}
|
|
||||||
extensionRequestCancelMu.Unlock()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func isExtensionRequestCancelled(requestID string) bool {
|
func isExtensionRequestCancelled(requestID string) bool {
|
||||||
if requestID == "" {
|
return extensionRequestCancels.isCancelled(requestID)
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
extensionRequestCancelMu.Lock()
|
|
||||||
entry, ok := extensionRequestCancelMap[requestID]
|
|
||||||
canceled := ok && entry.canceled
|
|
||||||
extensionRequestCancelMu.Unlock()
|
|
||||||
return canceled
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearExtensionRequestCancel(requestID string) {
|
func clearExtensionRequestCancel(requestID string) {
|
||||||
if requestID == "" {
|
extensionRequestCancels.release(requestID)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
extensionRequestCancelMu.Lock()
|
|
||||||
if entry, ok := extensionRequestCancelMap[requestID]; ok {
|
|
||||||
entry.refs--
|
|
||||||
if entry.refs <= 0 {
|
|
||||||
delete(extensionRequestCancelMap, requestID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
extensionRequestCancelMu.Unlock()
|
|
||||||
}
|
}
|
||||||
|
|||||||
+138
-29
@@ -1,7 +1,11 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image"
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -16,8 +20,11 @@ const (
|
|||||||
spotifySizeMax = "ab67616d000082c1"
|
spotifySizeMax = "ab67616d000082c1"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
|
// Square CDN covers using this path shape may return an image whose decoded
|
||||||
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
// dimensions differ from the dimensions advertised in the URL. Max-quality
|
||||||
|
// 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$`)
|
||||||
|
|
||||||
@@ -42,27 +49,137 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
|||||||
GoLog("[Cover] Upgraded 300x300 → 640x640")
|
GoLog("[Cover] Upgraded 300x300 → 640x640")
|
||||||
}
|
}
|
||||||
|
|
||||||
if maxQuality {
|
if !maxQuality {
|
||||||
maxURL := upgradeToMaxQuality(downloadURL)
|
GoLog("[Cover] Final URL: %s", downloadURL)
|
||||||
if maxURL != downloadURL {
|
data, err := fetchCoverCached(downloadURL)
|
||||||
downloadURL = maxURL
|
if err != nil {
|
||||||
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
|
return nil, err
|
||||||
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return append([]byte(nil), data...), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
GoLog("[Cover] Final URL: %s", downloadURL)
|
candidates := maxQualityCoverCandidateURLs(downloadURL)
|
||||||
|
data, selectedURL, width, height, err := fetchBestCoverCandidate(candidates)
|
||||||
data, err := fetchCoverCached(downloadURL)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
// 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;
|
// Cached bytes are shared across goroutines and must never be mutated;
|
||||||
// hand callers their own copy.
|
// hand callers their own copy.
|
||||||
return append([]byte(nil), data...), nil
|
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 (
|
const (
|
||||||
coverCacheMaxBytes = 24 * 1024 * 1024
|
coverCacheMaxBytes = 24 * 1024 * 1024
|
||||||
coverCacheTTL = 15 * time.Minute
|
coverCacheTTL = 15 * time.Minute
|
||||||
@@ -185,16 +302,8 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
sizeKB := len(data) / 1024
|
width, height := coverDimensions(data)
|
||||||
var resolution string
|
GoLog("[Cover] Downloaded %d KB (%dx%d)", len(data)/1024, width, height)
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -204,8 +313,8 @@ func upgradeToMaxQuality(coverURL string) string {
|
|||||||
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
if squareCoverSizeRegex.MatchString(coverURL) {
|
||||||
return upgradeDeezerCover(coverURL)
|
return upgradeSquareCover(coverURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(coverURL, "resources.tidal.com") {
|
if strings.Contains(coverURL, "resources.tidal.com") {
|
||||||
@@ -219,14 +328,14 @@ func upgradeToMaxQuality(coverURL string) string {
|
|||||||
return coverURL
|
return coverURL
|
||||||
}
|
}
|
||||||
|
|
||||||
func upgradeDeezerCover(coverURL string) string {
|
func upgradeSquareCover(coverURL string) string {
|
||||||
if !strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
if !squareCoverSizeRegex.MatchString(coverURL) {
|
||||||
return coverURL
|
return coverURL
|
||||||
}
|
}
|
||||||
|
|
||||||
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
|
upgraded := squareCoverSizeRegex.ReplaceAllString(coverURL, "/1900x1900-000000-80-0-0.jpg")
|
||||||
if upgraded != coverURL {
|
if upgraded != coverURL {
|
||||||
GoLog("[Cover] Deezer: upgraded to 1800x1800")
|
GoLog("[Cover] Square CDN: probing 1900x1900 and 1500x1500")
|
||||||
}
|
}
|
||||||
return upgraded
|
return upgraded
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,33 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func testPNG(t *testing.T, width, height int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
|
for y := 0; y < height; y++ {
|
||||||
|
for x := 0; x < width; x++ {
|
||||||
|
img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 100, A: 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
if err := png.Encode(&buffer, img); err != nil {
|
||||||
|
t.Fatalf("encode test cover: %v", err)
|
||||||
|
}
|
||||||
|
return buffer.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
func resetCoverCache() {
|
func resetCoverCache() {
|
||||||
coverMu.Lock()
|
coverMu.Lock()
|
||||||
coverCache = map[string]*coverCacheEntry{}
|
coverCache = map[string]*coverCacheEntry{}
|
||||||
@@ -94,3 +115,73 @@ func TestFetchCoverCachedTTLExpiry(t *testing.T) {
|
|||||||
t.Fatalf("expected refetch after TTL expiry, got %d fetches", got)
|
t.Fatalf("expected refetch after TTL expiry, got %d fetches", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMaxQualityCoverCandidatesProbe1900And1500(t *testing.T) {
|
||||||
|
url := "https://cdn-images.dzcdn.net/images/cover/abc/1000x1000-000000-80-0-0.jpg"
|
||||||
|
candidates := maxQualityCoverCandidateURLs(url)
|
||||||
|
if len(candidates) != 2 {
|
||||||
|
t.Fatalf("expected two candidates, got %#v", candidates)
|
||||||
|
}
|
||||||
|
if !strings.Contains(candidates[0], "1900x1900") {
|
||||||
|
t.Fatalf("first candidate = %q", candidates[0])
|
||||||
|
}
|
||||||
|
if !strings.Contains(candidates[1], "1500x1500") {
|
||||||
|
t.Fatalf("second candidate = %q", candidates[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadCoverSelectsDecodedDimensionsBeforeByteSize(t *testing.T) {
|
||||||
|
orig := coverFetch
|
||||||
|
defer func() { coverFetch = orig }()
|
||||||
|
resetCoverCache()
|
||||||
|
|
||||||
|
advertised1900ButSmaller := append(testPNG(t, 12, 12), bytes.Repeat([]byte{0}, 4096)...)
|
||||||
|
actualLargerDimensions := testPNG(t, 15, 15)
|
||||||
|
coverFetch = func(url string) ([]byte, error) {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(url, "1900x1900"):
|
||||||
|
return advertised1900ButSmaller, nil
|
||||||
|
case strings.Contains(url, "1500x1500"):
|
||||||
|
return actualLargerDimensions, nil
|
||||||
|
default:
|
||||||
|
return nil, errors.New("unexpected URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
url := "https://cdn-images.dzcdn.net/images/cover/abc/1000x1000-000000-80-0-0.jpg"
|
||||||
|
got, err := downloadCoverToMemory(url, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("download max cover: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got, actualLargerDimensions) {
|
||||||
|
t.Fatal("expected decoded 15x15 candidate instead of larger-byte 12x12 candidate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBestCoverCandidateUsesByteSizeAsDimensionTiebreaker(t *testing.T) {
|
||||||
|
orig := coverFetch
|
||||||
|
defer func() { coverFetch = orig }()
|
||||||
|
resetCoverCache()
|
||||||
|
|
||||||
|
base := testPNG(t, 10, 10)
|
||||||
|
largerFile := append(append([]byte(nil), base...), bytes.Repeat([]byte{0}, 512)...)
|
||||||
|
coverFetch = func(url string) ([]byte, error) {
|
||||||
|
if strings.Contains(url, "large") {
|
||||||
|
return largerFile, nil
|
||||||
|
}
|
||||||
|
return base, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
got, selected, width, height, err := fetchBestCoverCandidate(
|
||||||
|
[]string{"https://covers.test/small", "https://covers.test/large"},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("select cover: %v", err)
|
||||||
|
}
|
||||||
|
if selected != "https://covers.test/large" || width != 10 || height != 10 {
|
||||||
|
t.Fatalf("selected=%q dimensions=%dx%d", selected, width, height)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got, largerFile) {
|
||||||
|
t.Fatal("expected larger file when decoded dimensions are equal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ func resolveCollectionShareURL(ext *loadedExtension, itemType string, track *Ext
|
|||||||
if url := urlFromExternalLinks(track.ExternalLinks, "album"); url != "" {
|
if url := urlFromExternalLinks(track.ExternalLinks, "album"); url != "" {
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
if url := templateShareURL(ext, "album", firstNonEmptyString(track.AlbumID, collectionID(*track, "album"), track.AlbumURL)); url != "" {
|
if url := templateShareURL(ext, "album", firstNonEmptyTrimmed(track.AlbumID, collectionID(*track, "album"), track.AlbumURL)); url != "" {
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
@@ -350,7 +350,7 @@ func resolveCollectionShareURL(ext *loadedExtension, itemType string, track *Ext
|
|||||||
if url := urlFromExternalLinks(track.ExternalLinks, "artist"); url != "" {
|
if url := urlFromExternalLinks(track.ExternalLinks, "artist"); url != "" {
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
if url := templateShareURL(ext, "artist", firstNonEmptyString(track.ArtistID, collectionID(*track, "artist"))); url != "" {
|
if url := templateShareURL(ext, "artist", firstNonEmptyTrimmed(track.ArtistID, collectionID(*track, "artist"))); url != "" {
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
@@ -430,13 +430,3 @@ func stripProviderPrefix(id string) string {
|
|||||||
}
|
}
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
func firstNonEmptyString(values ...string) string {
|
|
||||||
for _, value := range values {
|
|
||||||
trimmed := strings.TrimSpace(value)
|
|
||||||
if trimmed != "" {
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -159,6 +159,40 @@ func writeTestFlacWithISRC(t *testing.T, path, isrc string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestISRCIndexCoversNonFlacFormats(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
flacPath := filepath.Join(dir, "a.flac")
|
||||||
|
mp3Path := filepath.Join(dir, "b.mp3")
|
||||||
|
writeTestFlacWithISRC(t, flacPath, "USAA00000011")
|
||||||
|
mp3Data := buildID3v23Tag(
|
||||||
|
id3TextFrame("TIT2", "Song"),
|
||||||
|
id3TextFrame("TSRC", "usbb00000022"),
|
||||||
|
)
|
||||||
|
if err := os.WriteFile(mp3Path, mp3Data, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Unsupported/untagged formats must stay invisible to the index.
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "c.wav"), []byte("RIFF"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer InvalidateISRCCache(dir)
|
||||||
|
|
||||||
|
if got := readFileISRC(mp3Path); got != "usbb00000022" {
|
||||||
|
t.Fatalf("readFileISRC mp3 = %q", got)
|
||||||
|
}
|
||||||
|
if got := readFileISRC(filepath.Join(dir, "c.wav")); got != "" {
|
||||||
|
t.Fatalf("readFileISRC wav = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
idx := buildISRCIndex(dir)
|
||||||
|
if path, ok := idx.lookup("USAA00000011"); !ok || path != flacPath {
|
||||||
|
t.Fatalf("flac lookup = %q/%v", path, ok)
|
||||||
|
}
|
||||||
|
if path, ok := idx.lookup("USBB00000022"); !ok || path != mp3Path {
|
||||||
|
t.Fatalf("mp3 lookup = %q/%v", path, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestISRCIndexIncrementalRebuild(t *testing.T) {
|
func TestISRCIndexIncrementalRebuild(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
trackA := filepath.Join(dir, "a.flac")
|
trackA := filepath.Join(dir, "a.flac")
|
||||||
|
|||||||
+6
-727
@@ -14,13 +14,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
deezerBaseURL = "https://api.deezer.com/2.0"
|
deezerBaseURL = "https://api.deezer.com/2.0"
|
||||||
deezerSearchURL = deezerBaseURL + "/search"
|
deezerSearchURL = deezerBaseURL + "/search"
|
||||||
deezerTrackURL = deezerBaseURL + "/track/%s"
|
deezerTrackURL = deezerBaseURL + "/track/%s"
|
||||||
deezerAlbumURL = deezerBaseURL + "/album/%s"
|
deezerAlbumURL = deezerBaseURL + "/album/%s"
|
||||||
deezerArtistURL = deezerBaseURL + "/artist/%s"
|
deezerArtistURL = deezerBaseURL + "/artist/%s"
|
||||||
deezerArtistRelatedURL = deezerBaseURL + "/artist/%s/related"
|
deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
|
||||||
deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
|
|
||||||
|
|
||||||
deezerCacheTTL = 10 * time.Minute
|
deezerCacheTTL = 10 * time.Minute
|
||||||
|
|
||||||
@@ -68,250 +67,6 @@ func GetDeezerClient() *DeezerClient {
|
|||||||
return deezerClient
|
return deezerClient
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *DeezerClient) pruneExpiredCacheEntriesLocked(
|
|
||||||
cache map[string]*cacheEntry,
|
|
||||||
now time.Time,
|
|
||||||
) {
|
|
||||||
for key, entry := range cache {
|
|
||||||
if entry == nil || now.After(entry.expiresAt) {
|
|
||||||
delete(cache, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *DeezerClient) trimCacheEntriesLocked(
|
|
||||||
cache map[string]*cacheEntry,
|
|
||||||
maxEntries int,
|
|
||||||
) {
|
|
||||||
if maxEntries <= 0 || len(cache) <= maxEntries {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for len(cache) > maxEntries {
|
|
||||||
var oldestKey string
|
|
||||||
var oldestExpiry time.Time
|
|
||||||
first := true
|
|
||||||
for key, entry := range cache {
|
|
||||||
expiry := time.Time{}
|
|
||||||
if entry != nil {
|
|
||||||
expiry = entry.expiresAt
|
|
||||||
}
|
|
||||||
if first || expiry.Before(oldestExpiry) {
|
|
||||||
first = false
|
|
||||||
oldestKey = key
|
|
||||||
oldestExpiry = expiry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if oldestKey == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
delete(cache, oldestKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *DeezerClient) trimStringCacheEntriesLocked(
|
|
||||||
cache map[string]string,
|
|
||||||
maxEntries int,
|
|
||||||
) {
|
|
||||||
if maxEntries <= 0 || len(cache) <= maxEntries {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
toRemove := len(cache) - maxEntries
|
|
||||||
for key := range cache {
|
|
||||||
delete(cache, key)
|
|
||||||
toRemove--
|
|
||||||
if toRemove <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *DeezerClient) maybeCleanupCachesLocked(now time.Time) {
|
|
||||||
periodicCleanupDue := c.cacheCleanupInterval > 0 &&
|
|
||||||
(c.lastCacheCleanup.IsZero() ||
|
|
||||||
now.Sub(c.lastCacheCleanup) >= c.cacheCleanupInterval)
|
|
||||||
|
|
||||||
if periodicCleanupDue {
|
|
||||||
c.pruneExpiredCacheEntriesLocked(c.searchCache, now)
|
|
||||||
c.pruneExpiredCacheEntriesLocked(c.albumCache, now)
|
|
||||||
c.pruneExpiredCacheEntriesLocked(c.artistCache, now)
|
|
||||||
c.lastCacheCleanup = now
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(c.searchCache) > deezerMaxSearchCacheEntries {
|
|
||||||
if !periodicCleanupDue {
|
|
||||||
c.pruneExpiredCacheEntriesLocked(c.searchCache, now)
|
|
||||||
}
|
|
||||||
c.trimCacheEntriesLocked(c.searchCache, deezerMaxSearchCacheEntries)
|
|
||||||
}
|
|
||||||
if len(c.albumCache) > deezerMaxAlbumCacheEntries {
|
|
||||||
if !periodicCleanupDue {
|
|
||||||
c.pruneExpiredCacheEntriesLocked(c.albumCache, now)
|
|
||||||
}
|
|
||||||
c.trimCacheEntriesLocked(c.albumCache, deezerMaxAlbumCacheEntries)
|
|
||||||
}
|
|
||||||
if len(c.artistCache) > deezerMaxArtistCacheEntries {
|
|
||||||
if !periodicCleanupDue {
|
|
||||||
c.pruneExpiredCacheEntriesLocked(c.artistCache, now)
|
|
||||||
}
|
|
||||||
c.trimCacheEntriesLocked(c.artistCache, deezerMaxArtistCacheEntries)
|
|
||||||
}
|
|
||||||
if len(c.isrcCache) > deezerMaxISRCCacheEntries {
|
|
||||||
c.trimStringCacheEntriesLocked(c.isrcCache, deezerMaxISRCCacheEntries)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type deezerTrack struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Duration int `json:"duration"`
|
|
||||||
TrackPosition int `json:"track_position"`
|
|
||||||
DiskNumber int `json:"disk_number"`
|
|
||||||
ISRC string `json:"isrc"`
|
|
||||||
Link string `json:"link"`
|
|
||||||
ReleaseDate string `json:"release_date"`
|
|
||||||
ExplicitLyrics bool `json:"explicit_lyrics"`
|
|
||||||
ExplicitContentLyrics int `json:"explicit_content_lyrics"`
|
|
||||||
Artist deezerArtist `json:"artist"`
|
|
||||||
Album deezerAlbumSimple `json:"album"`
|
|
||||||
Contributors []deezerArtist `json:"contributors"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// deezerTrackIsExplicit maps Deezer's parental-advisory fields to a boolean:
|
|
||||||
// explicit_lyrics is the boolean flag, explicit_content_lyrics uses 1 to mean
|
|
||||||
// explicit (0 = clean, 2 = unknown).
|
|
||||||
func deezerTrackIsExplicit(track deezerTrack) bool {
|
|
||||||
return track.ExplicitLyrics || track.ExplicitContentLyrics == 1
|
|
||||||
}
|
|
||||||
|
|
||||||
type deezerArtist struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Picture string `json:"picture"`
|
|
||||||
PictureMedium string `json:"picture_medium"`
|
|
||||||
PictureBig string `json:"picture_big"`
|
|
||||||
PictureXL string `json:"picture_xl"`
|
|
||||||
NbFan int `json:"nb_fan"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type deezerAlbumSimple struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Cover string `json:"cover"`
|
|
||||||
CoverMedium string `json:"cover_medium"`
|
|
||||||
CoverBig string `json:"cover_big"`
|
|
||||||
CoverXL string `json:"cover_xl"`
|
|
||||||
ReleaseDate string `json:"release_date"`
|
|
||||||
RecordType string `json:"record_type"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// deezerTrackArtistDisplay returns the display artist string for a track,
|
|
||||||
// preferring the Contributors list (comma-joined) when available, falling
|
|
||||||
// back to the primary Artist.Name.
|
|
||||||
func deezerTrackArtistDisplay(track deezerTrack) string {
|
|
||||||
if len(track.Contributors) > 0 {
|
|
||||||
names := make([]string, len(track.Contributors))
|
|
||||||
for i, a := range track.Contributors {
|
|
||||||
names[i] = a.Name
|
|
||||||
}
|
|
||||||
return strings.Join(names, ", ")
|
|
||||||
}
|
|
||||||
return track.Artist.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
|
||||||
artistName := deezerTrackArtistDisplay(track)
|
|
||||||
|
|
||||||
albumImage := track.Album.CoverXL
|
|
||||||
if albumImage == "" {
|
|
||||||
albumImage = track.Album.CoverBig
|
|
||||||
}
|
|
||||||
if albumImage == "" {
|
|
||||||
albumImage = track.Album.CoverMedium
|
|
||||||
}
|
|
||||||
if albumImage == "" {
|
|
||||||
albumImage = track.Album.Cover
|
|
||||||
}
|
|
||||||
|
|
||||||
releaseDate := track.ReleaseDate
|
|
||||||
if releaseDate == "" {
|
|
||||||
releaseDate = track.Album.ReleaseDate
|
|
||||||
}
|
|
||||||
|
|
||||||
return TrackMetadata{
|
|
||||||
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
|
||||||
Artists: artistName,
|
|
||||||
Name: track.Title,
|
|
||||||
AlbumName: track.Album.Title,
|
|
||||||
AlbumArtist: track.Artist.Name,
|
|
||||||
DurationMS: track.Duration * 1000,
|
|
||||||
Images: albumImage,
|
|
||||||
ReleaseDate: releaseDate,
|
|
||||||
TrackNumber: track.TrackPosition,
|
|
||||||
DiscNumber: track.DiskNumber,
|
|
||||||
ExternalURL: track.Link,
|
|
||||||
ISRC: track.ISRC,
|
|
||||||
AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID),
|
|
||||||
ArtistID: fmt.Sprintf("deezer:%d", track.Artist.ID),
|
|
||||||
Explicit: deezerTrackIsExplicit(track),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type deezerGenre struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type deezerAlbumFull struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Cover string `json:"cover"`
|
|
||||||
CoverMedium string `json:"cover_medium"`
|
|
||||||
CoverBig string `json:"cover_big"`
|
|
||||||
CoverXL string `json:"cover_xl"`
|
|
||||||
ReleaseDate string `json:"release_date"`
|
|
||||||
NbTracks int `json:"nb_tracks"`
|
|
||||||
RecordType string `json:"record_type"`
|
|
||||||
Label string `json:"label"`
|
|
||||||
Copyright string `json:"copyright"`
|
|
||||||
Genres struct {
|
|
||||||
Data []deezerGenre `json:"data"`
|
|
||||||
} `json:"genres"`
|
|
||||||
Artist deezerArtist `json:"artist"`
|
|
||||||
Contributors []deezerArtist `json:"contributors"`
|
|
||||||
Tracks struct {
|
|
||||||
Data []deezerTrack `json:"data"`
|
|
||||||
} `json:"tracks"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type deezerArtistFull struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Picture string `json:"picture"`
|
|
||||||
PictureMedium string `json:"picture_medium"`
|
|
||||||
PictureBig string `json:"picture_big"`
|
|
||||||
PictureXL string `json:"picture_xl"`
|
|
||||||
NbFan int `json:"nb_fan"`
|
|
||||||
NbAlbum int `json:"nb_album"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type deezerPlaylistFull struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Picture string `json:"picture"`
|
|
||||||
PictureMedium string `json:"picture_medium"`
|
|
||||||
PictureBig string `json:"picture_big"`
|
|
||||||
PictureXL string `json:"picture_xl"`
|
|
||||||
NbTracks int `json:"nb_tracks"`
|
|
||||||
Creator struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
} `json:"creator"`
|
|
||||||
Tracks struct {
|
|
||||||
Data []deezerTrack `json:"data"`
|
|
||||||
} `json:"tracks"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) {
|
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) {
|
||||||
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
|
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
|
||||||
|
|
||||||
@@ -563,443 +318,6 @@ func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResp
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) {
|
|
||||||
c.cacheMu.RLock()
|
|
||||||
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
|
||||||
c.cacheMu.RUnlock()
|
|
||||||
return entry.data.(*AlbumResponsePayload), nil
|
|
||||||
}
|
|
||||||
c.cacheMu.RUnlock()
|
|
||||||
|
|
||||||
albumURL := fmt.Sprintf(deezerAlbumURL, albumID)
|
|
||||||
|
|
||||||
var album deezerAlbumFull
|
|
||||||
if err := c.getJSON(ctx, albumURL, &album); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
albumImage := c.getBestAlbumImage(album)
|
|
||||||
artistName := album.Artist.Name
|
|
||||||
if len(album.Contributors) > 0 {
|
|
||||||
names := make([]string, len(album.Contributors))
|
|
||||||
for i, a := range album.Contributors {
|
|
||||||
names[i] = a.Name
|
|
||||||
}
|
|
||||||
artistName = strings.Join(names, ", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
var genres []string
|
|
||||||
for _, g := range album.Genres.Data {
|
|
||||||
if g.Name != "" {
|
|
||||||
genres = append(genres, g.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
genreStr := strings.Join(genres, ", ")
|
|
||||||
|
|
||||||
info := AlbumInfoMetadata{
|
|
||||||
TotalTracks: album.NbTracks,
|
|
||||||
Name: album.Title,
|
|
||||||
ReleaseDate: album.ReleaseDate,
|
|
||||||
Artists: artistName,
|
|
||||||
ArtistId: fmt.Sprintf("deezer:%d", album.Artist.ID),
|
|
||||||
Images: albumImage,
|
|
||||||
Genre: genreStr,
|
|
||||||
Label: album.Label,
|
|
||||||
}
|
|
||||||
|
|
||||||
allTracks := album.Tracks.Data
|
|
||||||
|
|
||||||
if album.NbTracks > len(allTracks) {
|
|
||||||
GoLog("[Deezer] Album has %d tracks but only got %d, fetching remaining...", album.NbTracks, len(allTracks))
|
|
||||||
|
|
||||||
tracksURL := fmt.Sprintf("%s/tracks?limit=100&index=%d", fmt.Sprintf(deezerAlbumURL, albumID), len(allTracks))
|
|
||||||
|
|
||||||
for len(allTracks) < album.NbTracks {
|
|
||||||
var tracksResp struct {
|
|
||||||
Data []deezerTrack `json:"data"`
|
|
||||||
Next string `json:"next"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.getJSON(ctx, tracksURL, &tracksResp); err != nil {
|
|
||||||
GoLog("[Deezer] Warning: failed to fetch album tracks page: %v", err)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(tracksResp.Data) == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
allTracks = append(allTracks, tracksResp.Data...)
|
|
||||||
|
|
||||||
if tracksResp.Next == "" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
tracksURL = tracksResp.Next
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[Deezer] Fetched total %d tracks for album", len(allTracks))
|
|
||||||
}
|
|
||||||
|
|
||||||
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
|
|
||||||
totalDiscs := 0
|
|
||||||
for _, track := range allTracks {
|
|
||||||
if track.DiskNumber > totalDiscs {
|
|
||||||
totalDiscs = track.DiskNumber
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
|
|
||||||
albumType := album.RecordType
|
|
||||||
if albumType == "compile" {
|
|
||||||
albumType = "compilation"
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, track := range allTracks {
|
|
||||||
trackIDStr := fmt.Sprintf("%d", track.ID)
|
|
||||||
isrc := isrcMap[trackIDStr]
|
|
||||||
|
|
||||||
trackNum := track.TrackPosition
|
|
||||||
if trackNum == 0 {
|
|
||||||
trackNum = i + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
tracks = append(tracks, AlbumTrackMetadata{
|
|
||||||
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
|
||||||
Artists: deezerTrackArtistDisplay(track),
|
|
||||||
Name: track.Title,
|
|
||||||
AlbumName: album.Title,
|
|
||||||
AlbumArtist: artistName,
|
|
||||||
DurationMS: track.Duration * 1000,
|
|
||||||
Images: albumImage,
|
|
||||||
ReleaseDate: album.ReleaseDate,
|
|
||||||
TrackNumber: trackNum,
|
|
||||||
TotalTracks: album.NbTracks,
|
|
||||||
DiscNumber: track.DiskNumber,
|
|
||||||
TotalDiscs: totalDiscs,
|
|
||||||
ExternalURL: track.Link,
|
|
||||||
ISRC: isrc,
|
|
||||||
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
|
|
||||||
AlbumType: albumType,
|
|
||||||
Explicit: deezerTrackIsExplicit(track),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
result := &AlbumResponsePayload{
|
|
||||||
AlbumInfo: info,
|
|
||||||
TrackList: tracks,
|
|
||||||
}
|
|
||||||
|
|
||||||
c.cacheMu.Lock()
|
|
||||||
now := time.Now()
|
|
||||||
c.albumCache[albumID] = &cacheEntry{
|
|
||||||
data: result,
|
|
||||||
expiresAt: now.Add(deezerCacheTTL),
|
|
||||||
}
|
|
||||||
c.maybeCleanupCachesLocked(now)
|
|
||||||
c.cacheMu.Unlock()
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistResponsePayload, error) {
|
|
||||||
c.cacheMu.RLock()
|
|
||||||
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
|
|
||||||
c.cacheMu.RUnlock()
|
|
||||||
return entry.data.(*ArtistResponsePayload), nil
|
|
||||||
}
|
|
||||||
c.cacheMu.RUnlock()
|
|
||||||
|
|
||||||
artistURL := fmt.Sprintf(deezerArtistURL, artistID)
|
|
||||||
var artist deezerArtistFull
|
|
||||||
if err := c.getJSON(ctx, artistURL, &artist); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
artistInfo := ArtistInfoMetadata{
|
|
||||||
ID: fmt.Sprintf("deezer:%d", artist.ID),
|
|
||||||
Name: artist.Name,
|
|
||||||
Images: c.getBestArtistImageFull(artist),
|
|
||||||
Followers: artist.NbFan,
|
|
||||||
Popularity: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
albumsURL := fmt.Sprintf("%s/albums?limit=100", fmt.Sprintf(deezerArtistURL, artistID))
|
|
||||||
var albumsResp struct {
|
|
||||||
Data []struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
ReleaseDate string `json:"release_date"`
|
|
||||||
NbTracks int `json:"nb_tracks"`
|
|
||||||
Cover string `json:"cover"`
|
|
||||||
CoverMedium string `json:"cover_medium"`
|
|
||||||
CoverBig string `json:"cover_big"`
|
|
||||||
CoverXL string `json:"cover_xl"`
|
|
||||||
RecordType string `json:"record_type"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
albums := make([]ArtistAlbumMetadata, 0)
|
|
||||||
if err := c.getJSON(ctx, albumsURL, &albumsResp); err == nil {
|
|
||||||
for _, album := range albumsResp.Data {
|
|
||||||
albumType := album.RecordType
|
|
||||||
if albumType == "compile" {
|
|
||||||
albumType = "compilation"
|
|
||||||
}
|
|
||||||
|
|
||||||
coverURL := album.CoverXL
|
|
||||||
if coverURL == "" {
|
|
||||||
coverURL = album.CoverBig
|
|
||||||
}
|
|
||||||
if coverURL == "" {
|
|
||||||
coverURL = album.CoverMedium
|
|
||||||
}
|
|
||||||
if coverURL == "" {
|
|
||||||
coverURL = album.Cover
|
|
||||||
}
|
|
||||||
|
|
||||||
albums = append(albums, ArtistAlbumMetadata{
|
|
||||||
ID: fmt.Sprintf("deezer:%d", album.ID),
|
|
||||||
Name: album.Title,
|
|
||||||
ReleaseDate: album.ReleaseDate,
|
|
||||||
TotalTracks: album.NbTracks,
|
|
||||||
Images: coverURL,
|
|
||||||
AlbumType: albumType,
|
|
||||||
Artists: artist.Name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// The Deezer /artist/{id}/albums endpoint does not return nb_tracks.
|
|
||||||
// Fetch track counts in parallel from individual /album/{id} endpoints.
|
|
||||||
c.fetchAlbumTrackCounts(ctx, albums)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := &ArtistResponsePayload{
|
|
||||||
ArtistInfo: artistInfo,
|
|
||||||
Albums: albums,
|
|
||||||
}
|
|
||||||
|
|
||||||
c.cacheMu.Lock()
|
|
||||||
now := time.Now()
|
|
||||||
c.artistCache[artistID] = &cacheEntry{
|
|
||||||
data: result,
|
|
||||||
expiresAt: now.Add(deezerCacheTTL),
|
|
||||||
}
|
|
||||||
c.maybeCleanupCachesLocked(now)
|
|
||||||
c.cacheMu.Unlock()
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchAlbumTrackCounts fetches nb_tracks for each album in parallel using
|
|
||||||
// individual /album/{id} calls, since the /artist/{id}/albums endpoint does
|
|
||||||
// not include this field. Albums whose track count is already known (non-zero)
|
|
||||||
// are skipped.
|
|
||||||
func (c *DeezerClient) fetchAlbumTrackCounts(ctx context.Context, albums []ArtistAlbumMetadata) {
|
|
||||||
type indexedID struct {
|
|
||||||
idx int
|
|
||||||
albumID string
|
|
||||||
}
|
|
||||||
var toFetch []indexedID
|
|
||||||
for i, a := range albums {
|
|
||||||
if a.TotalTracks == 0 {
|
|
||||||
rawID := strings.TrimPrefix(a.ID, "deezer:")
|
|
||||||
if rawID != "" {
|
|
||||||
toFetch = append(toFetch, indexedID{idx: i, albumID: rawID})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(toFetch) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxParallel = 10
|
|
||||||
sem := make(chan struct{}, maxParallel)
|
|
||||||
var mu sync.Mutex
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
|
|
||||||
for _, item := range toFetch {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(it indexedID) {
|
|
||||||
defer wg.Done()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case sem <- struct{}{}:
|
|
||||||
defer func() { <-sem }()
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
albumURL := fmt.Sprintf(deezerAlbumURL, it.albumID)
|
|
||||||
var resp struct {
|
|
||||||
NbTracks int `json:"nb_tracks"`
|
|
||||||
}
|
|
||||||
if err := c.getJSON(ctx, albumURL, &resp); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
mu.Lock()
|
|
||||||
albums[it.idx].TotalTracks = resp.NbTracks
|
|
||||||
mu.Unlock()
|
|
||||||
}(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *DeezerClient) GetRelatedArtists(ctx context.Context, artistID string, limit int) ([]SearchArtistResult, error) {
|
|
||||||
normalizedArtistID := strings.TrimSpace(strings.TrimPrefix(artistID, "deezer:"))
|
|
||||||
if normalizedArtistID == "" {
|
|
||||||
return nil, fmt.Errorf("invalid Deezer artist ID")
|
|
||||||
}
|
|
||||||
|
|
||||||
effectiveLimit := limit
|
|
||||||
if effectiveLimit <= 0 {
|
|
||||||
effectiveLimit = 12
|
|
||||||
}
|
|
||||||
|
|
||||||
relatedURL := fmt.Sprintf("%s?limit=%d", fmt.Sprintf(deezerArtistRelatedURL, normalizedArtistID), effectiveLimit)
|
|
||||||
var relatedResp struct {
|
|
||||||
Data []struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Picture string `json:"picture"`
|
|
||||||
PictureMedium string `json:"picture_medium"`
|
|
||||||
PictureBig string `json:"picture_big"`
|
|
||||||
PictureXL string `json:"picture_xl"`
|
|
||||||
NbFan int `json:"nb_fan"`
|
|
||||||
} `json:"data"`
|
|
||||||
Error *struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Code int `json:"code"`
|
|
||||||
} `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.getJSON(ctx, relatedURL, &relatedResp); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if relatedResp.Error != nil {
|
|
||||||
return nil, fmt.Errorf("deezer related artists error: %s", relatedResp.Error.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := make([]SearchArtistResult, 0, len(relatedResp.Data))
|
|
||||||
for _, artist := range relatedResp.Data {
|
|
||||||
imageURL := artist.PictureXL
|
|
||||||
if imageURL == "" {
|
|
||||||
imageURL = artist.PictureBig
|
|
||||||
}
|
|
||||||
if imageURL == "" {
|
|
||||||
imageURL = artist.PictureMedium
|
|
||||||
}
|
|
||||||
if imageURL == "" {
|
|
||||||
imageURL = artist.Picture
|
|
||||||
}
|
|
||||||
|
|
||||||
result = append(result, SearchArtistResult{
|
|
||||||
ID: fmt.Sprintf("deezer:%d", artist.ID),
|
|
||||||
Name: artist.Name,
|
|
||||||
Images: imageURL,
|
|
||||||
Followers: artist.NbFan,
|
|
||||||
Popularity: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
|
|
||||||
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
|
|
||||||
|
|
||||||
var playlist deezerPlaylistFull
|
|
||||||
if err := c.getJSON(ctx, playlistURL, &playlist); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
playlistImage := playlist.PictureXL
|
|
||||||
if playlistImage == "" {
|
|
||||||
playlistImage = playlist.PictureBig
|
|
||||||
}
|
|
||||||
if playlistImage == "" {
|
|
||||||
playlistImage = playlist.PictureMedium
|
|
||||||
}
|
|
||||||
|
|
||||||
var info PlaylistInfoMetadata
|
|
||||||
info.Tracks.Total = playlist.NbTracks
|
|
||||||
info.Owner.DisplayName = playlist.Creator.Name
|
|
||||||
info.Owner.Name = playlist.Title
|
|
||||||
info.Owner.Images = playlistImage
|
|
||||||
|
|
||||||
allTracks := playlist.Tracks.Data
|
|
||||||
|
|
||||||
if playlist.NbTracks > len(allTracks) {
|
|
||||||
GoLog("[Deezer] Playlist has %d tracks but only got %d, fetching remaining...", playlist.NbTracks, len(allTracks))
|
|
||||||
|
|
||||||
tracksURL := fmt.Sprintf("%s/tracks?limit=100&index=%d", fmt.Sprintf(deezerPlaylistURL, playlistID), len(allTracks))
|
|
||||||
|
|
||||||
for len(allTracks) < playlist.NbTracks {
|
|
||||||
var tracksResp struct {
|
|
||||||
Data []deezerTrack `json:"data"`
|
|
||||||
Next string `json:"next"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.getJSON(ctx, tracksURL, &tracksResp); err != nil {
|
|
||||||
GoLog("[Deezer] Warning: failed to fetch playlist tracks page: %v", err)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(tracksResp.Data) == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
allTracks = append(allTracks, tracksResp.Data...)
|
|
||||||
|
|
||||||
if tracksResp.Next == "" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
tracksURL = tracksResp.Next
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[Deezer] Fetched total %d tracks for playlist", len(allTracks))
|
|
||||||
}
|
|
||||||
|
|
||||||
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
|
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
|
|
||||||
for _, track := range allTracks {
|
|
||||||
albumImage := track.Album.CoverXL
|
|
||||||
if albumImage == "" {
|
|
||||||
albumImage = track.Album.CoverBig
|
|
||||||
}
|
|
||||||
if albumImage == "" {
|
|
||||||
albumImage = track.Album.CoverMedium
|
|
||||||
}
|
|
||||||
|
|
||||||
trackIDStr := fmt.Sprintf("%d", track.ID)
|
|
||||||
isrc := isrcMap[trackIDStr]
|
|
||||||
|
|
||||||
tracks = append(tracks, AlbumTrackMetadata{
|
|
||||||
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
|
||||||
Artists: deezerTrackArtistDisplay(track),
|
|
||||||
Name: track.Title,
|
|
||||||
AlbumName: track.Album.Title,
|
|
||||||
AlbumArtist: track.Artist.Name,
|
|
||||||
DurationMS: track.Duration * 1000,
|
|
||||||
Images: albumImage,
|
|
||||||
ReleaseDate: "",
|
|
||||||
TrackNumber: track.TrackPosition,
|
|
||||||
DiscNumber: track.DiskNumber,
|
|
||||||
ExternalURL: track.Link,
|
|
||||||
ISRC: isrc,
|
|
||||||
AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID),
|
|
||||||
Explicit: deezerTrackIsExplicit(track),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return &PlaylistResponsePayload{
|
|
||||||
PlaylistInfo: info,
|
|
||||||
TrackList: tracks,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMetadata, error) {
|
func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMetadata, error) {
|
||||||
directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc)
|
directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc)
|
||||||
|
|
||||||
@@ -1132,45 +450,6 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
|
|||||||
return fullTrack.ISRC, nil
|
return fullTrack.ISRC, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *DeezerClient) getBestArtistImage(artist deezerArtist) string {
|
|
||||||
if artist.PictureXL != "" {
|
|
||||||
return artist.PictureXL
|
|
||||||
}
|
|
||||||
if artist.PictureBig != "" {
|
|
||||||
return artist.PictureBig
|
|
||||||
}
|
|
||||||
if artist.PictureMedium != "" {
|
|
||||||
return artist.PictureMedium
|
|
||||||
}
|
|
||||||
return artist.Picture
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *DeezerClient) getBestArtistImageFull(artist deezerArtistFull) string {
|
|
||||||
if artist.PictureXL != "" {
|
|
||||||
return artist.PictureXL
|
|
||||||
}
|
|
||||||
if artist.PictureBig != "" {
|
|
||||||
return artist.PictureBig
|
|
||||||
}
|
|
||||||
if artist.PictureMedium != "" {
|
|
||||||
return artist.PictureMedium
|
|
||||||
}
|
|
||||||
return artist.Picture
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
|
|
||||||
if album.CoverXL != "" {
|
|
||||||
return album.CoverXL
|
|
||||||
}
|
|
||||||
if album.CoverBig != "" {
|
|
||||||
return album.CoverBig
|
|
||||||
}
|
|
||||||
if album.CoverMedium != "" {
|
|
||||||
return album.CoverMedium
|
|
||||||
}
|
|
||||||
return album.Cover
|
|
||||||
}
|
|
||||||
|
|
||||||
type AlbumExtendedMetadata struct {
|
type AlbumExtendedMetadata struct {
|
||||||
Genre string
|
Genre string
|
||||||
Label string
|
Label string
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) {
|
||||||
|
c.cacheMu.RLock()
|
||||||
|
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
return entry.data.(*AlbumResponsePayload), nil
|
||||||
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
albumURL := fmt.Sprintf(deezerAlbumURL, albumID)
|
||||||
|
|
||||||
|
var album deezerAlbumFull
|
||||||
|
if err := c.getJSON(ctx, albumURL, &album); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
albumImage := c.getBestAlbumImage(album)
|
||||||
|
artistName := album.Artist.Name
|
||||||
|
if len(album.Contributors) > 0 {
|
||||||
|
names := make([]string, len(album.Contributors))
|
||||||
|
for i, a := range album.Contributors {
|
||||||
|
names[i] = a.Name
|
||||||
|
}
|
||||||
|
artistName = strings.Join(names, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
var genres []string
|
||||||
|
for _, g := range album.Genres.Data {
|
||||||
|
if g.Name != "" {
|
||||||
|
genres = append(genres, g.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
genreStr := strings.Join(genres, ", ")
|
||||||
|
|
||||||
|
info := AlbumInfoMetadata{
|
||||||
|
TotalTracks: album.NbTracks,
|
||||||
|
Name: album.Title,
|
||||||
|
ReleaseDate: album.ReleaseDate,
|
||||||
|
Artists: artistName,
|
||||||
|
ArtistId: fmt.Sprintf("deezer:%d", album.Artist.ID),
|
||||||
|
Images: albumImage,
|
||||||
|
Genre: genreStr,
|
||||||
|
Label: album.Label,
|
||||||
|
}
|
||||||
|
|
||||||
|
allTracks := album.Tracks.Data
|
||||||
|
|
||||||
|
if album.NbTracks > len(allTracks) {
|
||||||
|
GoLog("[Deezer] Album has %d tracks but only got %d, fetching remaining...", album.NbTracks, len(allTracks))
|
||||||
|
|
||||||
|
tracksURL := fmt.Sprintf("%s/tracks?limit=100&index=%d", fmt.Sprintf(deezerAlbumURL, albumID), len(allTracks))
|
||||||
|
|
||||||
|
for len(allTracks) < album.NbTracks {
|
||||||
|
var tracksResp struct {
|
||||||
|
Data []deezerTrack `json:"data"`
|
||||||
|
Next string `json:"next"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.getJSON(ctx, tracksURL, &tracksResp); err != nil {
|
||||||
|
GoLog("[Deezer] Warning: failed to fetch album tracks page: %v", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tracksResp.Data) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
allTracks = append(allTracks, tracksResp.Data...)
|
||||||
|
|
||||||
|
if tracksResp.Next == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
tracksURL = tracksResp.Next
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Deezer] Fetched total %d tracks for album", len(allTracks))
|
||||||
|
}
|
||||||
|
|
||||||
|
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
|
||||||
|
totalDiscs := 0
|
||||||
|
for _, track := range allTracks {
|
||||||
|
if track.DiskNumber > totalDiscs {
|
||||||
|
totalDiscs = track.DiskNumber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
|
||||||
|
albumType := album.RecordType
|
||||||
|
if albumType == "compile" {
|
||||||
|
albumType = "compilation"
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, track := range allTracks {
|
||||||
|
trackIDStr := fmt.Sprintf("%d", track.ID)
|
||||||
|
isrc := isrcMap[trackIDStr]
|
||||||
|
|
||||||
|
trackNum := track.TrackPosition
|
||||||
|
if trackNum == 0 {
|
||||||
|
trackNum = i + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
|
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
||||||
|
Artists: deezerTrackArtistDisplay(track),
|
||||||
|
Name: track.Title,
|
||||||
|
AlbumName: album.Title,
|
||||||
|
AlbumArtist: artistName,
|
||||||
|
DurationMS: track.Duration * 1000,
|
||||||
|
Images: albumImage,
|
||||||
|
ReleaseDate: album.ReleaseDate,
|
||||||
|
TrackNumber: trackNum,
|
||||||
|
TotalTracks: album.NbTracks,
|
||||||
|
DiscNumber: track.DiskNumber,
|
||||||
|
TotalDiscs: totalDiscs,
|
||||||
|
ExternalURL: track.Link,
|
||||||
|
ISRC: isrc,
|
||||||
|
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
|
||||||
|
AlbumType: albumType,
|
||||||
|
Explicit: deezerTrackIsExplicit(track),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &AlbumResponsePayload{
|
||||||
|
AlbumInfo: info,
|
||||||
|
TrackList: tracks,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
now := time.Now()
|
||||||
|
c.albumCache[albumID] = &cacheEntry{
|
||||||
|
data: result,
|
||||||
|
expiresAt: now.Add(deezerCacheTTL),
|
||||||
|
}
|
||||||
|
c.maybeCleanupCachesLocked(now)
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistResponsePayload, error) {
|
||||||
|
c.cacheMu.RLock()
|
||||||
|
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
return entry.data.(*ArtistResponsePayload), nil
|
||||||
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
artistURL := fmt.Sprintf(deezerArtistURL, artistID)
|
||||||
|
var artist deezerArtistFull
|
||||||
|
if err := c.getJSON(ctx, artistURL, &artist); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
artistInfo := ArtistInfoMetadata{
|
||||||
|
ID: fmt.Sprintf("deezer:%d", artist.ID),
|
||||||
|
Name: artist.Name,
|
||||||
|
Images: c.getBestArtistImageFull(artist),
|
||||||
|
Followers: artist.NbFan,
|
||||||
|
Popularity: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
albumsURL := fmt.Sprintf("%s/albums?limit=100", fmt.Sprintf(deezerArtistURL, artistID))
|
||||||
|
var albumsResp struct {
|
||||||
|
Data []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
NbTracks int `json:"nb_tracks"`
|
||||||
|
Cover string `json:"cover"`
|
||||||
|
CoverMedium string `json:"cover_medium"`
|
||||||
|
CoverBig string `json:"cover_big"`
|
||||||
|
CoverXL string `json:"cover_xl"`
|
||||||
|
RecordType string `json:"record_type"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
albums := make([]ArtistAlbumMetadata, 0)
|
||||||
|
if err := c.getJSON(ctx, albumsURL, &albumsResp); err == nil {
|
||||||
|
for _, album := range albumsResp.Data {
|
||||||
|
albumType := album.RecordType
|
||||||
|
if albumType == "compile" {
|
||||||
|
albumType = "compilation"
|
||||||
|
}
|
||||||
|
|
||||||
|
coverURL := album.CoverXL
|
||||||
|
if coverURL == "" {
|
||||||
|
coverURL = album.CoverBig
|
||||||
|
}
|
||||||
|
if coverURL == "" {
|
||||||
|
coverURL = album.CoverMedium
|
||||||
|
}
|
||||||
|
if coverURL == "" {
|
||||||
|
coverURL = album.Cover
|
||||||
|
}
|
||||||
|
|
||||||
|
albums = append(albums, ArtistAlbumMetadata{
|
||||||
|
ID: fmt.Sprintf("deezer:%d", album.ID),
|
||||||
|
Name: album.Title,
|
||||||
|
ReleaseDate: album.ReleaseDate,
|
||||||
|
TotalTracks: album.NbTracks,
|
||||||
|
Images: coverURL,
|
||||||
|
AlbumType: albumType,
|
||||||
|
Artists: artist.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Deezer /artist/{id}/albums endpoint does not return nb_tracks.
|
||||||
|
// Fetch track counts in parallel from individual /album/{id} endpoints.
|
||||||
|
c.fetchAlbumTrackCounts(ctx, albums)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &ArtistResponsePayload{
|
||||||
|
ArtistInfo: artistInfo,
|
||||||
|
Albums: albums,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
now := time.Now()
|
||||||
|
c.artistCache[artistID] = &cacheEntry{
|
||||||
|
data: result,
|
||||||
|
expiresAt: now.Add(deezerCacheTTL),
|
||||||
|
}
|
||||||
|
c.maybeCleanupCachesLocked(now)
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchAlbumTrackCounts fetches nb_tracks for each album in parallel using
|
||||||
|
// individual /album/{id} calls, since the /artist/{id}/albums endpoint does
|
||||||
|
// not include this field. Albums whose track count is already known (non-zero)
|
||||||
|
// are skipped.
|
||||||
|
func (c *DeezerClient) fetchAlbumTrackCounts(ctx context.Context, albums []ArtistAlbumMetadata) {
|
||||||
|
type indexedID struct {
|
||||||
|
idx int
|
||||||
|
albumID string
|
||||||
|
}
|
||||||
|
var toFetch []indexedID
|
||||||
|
for i, a := range albums {
|
||||||
|
if a.TotalTracks == 0 {
|
||||||
|
rawID := strings.TrimPrefix(a.ID, "deezer:")
|
||||||
|
if rawID != "" {
|
||||||
|
toFetch = append(toFetch, indexedID{idx: i, albumID: rawID})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(toFetch) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxParallel = 10
|
||||||
|
sem := make(chan struct{}, maxParallel)
|
||||||
|
var mu sync.Mutex
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for _, item := range toFetch {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(it indexedID) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case sem <- struct{}{}:
|
||||||
|
defer func() { <-sem }()
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
albumURL := fmt.Sprintf(deezerAlbumURL, it.albumID)
|
||||||
|
var resp struct {
|
||||||
|
NbTracks int `json:"nb_tracks"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, albumURL, &resp); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
albums[it.idx].TotalTracks = resp.NbTracks
|
||||||
|
mu.Unlock()
|
||||||
|
}(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
|
||||||
|
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
|
||||||
|
|
||||||
|
var playlist deezerPlaylistFull
|
||||||
|
if err := c.getJSON(ctx, playlistURL, &playlist); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
playlistImage := playlist.PictureXL
|
||||||
|
if playlistImage == "" {
|
||||||
|
playlistImage = playlist.PictureBig
|
||||||
|
}
|
||||||
|
if playlistImage == "" {
|
||||||
|
playlistImage = playlist.PictureMedium
|
||||||
|
}
|
||||||
|
|
||||||
|
var info PlaylistInfoMetadata
|
||||||
|
info.Tracks.Total = playlist.NbTracks
|
||||||
|
info.Owner.DisplayName = playlist.Creator.Name
|
||||||
|
info.Owner.Name = playlist.Title
|
||||||
|
info.Owner.Images = playlistImage
|
||||||
|
|
||||||
|
allTracks := playlist.Tracks.Data
|
||||||
|
|
||||||
|
if playlist.NbTracks > len(allTracks) {
|
||||||
|
GoLog("[Deezer] Playlist has %d tracks but only got %d, fetching remaining...", playlist.NbTracks, len(allTracks))
|
||||||
|
|
||||||
|
tracksURL := fmt.Sprintf("%s/tracks?limit=100&index=%d", fmt.Sprintf(deezerPlaylistURL, playlistID), len(allTracks))
|
||||||
|
|
||||||
|
for len(allTracks) < playlist.NbTracks {
|
||||||
|
var tracksResp struct {
|
||||||
|
Data []deezerTrack `json:"data"`
|
||||||
|
Next string `json:"next"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.getJSON(ctx, tracksURL, &tracksResp); err != nil {
|
||||||
|
GoLog("[Deezer] Warning: failed to fetch playlist tracks page: %v", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tracksResp.Data) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
allTracks = append(allTracks, tracksResp.Data...)
|
||||||
|
|
||||||
|
if tracksResp.Next == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
tracksURL = tracksResp.Next
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Deezer] Fetched total %d tracks for playlist", len(allTracks))
|
||||||
|
}
|
||||||
|
|
||||||
|
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
|
||||||
|
|
||||||
|
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
|
||||||
|
for _, track := range allTracks {
|
||||||
|
albumImage := track.Album.CoverXL
|
||||||
|
if albumImage == "" {
|
||||||
|
albumImage = track.Album.CoverBig
|
||||||
|
}
|
||||||
|
if albumImage == "" {
|
||||||
|
albumImage = track.Album.CoverMedium
|
||||||
|
}
|
||||||
|
|
||||||
|
trackIDStr := fmt.Sprintf("%d", track.ID)
|
||||||
|
isrc := isrcMap[trackIDStr]
|
||||||
|
|
||||||
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
|
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
||||||
|
Artists: deezerTrackArtistDisplay(track),
|
||||||
|
Name: track.Title,
|
||||||
|
AlbumName: track.Album.Title,
|
||||||
|
AlbumArtist: track.Artist.Name,
|
||||||
|
DurationMS: track.Duration * 1000,
|
||||||
|
Images: albumImage,
|
||||||
|
ReleaseDate: "",
|
||||||
|
TrackNumber: track.TrackPosition,
|
||||||
|
DiscNumber: track.DiskNumber,
|
||||||
|
ExternalURL: track.Link,
|
||||||
|
ISRC: isrc,
|
||||||
|
AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID),
|
||||||
|
Explicit: deezerTrackIsExplicit(track),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &PlaylistResponsePayload{
|
||||||
|
PlaylistInfo: info,
|
||||||
|
TrackList: tracks,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
func (c *DeezerClient) pruneExpiredCacheEntriesLocked(
|
||||||
|
cache map[string]*cacheEntry,
|
||||||
|
now time.Time,
|
||||||
|
) {
|
||||||
|
for key, entry := range cache {
|
||||||
|
if entry == nil || now.After(entry.expiresAt) {
|
||||||
|
delete(cache, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) trimCacheEntriesLocked(
|
||||||
|
cache map[string]*cacheEntry,
|
||||||
|
maxEntries int,
|
||||||
|
) {
|
||||||
|
if maxEntries <= 0 || len(cache) <= maxEntries {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for len(cache) > maxEntries {
|
||||||
|
var oldestKey string
|
||||||
|
var oldestExpiry time.Time
|
||||||
|
first := true
|
||||||
|
for key, entry := range cache {
|
||||||
|
expiry := time.Time{}
|
||||||
|
if entry != nil {
|
||||||
|
expiry = entry.expiresAt
|
||||||
|
}
|
||||||
|
if first || expiry.Before(oldestExpiry) {
|
||||||
|
first = false
|
||||||
|
oldestKey = key
|
||||||
|
oldestExpiry = expiry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if oldestKey == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(cache, oldestKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) trimStringCacheEntriesLocked(
|
||||||
|
cache map[string]string,
|
||||||
|
maxEntries int,
|
||||||
|
) {
|
||||||
|
if maxEntries <= 0 || len(cache) <= maxEntries {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toRemove := len(cache) - maxEntries
|
||||||
|
for key := range cache {
|
||||||
|
delete(cache, key)
|
||||||
|
toRemove--
|
||||||
|
if toRemove <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) maybeCleanupCachesLocked(now time.Time) {
|
||||||
|
periodicCleanupDue := c.cacheCleanupInterval > 0 &&
|
||||||
|
(c.lastCacheCleanup.IsZero() ||
|
||||||
|
now.Sub(c.lastCacheCleanup) >= c.cacheCleanupInterval)
|
||||||
|
|
||||||
|
if periodicCleanupDue {
|
||||||
|
c.pruneExpiredCacheEntriesLocked(c.searchCache, now)
|
||||||
|
c.pruneExpiredCacheEntriesLocked(c.albumCache, now)
|
||||||
|
c.pruneExpiredCacheEntriesLocked(c.artistCache, now)
|
||||||
|
c.lastCacheCleanup = now
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.searchCache) > deezerMaxSearchCacheEntries {
|
||||||
|
if !periodicCleanupDue {
|
||||||
|
c.pruneExpiredCacheEntriesLocked(c.searchCache, now)
|
||||||
|
}
|
||||||
|
c.trimCacheEntriesLocked(c.searchCache, deezerMaxSearchCacheEntries)
|
||||||
|
}
|
||||||
|
if len(c.albumCache) > deezerMaxAlbumCacheEntries {
|
||||||
|
if !periodicCleanupDue {
|
||||||
|
c.pruneExpiredCacheEntriesLocked(c.albumCache, now)
|
||||||
|
}
|
||||||
|
c.trimCacheEntriesLocked(c.albumCache, deezerMaxAlbumCacheEntries)
|
||||||
|
}
|
||||||
|
if len(c.artistCache) > deezerMaxArtistCacheEntries {
|
||||||
|
if !periodicCleanupDue {
|
||||||
|
c.pruneExpiredCacheEntriesLocked(c.artistCache, now)
|
||||||
|
}
|
||||||
|
c.trimCacheEntriesLocked(c.artistCache, deezerMaxArtistCacheEntries)
|
||||||
|
}
|
||||||
|
if len(c.isrcCache) > deezerMaxISRCCacheEntries {
|
||||||
|
c.trimStringCacheEntriesLocked(c.isrcCache, deezerMaxISRCCacheEntries)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type deezerTrack struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
TrackPosition int `json:"track_position"`
|
||||||
|
DiskNumber int `json:"disk_number"`
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
ExplicitLyrics bool `json:"explicit_lyrics"`
|
||||||
|
ExplicitContentLyrics int `json:"explicit_content_lyrics"`
|
||||||
|
Artist deezerArtist `json:"artist"`
|
||||||
|
Album deezerAlbumSimple `json:"album"`
|
||||||
|
Contributors []deezerArtist `json:"contributors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// deezerTrackIsExplicit maps Deezer's parental-advisory fields to a boolean:
|
||||||
|
// explicit_lyrics is the boolean flag, explicit_content_lyrics uses 1 to mean
|
||||||
|
// explicit (0 = clean, 2 = unknown).
|
||||||
|
func deezerTrackIsExplicit(track deezerTrack) bool {
|
||||||
|
return track.ExplicitLyrics || track.ExplicitContentLyrics == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
type deezerArtist struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Picture string `json:"picture"`
|
||||||
|
PictureMedium string `json:"picture_medium"`
|
||||||
|
PictureBig string `json:"picture_big"`
|
||||||
|
PictureXL string `json:"picture_xl"`
|
||||||
|
NbFan int `json:"nb_fan"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deezerAlbumSimple struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Cover string `json:"cover"`
|
||||||
|
CoverMedium string `json:"cover_medium"`
|
||||||
|
CoverBig string `json:"cover_big"`
|
||||||
|
CoverXL string `json:"cover_xl"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
RecordType string `json:"record_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// deezerTrackArtistDisplay returns the display artist string for a track,
|
||||||
|
// preferring the Contributors list (comma-joined) when available, falling
|
||||||
|
// back to the primary Artist.Name.
|
||||||
|
func deezerTrackArtistDisplay(track deezerTrack) string {
|
||||||
|
if len(track.Contributors) > 0 {
|
||||||
|
names := make([]string, len(track.Contributors))
|
||||||
|
for i, a := range track.Contributors {
|
||||||
|
names[i] = a.Name
|
||||||
|
}
|
||||||
|
return strings.Join(names, ", ")
|
||||||
|
}
|
||||||
|
return track.Artist.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
||||||
|
artistName := deezerTrackArtistDisplay(track)
|
||||||
|
|
||||||
|
albumImage := track.Album.CoverXL
|
||||||
|
if albumImage == "" {
|
||||||
|
albumImage = track.Album.CoverBig
|
||||||
|
}
|
||||||
|
if albumImage == "" {
|
||||||
|
albumImage = track.Album.CoverMedium
|
||||||
|
}
|
||||||
|
if albumImage == "" {
|
||||||
|
albumImage = track.Album.Cover
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseDate := track.ReleaseDate
|
||||||
|
if releaseDate == "" {
|
||||||
|
releaseDate = track.Album.ReleaseDate
|
||||||
|
}
|
||||||
|
|
||||||
|
return TrackMetadata{
|
||||||
|
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
||||||
|
Artists: artistName,
|
||||||
|
Name: track.Title,
|
||||||
|
AlbumName: track.Album.Title,
|
||||||
|
AlbumArtist: track.Artist.Name,
|
||||||
|
DurationMS: track.Duration * 1000,
|
||||||
|
Images: albumImage,
|
||||||
|
ReleaseDate: releaseDate,
|
||||||
|
TrackNumber: track.TrackPosition,
|
||||||
|
DiscNumber: track.DiskNumber,
|
||||||
|
ExternalURL: track.Link,
|
||||||
|
ISRC: track.ISRC,
|
||||||
|
AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID),
|
||||||
|
ArtistID: fmt.Sprintf("deezer:%d", track.Artist.ID),
|
||||||
|
Explicit: deezerTrackIsExplicit(track),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type deezerGenre struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deezerAlbumFull struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Cover string `json:"cover"`
|
||||||
|
CoverMedium string `json:"cover_medium"`
|
||||||
|
CoverBig string `json:"cover_big"`
|
||||||
|
CoverXL string `json:"cover_xl"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
NbTracks int `json:"nb_tracks"`
|
||||||
|
RecordType string `json:"record_type"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Copyright string `json:"copyright"`
|
||||||
|
Genres struct {
|
||||||
|
Data []deezerGenre `json:"data"`
|
||||||
|
} `json:"genres"`
|
||||||
|
Artist deezerArtist `json:"artist"`
|
||||||
|
Contributors []deezerArtist `json:"contributors"`
|
||||||
|
Tracks struct {
|
||||||
|
Data []deezerTrack `json:"data"`
|
||||||
|
} `json:"tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deezerArtistFull struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Picture string `json:"picture"`
|
||||||
|
PictureMedium string `json:"picture_medium"`
|
||||||
|
PictureBig string `json:"picture_big"`
|
||||||
|
PictureXL string `json:"picture_xl"`
|
||||||
|
NbFan int `json:"nb_fan"`
|
||||||
|
NbAlbum int `json:"nb_album"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deezerPlaylistFull struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Picture string `json:"picture"`
|
||||||
|
PictureMedium string `json:"picture_medium"`
|
||||||
|
PictureBig string `json:"picture_big"`
|
||||||
|
PictureXL string `json:"picture_xl"`
|
||||||
|
NbTracks int `json:"nb_tracks"`
|
||||||
|
Creator struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"creator"`
|
||||||
|
Tracks struct {
|
||||||
|
Data []deezerTrack `json:"data"`
|
||||||
|
} `json:"tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) getBestArtistImage(artist deezerArtist) string {
|
||||||
|
if artist.PictureXL != "" {
|
||||||
|
return artist.PictureXL
|
||||||
|
}
|
||||||
|
if artist.PictureBig != "" {
|
||||||
|
return artist.PictureBig
|
||||||
|
}
|
||||||
|
if artist.PictureMedium != "" {
|
||||||
|
return artist.PictureMedium
|
||||||
|
}
|
||||||
|
return artist.Picture
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) getBestArtistImageFull(artist deezerArtistFull) string {
|
||||||
|
if artist.PictureXL != "" {
|
||||||
|
return artist.PictureXL
|
||||||
|
}
|
||||||
|
if artist.PictureBig != "" {
|
||||||
|
return artist.PictureBig
|
||||||
|
}
|
||||||
|
if artist.PictureMedium != "" {
|
||||||
|
return artist.PictureMedium
|
||||||
|
}
|
||||||
|
return artist.Picture
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
|
||||||
|
if album.CoverXL != "" {
|
||||||
|
return album.CoverXL
|
||||||
|
}
|
||||||
|
if album.CoverBig != "" {
|
||||||
|
return album.CoverBig
|
||||||
|
}
|
||||||
|
if album.CoverMedium != "" {
|
||||||
|
return album.CoverMedium
|
||||||
|
}
|
||||||
|
return album.Cover
|
||||||
|
}
|
||||||
@@ -78,17 +78,6 @@ func TestDeezerClientWithFakeHTTP(t *testing.T) {
|
|||||||
t.Fatalf("cached artist = %#v/%v", cachedArtist, err)
|
t.Fatalf("cached artist = %#v/%v", cachedArtist, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
related, err := client.GetRelatedArtists(ctx, "deezer:301", 3)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetRelatedArtists: %v", err)
|
|
||||||
}
|
|
||||||
if len(related) != 1 || related[0].ID != "deezer:302" {
|
|
||||||
t.Fatalf("related = %#v", related)
|
|
||||||
}
|
|
||||||
if _, err := client.GetRelatedArtists(ctx, "", 0); err == nil {
|
|
||||||
t.Fatal("expected invalid related artist ID")
|
|
||||||
}
|
|
||||||
|
|
||||||
playlist, err := client.GetPlaylist(ctx, "401")
|
playlist, err := client.GetPlaylist(ctx, "401")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetPlaylist: %v", err)
|
t.Fatalf("GetPlaylist: %v", err)
|
||||||
|
|||||||
+34
-2
@@ -108,7 +108,7 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ext := strings.ToLower(filepath.Ext(path))
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
if ext != ".flac" {
|
if !isrcIndexExts[ext] {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +141,7 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
|
|||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for i := range tasks {
|
for i := range tasks {
|
||||||
isrcs[i] = strings.ToUpper(readFlacISRC(toParse[i].path))
|
isrcs[i] = strings.ToUpper(readFileISRC(toParse[i].path))
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -170,6 +170,38 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
|
|||||||
return idx
|
return idx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isrcIndexExts are the formats the download pipeline can produce; each has
|
||||||
|
// a native tag reader that stops at the metadata blocks.
|
||||||
|
var isrcIndexExts = map[string]bool{
|
||||||
|
".flac": true,
|
||||||
|
".mp3": true,
|
||||||
|
".m4a": true,
|
||||||
|
".ogg": true,
|
||||||
|
".opus": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFileISRC reads the ISRC tag using the native reader for the format.
|
||||||
|
// Returns "" for unsupported formats, unreadable files, or missing tags.
|
||||||
|
func readFileISRC(path string) string {
|
||||||
|
switch strings.ToLower(filepath.Ext(path)) {
|
||||||
|
case ".flac":
|
||||||
|
return readFlacISRC(path)
|
||||||
|
case ".mp3":
|
||||||
|
if meta, err := ReadID3Tags(path); err == nil && meta != nil {
|
||||||
|
return strings.TrimSpace(meta.ISRC)
|
||||||
|
}
|
||||||
|
case ".m4a":
|
||||||
|
if meta, err := ReadM4ATags(path); err == nil && meta != nil {
|
||||||
|
return strings.TrimSpace(meta.ISRC)
|
||||||
|
}
|
||||||
|
case ".ogg", ".opus":
|
||||||
|
if meta, err := ReadOggVorbisComments(path); err == nil && meta != nil {
|
||||||
|
return strings.TrimSpace(meta.ISRC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// readFlacISRC extracts the ISRC Vorbis comment from a FLAC file by walking
|
// readFlacISRC extracts the ISRC Vorbis comment from a FLAC file by walking
|
||||||
// the metadata block headers and reading only the VORBIS_COMMENT payload;
|
// the metadata block headers and reading only the VORBIS_COMMENT payload;
|
||||||
// picture and padding blocks are seeked past, never loaded. Returns "" when
|
// picture and padding blocks are seeked past, never loaded. Returns "" when
|
||||||
|
|||||||
+10
-11
@@ -63,6 +63,16 @@ func SetSongLinkNetworkOptions(allowHTTP, insecureTLS bool) {
|
|||||||
SetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
|
SetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTrackPlatformLinksJSON returns {"platforms": {platformID: url}} for a
|
||||||
|
// track, resolved via song.link (memory-cached; either ID may be empty).
|
||||||
|
func GetTrackPlatformLinksJSON(spotifyTrackID string, isrc string) (string, error) {
|
||||||
|
links, err := NewSongLinkClient().GetTrackPlatformLinks(spotifyTrackID, isrc)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return marshalJSONString(map[string]any{"platforms": links})
|
||||||
|
}
|
||||||
|
|
||||||
func SetDownloadDirectory(path string) error {
|
func SetDownloadDirectory(path string) error {
|
||||||
return setDownloadDir(path)
|
return setDownloadDir(path)
|
||||||
}
|
}
|
||||||
@@ -74,17 +84,6 @@ func AllowDownloadDir(path string) {
|
|||||||
AddAllowedDownloadDir(path)
|
AddAllowedDownloadDir(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func CheckDuplicate(outputDir, isrc string) (string, error) {
|
|
||||||
existingFile, exists := CheckISRCExists(outputDir, isrc)
|
|
||||||
|
|
||||||
result := map[string]any{
|
|
||||||
"exists": exists,
|
|
||||||
"filepath": existingFile,
|
|
||||||
}
|
|
||||||
|
|
||||||
return marshalJSONString(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func CheckDuplicatesBatch(outputDir, tracksJSON string) (string, error) {
|
func CheckDuplicatesBatch(outputDir, tracksJSON string) (string, error) {
|
||||||
return CheckFilesExistParallel(outputDir, tracksJSON)
|
return CheckFilesExistParallel(outputDir, tracksJSON)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,22 +17,6 @@ func GetTrackCacheSize() int {
|
|||||||
func ClearTrackIDCache() {
|
func ClearTrackIDCache() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetDeezerRelatedArtists(artistID string, limit int) (string, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
client := GetDeezerClient()
|
|
||||||
artists, err := client.GetRelatedArtists(ctx, artistID, limit)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := map[string]any{
|
|
||||||
"artists": artists,
|
|
||||||
}
|
|
||||||
return marshalJSONString(resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
|
func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@@ -383,12 +383,6 @@ func DownloadByStrategy(requestJSON string) (string, error) {
|
|||||||
return errorResponse("Extension providers are disabled; built-in download providers have been retired")
|
return errorResponse("Extension providers are disabled; built-in download providers have been retired")
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetDownloadProgress() string {
|
|
||||||
progress := getProgress()
|
|
||||||
jsonBytes, _ := json.Marshal(progress)
|
|
||||||
return string(jsonBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetAllDownloadProgress() string {
|
func GetAllDownloadProgress() string {
|
||||||
return GetMultiProgress()
|
return GetMultiProgress()
|
||||||
}
|
}
|
||||||
@@ -401,10 +395,6 @@ func InitItemProgress(itemID string) {
|
|||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func FinishItemProgress(itemID string) {
|
|
||||||
CompleteItemProgress(itemID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ClearItemProgress(itemID string) {
|
func ClearItemProgress(itemID string) {
|
||||||
RemoveItemProgress(itemID)
|
RemoveItemProgress(itemID)
|
||||||
}
|
}
|
||||||
@@ -445,18 +435,20 @@ func classifyDownloadErrorType(msg string) string {
|
|||||||
return "isp_blocked"
|
return "isp_blocked"
|
||||||
} else if strings.Contains(lowerMsg, "cancel") {
|
} else if strings.Contains(lowerMsg, "cancel") {
|
||||||
return "cancelled"
|
return "cancelled"
|
||||||
} else if strings.Contains(lowerMsg, "verify_required") ||
|
} else if strings.Contains(lowerMsg, "verification_required") ||
|
||||||
strings.Contains(lowerMsg, "verification_required") ||
|
|
||||||
strings.Contains(lowerMsg, "verification required") ||
|
|
||||||
strings.Contains(lowerMsg, "needs verification") ||
|
|
||||||
strings.Contains(lowerMsg, "session is not authenticated") ||
|
strings.Contains(lowerMsg, "session is not authenticated") ||
|
||||||
strings.Contains(lowerMsg, "signed session is not authenticated") ||
|
strings.Contains(lowerMsg, "signed session is not authenticated") ||
|
||||||
strings.Contains(lowerMsg, "signed session expired") ||
|
strings.Contains(lowerMsg, "signed session expired") {
|
||||||
strings.Contains(lowerMsg, "unauthorized") ||
|
|
||||||
strings.Contains(lowerMsg, "precondition required") ||
|
|
||||||
messageHasHTTPStatusCode(lowerMsg, "401") ||
|
|
||||||
messageHasHTTPStatusCode(lowerMsg, "428") {
|
|
||||||
return "verification_required"
|
return "verification_required"
|
||||||
|
} else if strings.Contains(lowerMsg, "byoa_provider_reauth_required") ||
|
||||||
|
strings.Contains(lowerMsg, "reauth_provider") {
|
||||||
|
return "provider_reauth_required"
|
||||||
|
} else if strings.Contains(lowerMsg, "request_auth_invalid") {
|
||||||
|
return "request_auth_invalid"
|
||||||
|
} else if strings.Contains(lowerMsg, "provider_auth_failed") {
|
||||||
|
return "provider_auth_failed"
|
||||||
|
} else if strings.Contains(lowerMsg, "provider_unavailable") {
|
||||||
|
return "provider_unavailable"
|
||||||
} else if strings.Contains(lowerMsg, "rate limit") ||
|
} else if strings.Contains(lowerMsg, "rate limit") ||
|
||||||
messageHasHTTPStatusCode(lowerMsg, "429") ||
|
messageHasHTTPStatusCode(lowerMsg, "429") ||
|
||||||
strings.Contains(lowerMsg, "too many requests") {
|
strings.Contains(lowerMsg, "too many requests") {
|
||||||
@@ -483,6 +475,26 @@ func classifyDownloadErrorType(msg string) string {
|
|||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isOutputStorageWriteFailure distinguishes an unwritable destination from a
|
||||||
|
// provider-specific failure. Provider fallback cannot repair the former: all
|
||||||
|
// providers receive the same output path, so continuing only delays the
|
||||||
|
// storage fallback and can replace the useful permission error with an
|
||||||
|
// unrelated error from the last provider.
|
||||||
|
func isOutputStorageWriteFailure(errorType, message string) bool {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(errorType), "permission") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
lowerMsg := strings.ToLower(strings.TrimSpace(message))
|
||||||
|
if lowerMsg == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(lowerMsg, "operation not permitted") ||
|
||||||
|
strings.Contains(lowerMsg, "permission denied") ||
|
||||||
|
strings.Contains(lowerMsg, "read-only file system") ||
|
||||||
|
strings.Contains(lowerMsg, "failed to create file") ||
|
||||||
|
strings.Contains(lowerMsg, "failed to create directory")
|
||||||
|
}
|
||||||
|
|
||||||
func messageHasHTTPStatusCode(lowerMsg, code string) bool {
|
func messageHasHTTPStatusCode(lowerMsg, code string) bool {
|
||||||
return strings.Contains(lowerMsg, "http "+code) ||
|
return strings.Contains(lowerMsg, "http "+code) ||
|
||||||
strings.Contains(lowerMsg, "http status "+code) ||
|
strings.Contains(lowerMsg, "http status "+code) ||
|
||||||
|
|||||||
@@ -432,9 +432,9 @@ func SetExtensionSettingsJSON(extensionID, settingsJSON string) error {
|
|||||||
return manager.InitializeExtension(extensionID, settings)
|
return manager.InitializeExtension(extensionID, settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) {
|
func SearchTracksWithMetadataProvidersJSON(query string, limit int, includeExtensions bool) (string, error) {
|
||||||
manager := getExtensionManager()
|
manager := getExtensionManager()
|
||||||
tracks, err := manager.SearchTracksWithExtensions(query, limit)
|
tracks, err := manager.SearchTracksWithMetadataProviders(query, limit, includeExtensions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -442,9 +442,9 @@ func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) {
|
|||||||
return marshalJSONString(tracks)
|
return marshalJSONString(tracks)
|
||||||
}
|
}
|
||||||
|
|
||||||
func SearchTracksWithMetadataProvidersJSON(query string, limit int, includeExtensions bool) (string, error) {
|
func SearchTracksWithMetadataProviderJSON(providerID, query string, limit int) (string, error) {
|
||||||
manager := getExtensionManager()
|
manager := getExtensionManager()
|
||||||
tracks, err := manager.SearchTracksWithMetadataProviders(query, limit, includeExtensions)
|
tracks, err := manager.SearchTracksWithMetadataProvider(providerID, query, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -512,7 +512,17 @@ func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
|
|||||||
preflightStartedAt := time.Now()
|
preflightStartedAt := time.Now()
|
||||||
verificationRequired, preflightErr := preflightExtensionDownloadSession(sessionProvider)
|
verificationRequired, preflightErr := preflightExtensionDownloadSession(sessionProvider)
|
||||||
if preflightErr != nil {
|
if preflightErr != nil {
|
||||||
GoLog("[DownloadWithExtensions] Signed-session preflight for %s was inconclusive after %s: %v\n", sessionProvider, time.Since(preflightStartedAt).Round(time.Millisecond), preflightErr)
|
message := fmt.Sprintf("Could not start verification for %s: %v", sessionProvider, preflightErr)
|
||||||
|
GoLog("[DownloadWithExtensions] Signed-session preflight for %s failed after %s: %v\n", sessionProvider, time.Since(preflightStartedAt).Round(time.Millisecond), preflightErr)
|
||||||
|
if req.ItemID != "" {
|
||||||
|
RemoveItemProgress(req.ItemID)
|
||||||
|
}
|
||||||
|
return marshalJSONString(&DownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: message,
|
||||||
|
ErrorType: classifyDownloadErrorType(message),
|
||||||
|
Service: sessionProvider,
|
||||||
|
})
|
||||||
} else if verificationRequired {
|
} else if verificationRequired {
|
||||||
GoLog("[DownloadWithExtensions] Signed-session verification required for %s after %s; skipping metadata preparation\n", sessionProvider, time.Since(preflightStartedAt).Round(time.Millisecond))
|
GoLog("[DownloadWithExtensions] Signed-session verification required for %s after %s; skipping metadata preparation\n", sessionProvider, time.Since(preflightStartedAt).Round(time.Millisecond))
|
||||||
cacheUnpreparedDownloadRequest(downloadPreparationKey(req), req)
|
cacheUnpreparedDownloadRequest(downloadPreparationKey(req), req)
|
||||||
@@ -566,7 +576,10 @@ func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
|
func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
|
||||||
req := ensureExtensionPendingAuthRequest(extensionID)
|
req, err := ensureExtensionPendingAuthRequest(extensionID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
if req == nil {
|
if req == nil {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
@@ -580,15 +593,15 @@ func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
|
|||||||
return marshalJSONString(result)
|
return marshalJSONString(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureExtensionPendingAuthRequest(extensionID string) *PendingAuthRequest {
|
func ensureExtensionPendingAuthRequest(extensionID string) (*PendingAuthRequest, error) {
|
||||||
extensionID = strings.TrimSpace(extensionID)
|
extensionID = strings.TrimSpace(extensionID)
|
||||||
if extensionID == "" {
|
if extensionID == "" {
|
||||||
return nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if req := GetPendingAuthRequest(extensionID); req != nil {
|
if req := GetPendingAuthRequest(extensionID); req != nil {
|
||||||
if time.Since(req.CreatedAt) < pendingAuthRequestTTL {
|
if time.Since(req.CreatedAt) < pendingAuthRequestTTL {
|
||||||
return req
|
return req, nil
|
||||||
}
|
}
|
||||||
// The cached challenge is stale (e.g. verification was requested
|
// The cached challenge is stale (e.g. verification was requested
|
||||||
// while the app was backgrounded and never completed); serving it
|
// while the app was backgrounded and never completed); serving it
|
||||||
@@ -599,25 +612,24 @@ func ensureExtensionPendingAuthRequest(extensionID string) *PendingAuthRequest {
|
|||||||
manager := getExtensionManager()
|
manager := getExtensionManager()
|
||||||
ext, err := manager.GetExtension(extensionID)
|
ext, err := manager.GetExtension(extensionID)
|
||||||
if err != nil || ext == nil || !ext.Enabled || ext.Manifest == nil || ext.Manifest.SignedSession == nil {
|
if err != nil || ext == nil || !ext.Enabled || ext.Manifest == nil || ext.Manifest.SignedSession == nil {
|
||||||
return nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ext.ensureRuntimeReady(); err != nil || ext.runtime == nil {
|
if err := ext.ensureRuntimeReady(); err != nil {
|
||||||
return nil
|
return nil, err
|
||||||
|
}
|
||||||
|
if ext.runtime == nil {
|
||||||
|
return nil, fmt.Errorf("extension '%s' runtime is unavailable", extensionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
config := signedSessionConfigWithDefaults(ext.Manifest.SignedSession)
|
verificationRequired, err := ext.runtime.preflightSignedSession()
|
||||||
if config.Namespace == "" || config.BaseURL == "" {
|
if err != nil {
|
||||||
return nil
|
return nil, err
|
||||||
}
|
}
|
||||||
if record, err := ext.runtime.loadSignedSession(config); err == nil {
|
if !verificationRequired {
|
||||||
record.SessionID = ""
|
return nil, nil
|
||||||
record.SessionSecret = ""
|
|
||||||
record.ExpiresAt = ""
|
|
||||||
_ = ext.runtime.saveSignedSession(config, record)
|
|
||||||
}
|
}
|
||||||
ext.runtime.startSignedSessionVerification(config, "pending-auth-request")
|
return GetPendingAuthRequest(extensionID), nil
|
||||||
return GetPendingAuthRequest(extensionID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetExtensionAuthCodeByID(extensionID, authCode string) {
|
func SetExtensionAuthCodeByID(extensionID, authCode string) {
|
||||||
@@ -771,47 +783,7 @@ func CustomSearchWithExtensionJSONWithRequestID(extensionID, query string, optio
|
|||||||
|
|
||||||
result := make([]map[string]any, len(tracks))
|
result := make([]map[string]any, len(tracks))
|
||||||
for i, track := range tracks {
|
for i, track := range tracks {
|
||||||
result[i] = map[string]any{
|
result[i] = normalizeExtensionTrackMetadataMap(track, "", 0)
|
||||||
"id": track.ID,
|
|
||||||
"name": track.Name,
|
|
||||||
"artists": track.Artists,
|
|
||||||
"album_name": track.AlbumName,
|
|
||||||
"album_artist": track.AlbumArtist,
|
|
||||||
"duration_ms": track.DurationMS,
|
|
||||||
"images": track.ResolvedCoverURL(),
|
|
||||||
"preview_url": track.PreviewURL,
|
|
||||||
"release_date": track.ReleaseDate,
|
|
||||||
"track_number": track.TrackNumber,
|
|
||||||
"total_tracks": track.TotalTracks,
|
|
||||||
"disc_number": track.DiscNumber,
|
|
||||||
"total_discs": track.TotalDiscs,
|
|
||||||
"isrc": track.ISRC,
|
|
||||||
"provider_id": track.ProviderID,
|
|
||||||
"item_type": track.ItemType,
|
|
||||||
"album_type": track.AlbumType,
|
|
||||||
"composer": track.Composer,
|
|
||||||
"audio_quality": track.AudioQuality,
|
|
||||||
"audio_modes": track.AudioModes,
|
|
||||||
"explicit": track.Explicit,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return marshalJSONString(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetSearchProvidersJSON() (string, error) {
|
|
||||||
manager := getExtensionManager()
|
|
||||||
providers := manager.GetSearchProviders()
|
|
||||||
|
|
||||||
result := make([]map[string]any, 0, len(providers))
|
|
||||||
for _, p := range providers {
|
|
||||||
result = append(result, map[string]any{
|
|
||||||
"id": p.extension.ID,
|
|
||||||
"display_name": p.extension.Manifest.DisplayName,
|
|
||||||
"placeholder": p.extension.Manifest.SearchBehavior.Placeholder,
|
|
||||||
"primary": p.extension.Manifest.SearchBehavior.Primary,
|
|
||||||
"icon": p.extension.Manifest.SearchBehavior.Icon,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return marshalJSONString(result)
|
return marshalJSONString(result)
|
||||||
@@ -841,51 +813,13 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if result.Track != nil {
|
if result.Track != nil {
|
||||||
response["track"] = map[string]any{
|
response["track"] = normalizeExtensionTrackMetadataMap(*result.Track, "", 0)
|
||||||
"id": result.Track.ID,
|
|
||||||
"name": result.Track.Name,
|
|
||||||
"artists": result.Track.Artists,
|
|
||||||
"album_name": result.Track.AlbumName,
|
|
||||||
"album_artist": result.Track.AlbumArtist,
|
|
||||||
"duration_ms": result.Track.DurationMS,
|
|
||||||
"images": result.Track.ResolvedCoverURL(),
|
|
||||||
"preview_url": result.Track.PreviewURL,
|
|
||||||
"release_date": result.Track.ReleaseDate,
|
|
||||||
"track_number": result.Track.TrackNumber,
|
|
||||||
"total_tracks": result.Track.TotalTracks,
|
|
||||||
"disc_number": result.Track.DiscNumber,
|
|
||||||
"total_discs": result.Track.TotalDiscs,
|
|
||||||
"isrc": result.Track.ISRC,
|
|
||||||
"provider_id": result.Track.ProviderID,
|
|
||||||
"composer": result.Track.Composer,
|
|
||||||
"explicit": result.Track.Explicit,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(result.Tracks) > 0 {
|
if len(result.Tracks) > 0 {
|
||||||
tracks := make([]map[string]any, len(result.Tracks))
|
tracks := make([]map[string]any, len(result.Tracks))
|
||||||
for i, track := range result.Tracks {
|
for i, track := range result.Tracks {
|
||||||
tracks[i] = map[string]any{
|
tracks[i] = normalizeExtensionTrackMetadataMap(track, "", 0)
|
||||||
"id": track.ID,
|
|
||||||
"name": track.Name,
|
|
||||||
"artists": track.Artists,
|
|
||||||
"album_name": track.AlbumName,
|
|
||||||
"album_artist": track.AlbumArtist,
|
|
||||||
"duration_ms": track.DurationMS,
|
|
||||||
"images": track.ResolvedCoverURL(),
|
|
||||||
"preview_url": track.PreviewURL,
|
|
||||||
"release_date": track.ReleaseDate,
|
|
||||||
"track_number": track.TrackNumber,
|
|
||||||
"total_tracks": track.TotalTracks,
|
|
||||||
"disc_number": track.DiscNumber,
|
|
||||||
"total_discs": track.TotalDiscs,
|
|
||||||
"isrc": track.ISRC,
|
|
||||||
"provider_id": track.ProviderID,
|
|
||||||
"item_type": track.ItemType,
|
|
||||||
"album_type": track.AlbumType,
|
|
||||||
"composer": track.Composer,
|
|
||||||
"explicit": track.Explicit,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
response["tracks"] = tracks
|
response["tracks"] = tracks
|
||||||
}
|
}
|
||||||
@@ -964,26 +898,7 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
if len(result.Artist.TopTracks) > 0 {
|
if len(result.Artist.TopTracks) > 0 {
|
||||||
topTracks := make([]map[string]any, len(result.Artist.TopTracks))
|
topTracks := make([]map[string]any, len(result.Artist.TopTracks))
|
||||||
for i, track := range result.Artist.TopTracks {
|
for i, track := range result.Artist.TopTracks {
|
||||||
topTracks[i] = map[string]any{
|
topTracks[i] = normalizeExtensionTrackMetadataMap(track, "", 0)
|
||||||
"id": track.ID,
|
|
||||||
"name": track.Name,
|
|
||||||
"artists": track.Artists,
|
|
||||||
"album_name": track.AlbumName,
|
|
||||||
"album_artist": track.AlbumArtist,
|
|
||||||
"duration_ms": track.DurationMS,
|
|
||||||
"images": track.ResolvedCoverURL(),
|
|
||||||
"preview_url": track.PreviewURL,
|
|
||||||
"release_date": track.ReleaseDate,
|
|
||||||
"track_number": track.TrackNumber,
|
|
||||||
"total_tracks": track.TotalTracks,
|
|
||||||
"disc_number": track.DiscNumber,
|
|
||||||
"total_discs": track.TotalDiscs,
|
|
||||||
"isrc": track.ISRC,
|
|
||||||
"provider_id": track.ProviderID,
|
|
||||||
"spotify_id": track.SpotifyID,
|
|
||||||
"composer": track.Composer,
|
|
||||||
"explicit": track.Explicit,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
artistResponse["top_tracks"] = topTracks
|
artistResponse["top_tracks"] = topTracks
|
||||||
}
|
}
|
||||||
@@ -1003,39 +918,6 @@ func FindURLHandlerJSON(url string) string {
|
|||||||
return handler.extension.ID
|
return handler.extension.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetURLHandlersJSON() (string, error) {
|
|
||||||
manager := getExtensionManager()
|
|
||||||
handlers := manager.GetURLHandlers()
|
|
||||||
|
|
||||||
result := make([]map[string]any, 0, len(handlers))
|
|
||||||
for _, h := range handlers {
|
|
||||||
result = append(result, map[string]any{
|
|
||||||
"id": h.extension.ID,
|
|
||||||
"display_name": h.extension.Manifest.DisplayName,
|
|
||||||
"patterns": h.extension.Manifest.URLHandler.Patterns,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return marshalJSONString(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func RunPostProcessingJSON(filePath, metadataJSON string) (string, error) {
|
|
||||||
var metadata map[string]any
|
|
||||||
if metadataJSON != "" {
|
|
||||||
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
|
|
||||||
metadata = make(map[string]any)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
manager := getExtensionManager()
|
|
||||||
result, err := manager.RunPostProcessing(filePath, metadata)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return marshalJSONString(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func RunPostProcessingV2JSON(inputJSON, metadataJSON string) (string, error) {
|
func RunPostProcessingV2JSON(inputJSON, metadataJSON string) (string, error) {
|
||||||
var metadata map[string]any
|
var metadata map[string]any
|
||||||
if metadataJSON != "" {
|
if metadataJSON != "" {
|
||||||
@@ -1060,33 +942,6 @@ func RunPostProcessingV2JSON(inputJSON, metadataJSON string) (string, error) {
|
|||||||
return marshalJSONString(result)
|
return marshalJSONString(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetPostProcessingProvidersJSON() (string, error) {
|
|
||||||
manager := getExtensionManager()
|
|
||||||
providers := manager.GetPostProcessingProviders()
|
|
||||||
|
|
||||||
result := make([]map[string]any, 0, len(providers))
|
|
||||||
for _, p := range providers {
|
|
||||||
hooks := make([]map[string]any, 0)
|
|
||||||
for _, h := range p.extension.Manifest.GetPostProcessingHooks() {
|
|
||||||
hooks = append(hooks, map[string]any{
|
|
||||||
"id": h.ID,
|
|
||||||
"name": h.Name,
|
|
||||||
"description": h.Description,
|
|
||||||
"default_enabled": h.DefaultEnabled,
|
|
||||||
"supported_formats": h.SupportedFormats,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
result = append(result, map[string]any{
|
|
||||||
"id": p.extension.ID,
|
|
||||||
"display_name": p.extension.Manifest.DisplayName,
|
|
||||||
"hooks": hooks,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return marshalJSONString(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Duration) (string, error) {
|
func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Duration) (string, error) {
|
||||||
return callExtensionFunctionJSONWithRequestID(extensionID, functionName, timeout, "")
|
return callExtensionFunctionJSONWithRequestID(extensionID, functionName, timeout, "")
|
||||||
}
|
}
|
||||||
@@ -1165,10 +1020,6 @@ func GetExtensionHomeFeedJSONWithRequestID(extensionID, requestID string) (strin
|
|||||||
return callExtensionFunctionJSONWithRequestID(extensionID, "getHomeFeed", 60*time.Second, requestID)
|
return callExtensionFunctionJSONWithRequestID(extensionID, "getHomeFeed", 60*time.Second, requestID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) {
|
|
||||||
return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
func CancelExtensionRequestJSON(requestID string) {
|
func CancelExtensionRequestJSON(requestID string) {
|
||||||
cancelExtensionRequest(requestID)
|
cancelExtensionRequest(requestID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,29 +7,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (string, error) {
|
|
||||||
client := NewLyricsClient()
|
|
||||||
durationSec := float64(durationMs) / 1000.0
|
|
||||||
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
result := map[string]any{
|
|
||||||
"success": true,
|
|
||||||
"source": lyrics.Source,
|
|
||||||
"sync_type": lyrics.SyncType,
|
|
||||||
"lines": lyrics.Lines,
|
|
||||||
"instrumental": lyrics.Instrumental,
|
|
||||||
}
|
|
||||||
|
|
||||||
return marshalJSONString(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
|
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
|
||||||
if filePath != "" {
|
if filePath != "" {
|
||||||
lyrics, err := ExtractLyrics(filePath)
|
lyrics, err := ExtractLyrics(filePath)
|
||||||
if err == nil && lyrics != "" {
|
if err == nil && rawLyricsHasUsableContent(lyrics) {
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
return "", nil
|
return "", nil
|
||||||
@@ -53,7 +34,7 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, dura
|
|||||||
func GetLyricsLRCWithSource(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
|
func GetLyricsLRCWithSource(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
|
||||||
if filePath != "" {
|
if filePath != "" {
|
||||||
lyrics, err := ExtractLyrics(filePath)
|
lyrics, err := ExtractLyrics(filePath)
|
||||||
if err == nil && lyrics != "" {
|
if err == nil && rawLyricsHasUsableContent(lyrics) {
|
||||||
source := extractLyricsSourceFromLRC(lyrics)
|
source := extractLyricsSourceFromLRC(lyrics)
|
||||||
if source == "" {
|
if source == "" {
|
||||||
source = "Embedded"
|
source = "Embedded"
|
||||||
@@ -62,7 +43,7 @@ func GetLyricsLRCWithSource(spotifyID, trackName, artistName string, filePath st
|
|||||||
"lyrics": lyrics,
|
"lyrics": lyrics,
|
||||||
"source": source,
|
"source": source,
|
||||||
"sync_type": "EMBEDDED",
|
"sync_type": "EMBEDDED",
|
||||||
"instrumental": false,
|
"instrumental": isInstrumentalLyricsMarker(lyrics),
|
||||||
}
|
}
|
||||||
return marshalJSONString(result)
|
return marshalJSONString(result)
|
||||||
}
|
}
|
||||||
@@ -119,7 +100,7 @@ func FetchAndSaveLyrics(trackName, artistName, spotifyID string, durationMs int6
|
|||||||
// use those directly instead of making redundant network requests.
|
// use those directly instead of making redundant network requests.
|
||||||
if audioFilePath != "" {
|
if audioFilePath != "" {
|
||||||
existing, err := ExtractLyrics(audioFilePath)
|
existing, err := ExtractLyrics(audioFilePath)
|
||||||
if err == nil && strings.TrimSpace(existing) != "" {
|
if err == nil && rawLyricsHasUsableContent(existing) {
|
||||||
if err := os.WriteFile(outputPath, []byte(existing), 0644); err != nil {
|
if err := os.WriteFile(outputPath, []byte(existing), 0644); err != nil {
|
||||||
return fmt.Errorf("failed to write LRC file: %w", err)
|
return fmt.Errorf("failed to write LRC file: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,6 +144,13 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
}
|
}
|
||||||
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
||||||
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
|
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
|
||||||
|
// Average bitrate from file size: helps spot lossy audio
|
||||||
|
// repackaged as "24-bit" FLAC (real hi-res sits well above
|
||||||
|
// ~1500 kbps, upconverted files far below).
|
||||||
|
durationSec := float64(quality.TotalSamples) / float64(quality.SampleRate)
|
||||||
|
if info, statErr := os.Stat(filePath); statErr == nil && info.Size() > 0 && durationSec > 0 {
|
||||||
|
result["bitrate"] = int(float64(info.Size()) * 8 / durationSec / 1000)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,6 +171,10 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
}
|
}
|
||||||
if quality.Bitrate > 0 && !isLosslessLibraryFormat(fmt.Sprint(result["format"])) {
|
if quality.Bitrate > 0 && !isLosslessLibraryFormat(fmt.Sprint(result["format"])) {
|
||||||
result["bitrate"] = quality.Bitrate
|
result["bitrate"] = quality.Bitrate
|
||||||
|
} else if quality.Duration > 0 {
|
||||||
|
if info, statErr := os.Stat(filePath); statErr == nil && info.Size() > 0 {
|
||||||
|
result["bitrate"] = int(float64(info.Size()) * 8 / float64(quality.Duration) / 1000)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if isMp3 {
|
} else if isMp3 {
|
||||||
@@ -555,6 +566,11 @@ func ExtractCoverToFile(audioPath string, outputPath string) error {
|
|||||||
coverData, _, err = extractMP3CoverArt(audioPath)
|
coverData, _, err = extractMP3CoverArt(audioPath)
|
||||||
} else if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
|
} else if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
|
||||||
coverData, _, err = extractOggCoverArt(audioPath)
|
coverData, _, err = extractOggCoverArt(audioPath)
|
||||||
|
} else if strings.HasSuffix(lower, ".wav") ||
|
||||||
|
strings.HasSuffix(lower, ".aiff") ||
|
||||||
|
strings.HasSuffix(lower, ".aif") ||
|
||||||
|
strings.HasSuffix(lower, ".aifc") {
|
||||||
|
coverData, _, err = extractWAVAIFFCover(audioPath)
|
||||||
} else {
|
} else {
|
||||||
return fmt.Errorf("unsupported audio format for cover extraction")
|
return fmt.Errorf("unsupported audio format for cover extraction")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/text/cases"
|
"golang.org/x/text/cases"
|
||||||
@@ -14,6 +15,62 @@ import (
|
|||||||
|
|
||||||
const musicBrainzAPIBase = "https://musicbrainz.org/ws/2"
|
const musicBrainzAPIBase = "https://musicbrainz.org/ws/2"
|
||||||
|
|
||||||
|
// MusicBrainz lookups run in the per-track finalize stage and album batches
|
||||||
|
// repeat the same ISRC-adjacent questions; each miss costs up to 3 attempts
|
||||||
|
// with 2s sleeps. Positive results are stable (6h TTL); misses/errors expire
|
||||||
|
// quickly (10min) so a manual re-enrich can retry soon.
|
||||||
|
const (
|
||||||
|
musicBrainzCachePositiveTTL = 6 * time.Hour
|
||||||
|
musicBrainzCacheNegativeTTL = 10 * time.Minute
|
||||||
|
musicBrainzCacheMaxEntries = 256
|
||||||
|
)
|
||||||
|
|
||||||
|
type musicBrainzCacheEntry struct {
|
||||||
|
value string
|
||||||
|
err error
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
musicBrainzCacheMu sync.Mutex
|
||||||
|
musicBrainzCache = make(map[string]musicBrainzCacheEntry)
|
||||||
|
)
|
||||||
|
|
||||||
|
func musicBrainzCached(key string, fetch func() (string, error)) (string, error) {
|
||||||
|
musicBrainzCacheMu.Lock()
|
||||||
|
if entry, ok := musicBrainzCache[key]; ok && time.Now().Before(entry.expiresAt) {
|
||||||
|
musicBrainzCacheMu.Unlock()
|
||||||
|
return entry.value, entry.err
|
||||||
|
}
|
||||||
|
musicBrainzCacheMu.Unlock()
|
||||||
|
|
||||||
|
value, err := fetch()
|
||||||
|
|
||||||
|
ttl := musicBrainzCachePositiveTTL
|
||||||
|
if err != nil || value == "" {
|
||||||
|
ttl = musicBrainzCacheNegativeTTL
|
||||||
|
}
|
||||||
|
musicBrainzCacheMu.Lock()
|
||||||
|
if len(musicBrainzCache) >= musicBrainzCacheMaxEntries {
|
||||||
|
now := time.Now()
|
||||||
|
for k, e := range musicBrainzCache {
|
||||||
|
if now.After(e.expiresAt) {
|
||||||
|
delete(musicBrainzCache, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(musicBrainzCache) >= musicBrainzCacheMaxEntries {
|
||||||
|
musicBrainzCache = make(map[string]musicBrainzCacheEntry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
musicBrainzCache[key] = musicBrainzCacheEntry{
|
||||||
|
value: value,
|
||||||
|
err: err,
|
||||||
|
expiresAt: time.Now().Add(ttl),
|
||||||
|
}
|
||||||
|
musicBrainzCacheMu.Unlock()
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
|
||||||
type musicBrainzTag struct {
|
type musicBrainzTag struct {
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -170,33 +227,40 @@ func fetchMusicBrainzRecordingByISRC(isrc string, inc string, payload any) (stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
func FetchMusicBrainzAlbumArtistByISRC(isrc string, albumName string) (string, error) {
|
func FetchMusicBrainzAlbumArtistByISRC(isrc string, albumName string) (string, error) {
|
||||||
var payload musicBrainzAlbumArtistResponse
|
key := "albumartist\x00" + strings.ToUpper(strings.TrimSpace(isrc)) +
|
||||||
normalizedISRC, err := fetchMusicBrainzRecordingByISRC(isrc, "releases+artist-credits", &payload)
|
"\x00" + strings.ToLower(strings.TrimSpace(albumName))
|
||||||
if err != nil {
|
return musicBrainzCached(key, func() (string, error) {
|
||||||
return "", err
|
var payload musicBrainzAlbumArtistResponse
|
||||||
}
|
normalizedISRC, err := fetchMusicBrainzRecordingByISRC(isrc, "releases+artist-credits", &payload)
|
||||||
for _, recording := range payload.Recordings {
|
if err != nil {
|
||||||
if albumArtist := selectMusicBrainzAlbumArtist(recording.Releases, albumName); albumArtist != "" {
|
return "", err
|
||||||
return albumArtist, nil
|
}
|
||||||
|
for _, recording := range payload.Recordings {
|
||||||
|
if albumArtist := selectMusicBrainzAlbumArtist(recording.Releases, albumName); albumArtist != "" {
|
||||||
|
return albumArtist, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("no MusicBrainz album artist found for ISRC: %s", normalizedISRC)
|
return "", fmt.Errorf("no MusicBrainz album artist found for ISRC: %s", normalizedISRC)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchMusicBrainzGenreByISRC(isrc string) (string, error) {
|
func FetchMusicBrainzGenreByISRC(isrc string) (string, error) {
|
||||||
var payload musicBrainzRecordingResponse
|
key := "genre\x00" + strings.ToUpper(strings.TrimSpace(isrc))
|
||||||
normalizedISRC, err := fetchMusicBrainzRecordingByISRC(isrc, "tags", &payload)
|
return musicBrainzCached(key, func() (string, error) {
|
||||||
if err != nil {
|
var payload musicBrainzRecordingResponse
|
||||||
return "", err
|
normalizedISRC, err := fetchMusicBrainzRecordingByISRC(isrc, "tags", &payload)
|
||||||
}
|
if err != nil {
|
||||||
if len(payload.Recordings) == 0 {
|
return "", err
|
||||||
return "", fmt.Errorf("no recordings found for ISRC: %s", normalizedISRC)
|
}
|
||||||
}
|
if len(payload.Recordings) == 0 {
|
||||||
|
return "", fmt.Errorf("no recordings found for ISRC: %s", normalizedISRC)
|
||||||
|
}
|
||||||
|
|
||||||
genre := formatMusicBrainzGenre(payload.Recordings[0].Tags)
|
genre := formatMusicBrainzGenre(payload.Recordings[0].Tags)
|
||||||
if genre == "" {
|
if genre == "" {
|
||||||
return "", fmt.Errorf("no MusicBrainz genre tags found for ISRC: %s", normalizedISRC)
|
return "", fmt.Errorf("no MusicBrainz genre tags found for ISRC: %s", normalizedISRC)
|
||||||
}
|
}
|
||||||
return genre, nil
|
return genre, nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ type reEnrichRequest struct {
|
|||||||
DurationMs int64 `json:"duration_ms"`
|
DurationMs int64 `json:"duration_ms"`
|
||||||
SearchOnline bool `json:"search_online"`
|
SearchOnline bool `json:"search_online"`
|
||||||
UpdateFields []string `json:"update_fields,omitempty"`
|
UpdateFields []string `json:"update_fields,omitempty"`
|
||||||
|
// ReplaceReleaseMetadata lets a deliberate single-file re-enrich action
|
||||||
|
// repair a stale album identity (for example, a playlist name stored as
|
||||||
|
// ALBUM). Batch and older callers keep the conservative mismatch guard.
|
||||||
|
ReplaceReleaseMetadata bool `json:"replace_release_metadata,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// shouldUpdateField returns true if the given field group should be updated.
|
// shouldUpdateField returns true if the given field group should be updated.
|
||||||
@@ -92,10 +96,14 @@ func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sameRelease := reEnrichSameRelease(req.AlbumName, track.AlbumName)
|
albumMatches := reEnrichSameRelease(req.AlbumName, track.AlbumName)
|
||||||
|
sameRelease := req.ReplaceReleaseMetadata || albumMatches
|
||||||
if !sameRelease {
|
if !sameRelease {
|
||||||
GoLog("[ReEnrich] Candidate album %q differs from file album %q; keeping release identity (album, cover, positions, date)\n",
|
GoLog("[ReEnrich] Candidate album %q differs from file album %q; keeping release identity (album, cover, positions, date)\n",
|
||||||
track.AlbumName, req.AlbumName)
|
track.AlbumName, req.AlbumName)
|
||||||
|
} else if req.ReplaceReleaseMetadata && !albumMatches {
|
||||||
|
GoLog("[ReEnrich] Candidate album %q differs from file album %q; replacing release identity as requested\n",
|
||||||
|
track.AlbumName, req.AlbumName)
|
||||||
}
|
}
|
||||||
|
|
||||||
if track.SpotifyID != "" {
|
if track.SpotifyID != "" {
|
||||||
|
|||||||
@@ -20,9 +20,6 @@ func TestLyricsExportWrappersWithoutNetwork(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if jsonText, err := FetchLyrics("spotify-1", "Song Instrumental", "Artist", 180000); err != nil || !strings.Contains(jsonText, `"instrumental":true`) {
|
|
||||||
t.Fatalf("FetchLyrics instrumental = %q/%v", jsonText, err)
|
|
||||||
}
|
|
||||||
if lrc, err := GetLyricsLRC("spotify-1", "Song Instrumental", "Artist", "", 180000); err != nil || lrc != "[instrumental:true]" {
|
if lrc, err := GetLyricsLRC("spotify-1", "Song Instrumental", "Artist", "", 180000); err != nil || lrc != "[instrumental:true]" {
|
||||||
t.Fatalf("GetLyricsLRC instrumental = %q/%v", lrc, err)
|
t.Fatalf("GetLyricsLRC instrumental = %q/%v", lrc, err)
|
||||||
}
|
}
|
||||||
@@ -51,6 +48,36 @@ func TestLyricsExportWrappersWithoutNetwork(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLyricsExportWrappersRejectMetadataOnlySidecar(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
audioPath := filepath.Join(dir, "metadata-only.mp3")
|
||||||
|
if err := os.WriteFile(audioPath, []byte("audio"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
metadataOnly := "[ti:Title]\n[ar:Artist]\n[al:Album]\n[by:SpotiFLAC Mobile]"
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "metadata-only.lrc"), []byte(metadataOnly), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rawLyricsHasUsableContent(metadataOnly) {
|
||||||
|
t.Fatal("metadata-only LRC must not be considered usable")
|
||||||
|
}
|
||||||
|
if !rawLyricsHasUsableContent("[00:01.00]Actual lyric") {
|
||||||
|
t.Fatal("timed lyric must be considered usable")
|
||||||
|
}
|
||||||
|
if !rawLyricsHasUsableContent("[instrumental:true]") {
|
||||||
|
t.Fatal("instrumental marker must be considered usable")
|
||||||
|
}
|
||||||
|
|
||||||
|
if lrc, err := GetLyricsLRC("", "", "", audioPath, 0); err != nil || lrc != "" {
|
||||||
|
t.Fatalf("GetLyricsLRC metadata-only sidecar = %q/%v", lrc, err)
|
||||||
|
}
|
||||||
|
if jsonText, err := GetLyricsLRCWithSource("", "", "", audioPath, 0); err != nil ||
|
||||||
|
!strings.Contains(jsonText, `"lyrics":""`) || strings.Contains(jsonText, `"source":"Embedded"`) {
|
||||||
|
t.Fatalf("GetLyricsLRCWithSource metadata-only sidecar = %q/%v", jsonText, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSongLinkExportWrappersWithFakeClient(t *testing.T) {
|
func TestSongLinkExportWrappersWithFakeClient(t *testing.T) {
|
||||||
origClient := globalSongLinkClient
|
origClient := globalSongLinkClient
|
||||||
origRetryConfig := songLinkRetryConfig
|
origRetryConfig := songLinkRetryConfig
|
||||||
@@ -112,6 +139,19 @@ func TestSongLinkExportWrappersWithFakeClient(t *testing.T) {
|
|||||||
t.Fatal("songLinkExtractDeezerTrackID mismatch")
|
t.Fatal("songLinkExtractDeezerTrackID mismatch")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if linksJSON, err := GetTrackPlatformLinksJSON("spotify-1", ""); err != nil ||
|
||||||
|
!strings.Contains(linksJSON, `"tidal":"https://listen.tidal.com/track/202"`) ||
|
||||||
|
!strings.Contains(linksJSON, `"spotify":`) {
|
||||||
|
t.Fatalf("GetTrackPlatformLinksJSON = %q/%v", linksJSON, err)
|
||||||
|
}
|
||||||
|
// Second call must come from the links cache, not a new request.
|
||||||
|
if cached, hit, cachedErr := trackPlatformLinksCacheLookup(GetSongLinkRegion() + "|spotify:spotify-1"); !hit || cachedErr || cached["tidal"] == "" {
|
||||||
|
t.Fatalf("trackPlatformLinksCacheLookup = %#v hit=%v err=%v", cached, hit, cachedErr)
|
||||||
|
}
|
||||||
|
if _, err := GetTrackPlatformLinksJSON("", ""); err == nil {
|
||||||
|
t.Fatal("GetTrackPlatformLinksJSON with empty IDs should error")
|
||||||
|
}
|
||||||
|
|
||||||
deezerClient = &DeezerClient{
|
deezerClient = &DeezerClient{
|
||||||
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
body := fakeDeezerResponse(req.URL.Path, req.URL.RawQuery)
|
body := fakeDeezerResponse(req.URL.Path, req.URL.RawQuery)
|
||||||
|
|||||||
@@ -33,9 +33,8 @@ func TestDownloadErrorClassificationPrioritizesRateLimit(t *testing.T) {
|
|||||||
|
|
||||||
func TestDownloadErrorClassificationDetectsVerificationRequired(t *testing.T) {
|
func TestDownloadErrorClassificationDetectsVerificationRequired(t *testing.T) {
|
||||||
cases := []string{
|
cases := []string{
|
||||||
"HTTP 401 for /tickets",
|
"verification_required: canonical gateway challenge",
|
||||||
"HTTP status 428: precondition required",
|
"signed session expired",
|
||||||
"Verification required",
|
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
if got := classifyDownloadErrorType(tc); got != "verification_required" {
|
if got := classifyDownloadErrorType(tc); got != "verification_required" {
|
||||||
@@ -44,6 +43,76 @@ func TestDownloadErrorClassificationDetectsVerificationRequired(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDownloadErrorClassificationDoesNotInferVerificationFromHTTPStatus(t *testing.T) {
|
||||||
|
cases := []string{
|
||||||
|
"HTTP 401 for /tickets",
|
||||||
|
"HTTP 403 forbidden",
|
||||||
|
"HTTP status 428: precondition required",
|
||||||
|
"Provider returned unauthorized",
|
||||||
|
"VERIFY_REQUIRED without canonical origin and action",
|
||||||
|
"Verification required without a typed contract",
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := classifyDownloadErrorType(tc); got == "verification_required" {
|
||||||
|
t.Fatalf("classifyDownloadErrorType(%q) inferred verification from an ambiguous status", tc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadErrorClassificationPreservesProviderContracts(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
"PROVIDER_AUTH_FAILED": "provider_auth_failed",
|
||||||
|
"PROVIDER_UNAVAILABLE": "provider_unavailable",
|
||||||
|
"REQUEST_AUTH_INVALID": "request_auth_invalid",
|
||||||
|
"BYOA_PROVIDER_REAUTH_REQUIRED action": "provider_reauth_required",
|
||||||
|
}
|
||||||
|
for message, want := range tests {
|
||||||
|
if got := classifyDownloadErrorType(message); got != want {
|
||||||
|
t.Fatalf("classifyDownloadErrorType(%q) = %q, want %q", message, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutputStorageWriteFailureDetection(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
errorType string
|
||||||
|
message string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "typed permission failure",
|
||||||
|
errorType: "permission",
|
||||||
|
message: "backend omitted details",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "android operation not permitted",
|
||||||
|
message: "failed to create file: open /storage/song.partial: operation not permitted",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "read only destination",
|
||||||
|
message: "open /music/song.flac: read-only file system",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "provider API error",
|
||||||
|
errorType: "api_error",
|
||||||
|
message: "HTTP 404 for /download",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := isOutputStorageWriteFailure(tc.errorType, tc.message); got != tc.want {
|
||||||
|
t.Fatalf("isOutputStorageWriteFailure(%q, %q) = %v, want %v", tc.errorType, tc.message, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetProviderMetadataPrefersEnabledDeezerExtension(t *testing.T) {
|
func TestGetProviderMetadataPrefersEnabledDeezerExtension(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
if err := InitExtensionSystem(filepath.Join(dir, "extensions"), filepath.Join(dir, "data")); err != nil {
|
if err := InitExtensionSystem(filepath.Join(dir, "extensions"), filepath.Join(dir, "data")); err != nil {
|
||||||
@@ -104,6 +173,40 @@ func TestExtensionTrackExportsPreserveExplicitFlag(t *testing.T) {
|
|||||||
assertExplicit("URL handler", jsonText, err)
|
assertExplicit("URL handler", jsonText, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSearchTracksWithMetadataProviderUsesOnlySelectedExtension(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := InitExtensionSystem(filepath.Join(dir, "extensions"), filepath.Join(dir, "data")); err != nil {
|
||||||
|
t.Fatalf("InitExtensionSystem: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
|
||||||
|
selected.ID = "selected-metadata"
|
||||||
|
selected.Manifest.Name = selected.ID
|
||||||
|
other := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
|
||||||
|
other.ID = "other-metadata"
|
||||||
|
other.Manifest.Name = other.ID
|
||||||
|
|
||||||
|
manager := getExtensionManager()
|
||||||
|
manager.mu.Lock()
|
||||||
|
manager.extensions = map[string]*loadedExtension{
|
||||||
|
selected.ID: selected,
|
||||||
|
other.ID: other,
|
||||||
|
}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
defer CleanupExtensions()
|
||||||
|
|
||||||
|
jsonText, err := SearchTracksWithMetadataProviderJSON(selected.ID, "needle", 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SearchTracksWithMetadataProviderJSON: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(jsonText, `"provider_id":"selected-metadata"`) {
|
||||||
|
t.Fatalf("expected selected provider attribution, got %s", jsonText)
|
||||||
|
}
|
||||||
|
if strings.Contains(jsonText, `"provider_id":"other-metadata"`) {
|
||||||
|
t.Fatalf("unexpected fallback to another provider: %s", jsonText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
dataDir := filepath.Join(dir, "data")
|
dataDir := filepath.Join(dir, "data")
|
||||||
@@ -134,10 +237,9 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
InitItemProgress("item-1")
|
InitItemProgress("item-1")
|
||||||
FinishItemProgress("item-1")
|
|
||||||
ClearItemProgress("item-1")
|
ClearItemProgress("item-1")
|
||||||
CancelDownload("item-1")
|
CancelDownload("item-1")
|
||||||
if GetDownloadProgress() == "" || GetAllDownloadProgress() == "" || GetAllDownloadProgressDelta(0) == "" {
|
if GetAllDownloadProgress() == "" || GetAllDownloadProgressDelta(0) == "" {
|
||||||
t.Fatal("expected progress JSON")
|
t.Fatal("expected progress JSON")
|
||||||
}
|
}
|
||||||
CleanupConnections()
|
CleanupConnections()
|
||||||
@@ -194,9 +296,6 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
|||||||
if err := SetDownloadDirectory(dir); err != nil {
|
if err := SetDownloadDirectory(dir); err != nil {
|
||||||
t.Fatalf("SetDownloadDirectory: %v", err)
|
t.Fatalf("SetDownloadDirectory: %v", err)
|
||||||
}
|
}
|
||||||
if duplicateJSON, err := CheckDuplicate(dir, ""); err != nil || !strings.Contains(duplicateJSON, "exists") {
|
|
||||||
t.Fatalf("CheckDuplicate = %q/%v", duplicateJSON, err)
|
|
||||||
}
|
|
||||||
if batchJSON, err := CheckDuplicatesBatch(dir, `[{"isrc":"","track_name":"Song","artist_name":"Artist"}]`); err != nil || !strings.Contains(batchJSON, "Song") {
|
if batchJSON, err := CheckDuplicatesBatch(dir, `[{"isrc":"","track_name":"Song","artist_name":"Artist"}]`); err != nil || !strings.Contains(batchJSON, "Song") {
|
||||||
t.Fatalf("CheckDuplicatesBatch = %q/%v", batchJSON, err)
|
t.Fatalf("CheckDuplicatesBatch = %q/%v", batchJSON, err)
|
||||||
}
|
}
|
||||||
@@ -262,9 +361,6 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
|||||||
t.Fatal("expected settings JSON error")
|
t.Fatal("expected settings JSON error")
|
||||||
}
|
}
|
||||||
|
|
||||||
if jsonText, err := SearchTracksWithExtensionsJSON("song", 5); err != nil || !strings.Contains(jsonText, "search-1") {
|
|
||||||
t.Fatalf("SearchTracksWithExtensionsJSON = %q/%v", jsonText, err)
|
|
||||||
}
|
|
||||||
if jsonText, err := SearchTracksWithMetadataProvidersJSON("song", 5, true); err != nil || !strings.Contains(jsonText, "search-1") {
|
if jsonText, err := SearchTracksWithMetadataProvidersJSON("song", 5, true); err != nil || !strings.Contains(jsonText, "search-1") {
|
||||||
t.Fatalf("SearchTracksWithMetadataProvidersJSON = %q/%v", jsonText, err)
|
t.Fatalf("SearchTracksWithMetadataProvidersJSON = %q/%v", jsonText, err)
|
||||||
}
|
}
|
||||||
@@ -362,9 +458,6 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
|||||||
if _, err := GetDeezerMetadata("bad", "1"); err == nil {
|
if _, err := GetDeezerMetadata("bad", "1"); err == nil {
|
||||||
t.Fatal("expected unsupported Deezer metadata type")
|
t.Fatal("expected unsupported Deezer metadata type")
|
||||||
}
|
}
|
||||||
if jsonText, err := GetDeezerRelatedArtists("301", 2); err != nil || !strings.Contains(jsonText, "Related") {
|
|
||||||
t.Fatalf("GetDeezerRelatedArtists = %q/%v", jsonText, err)
|
|
||||||
}
|
|
||||||
if jsonText, err := GetDeezerExtendedMetadata("101"); err != nil || !strings.Contains(jsonText, "Label") {
|
if jsonText, err := GetDeezerExtendedMetadata("101"); err != nil || !strings.Contains(jsonText, "Label") {
|
||||||
t.Fatalf("GetDeezerExtendedMetadata = %q/%v", jsonText, err)
|
t.Fatalf("GetDeezerExtendedMetadata = %q/%v", jsonText, err)
|
||||||
}
|
}
|
||||||
@@ -385,37 +478,22 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
|||||||
if customJSON, err := CustomSearchWithExtensionJSONWithRequestID(ext.ID, "needle", `not-json`, "req-custom"); err != nil || !strings.Contains(customJSON, "custom-1") {
|
if customJSON, err := CustomSearchWithExtensionJSONWithRequestID(ext.ID, "needle", `not-json`, "req-custom"); err != nil || !strings.Contains(customJSON, "custom-1") {
|
||||||
t.Fatalf("CustomSearchWithExtensionJSONWithRequestID = %q/%v", customJSON, err)
|
t.Fatalf("CustomSearchWithExtensionJSONWithRequestID = %q/%v", customJSON, err)
|
||||||
}
|
}
|
||||||
if providersJSON, err := GetSearchProvidersJSON(); err != nil || !strings.Contains(providersJSON, "coverage-ext") {
|
|
||||||
t.Fatalf("GetSearchProvidersJSON = %q/%v", providersJSON, err)
|
|
||||||
}
|
|
||||||
if found := FindURLHandlerJSON("https://example.test/track/1"); found != ext.ID {
|
if found := FindURLHandlerJSON("https://example.test/track/1"); found != ext.ID {
|
||||||
t.Fatalf("FindURLHandlerJSON = %q", found)
|
t.Fatalf("FindURLHandlerJSON = %q", found)
|
||||||
}
|
}
|
||||||
if handlersJSON, err := GetURLHandlersJSON(); err != nil || !strings.Contains(handlersJSON, "coverage-ext") {
|
|
||||||
t.Fatalf("GetURLHandlersJSON = %q/%v", handlersJSON, err)
|
|
||||||
}
|
|
||||||
if handledJSON, err := HandleURLWithExtensionJSON("https://example.test/track/1"); err != nil || !strings.Contains(handledJSON, "url-track") {
|
if handledJSON, err := HandleURLWithExtensionJSON("https://example.test/track/1"); err != nil || !strings.Contains(handledJSON, "url-track") {
|
||||||
t.Fatalf("HandleURLWithExtensionJSON = %q/%v", handledJSON, err)
|
t.Fatalf("HandleURLWithExtensionJSON = %q/%v", handledJSON, err)
|
||||||
}
|
}
|
||||||
if postJSON, err := RunPostProcessingJSON(filepath.Join(dir, "song.flac"), `{"title":"Song"}`); err != nil || !strings.Contains(postJSON, "success") {
|
|
||||||
t.Fatalf("RunPostProcessingJSON = %q/%v", postJSON, err)
|
|
||||||
}
|
|
||||||
v2Input := `{"path":"` + escapeJSONPath(filepath.Join(dir, "song.flac")) + `","uri":"content://song","name":"song.flac","mime_type":"audio/flac","size":10}`
|
v2Input := `{"path":"` + escapeJSONPath(filepath.Join(dir, "song.flac")) + `","uri":"content://song","name":"song.flac","mime_type":"audio/flac","size":10}`
|
||||||
if postJSON, err := RunPostProcessingV2JSON(v2Input, `not-json`); err != nil || !strings.Contains(postJSON, "success") {
|
if postJSON, err := RunPostProcessingV2JSON(v2Input, `not-json`); err != nil || !strings.Contains(postJSON, "success") {
|
||||||
t.Fatalf("RunPostProcessingV2JSON = %q/%v", postJSON, err)
|
t.Fatalf("RunPostProcessingV2JSON = %q/%v", postJSON, err)
|
||||||
}
|
}
|
||||||
if postProviders, err := GetPostProcessingProvidersJSON(); err != nil || !strings.Contains(postProviders, "hook") {
|
|
||||||
t.Fatalf("GetPostProcessingProvidersJSON = %q/%v", postProviders, err)
|
|
||||||
}
|
|
||||||
if feedJSON, err := GetExtensionHomeFeedJSON(ext.ID); err != nil || !strings.Contains(feedJSON, "home-1") {
|
if feedJSON, err := GetExtensionHomeFeedJSON(ext.ID); err != nil || !strings.Contains(feedJSON, "home-1") {
|
||||||
t.Fatalf("GetExtensionHomeFeedJSON = %q/%v", feedJSON, err)
|
t.Fatalf("GetExtensionHomeFeedJSON = %q/%v", feedJSON, err)
|
||||||
}
|
}
|
||||||
if feedJSON, err := GetExtensionHomeFeedJSONWithRequestID(ext.ID, "req-home"); err != nil || !strings.Contains(feedJSON, "home-1") {
|
if feedJSON, err := GetExtensionHomeFeedJSONWithRequestID(ext.ID, "req-home"); err != nil || !strings.Contains(feedJSON, "home-1") {
|
||||||
t.Fatalf("GetExtensionHomeFeedJSONWithRequestID = %q/%v", feedJSON, err)
|
t.Fatalf("GetExtensionHomeFeedJSONWithRequestID = %q/%v", feedJSON, err)
|
||||||
}
|
}
|
||||||
if categoriesJSON, err := GetExtensionBrowseCategoriesJSON(ext.ID); err != nil || !strings.Contains(categoriesJSON, "cat-1") {
|
|
||||||
t.Fatalf("GetExtensionBrowseCategoriesJSON = %q/%v", categoriesJSON, err)
|
|
||||||
}
|
|
||||||
CancelExtensionRequestJSON("req-home")
|
CancelExtensionRequestJSON("req-home")
|
||||||
|
|
||||||
storeDir := filepath.Join(dir, "store")
|
storeDir := filepath.Join(dir, "store")
|
||||||
|
|||||||
@@ -405,6 +405,44 @@ func TestApplyReEnrichTrackMetadataKeepsReleaseIdentityOnAlbumMismatch(t *testin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyReEnrichTrackMetadataReplacesStalePlaylistAlbumWhenRequested(t *testing.T) {
|
||||||
|
req := reEnrichRequest{
|
||||||
|
TrackName: "Song",
|
||||||
|
ArtistName: "Artist",
|
||||||
|
AlbumName: "Road Trip Playlist",
|
||||||
|
CoverURL: "https://covers/playlist.jpg",
|
||||||
|
TrackNumber: 42,
|
||||||
|
ReleaseDate: "",
|
||||||
|
ReplaceReleaseMetadata: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
|
||||||
|
Name: "Song",
|
||||||
|
Artists: "Artist",
|
||||||
|
AlbumName: "Actual Album",
|
||||||
|
AlbumArtist: "Artist",
|
||||||
|
CoverURL: "https://covers/album.jpg",
|
||||||
|
TrackNumber: 3,
|
||||||
|
ReleaseDate: "2024-01-01",
|
||||||
|
})
|
||||||
|
|
||||||
|
if req.AlbumName != "Actual Album" {
|
||||||
|
t.Fatalf("album = %q, want actual album", req.AlbumName)
|
||||||
|
}
|
||||||
|
if req.AlbumArtist != "Artist" {
|
||||||
|
t.Fatalf("album artist = %q", req.AlbumArtist)
|
||||||
|
}
|
||||||
|
if req.CoverURL != "https://covers/album.jpg" {
|
||||||
|
t.Fatalf("cover = %q", req.CoverURL)
|
||||||
|
}
|
||||||
|
if req.TrackNumber != 3 {
|
||||||
|
t.Fatalf("track number = %d", req.TrackNumber)
|
||||||
|
}
|
||||||
|
if req.ReleaseDate != "2024-01-01" {
|
||||||
|
t.Fatalf("release date = %q", req.ReleaseDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
|
func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
|
||||||
req := reEnrichRequest{
|
req := reEnrichRequest{
|
||||||
TrackName: "Song Title",
|
TrackName: "Song Title",
|
||||||
|
|||||||
@@ -3,375 +3,10 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func manifestCapabilityStringList(manifest *ExtensionManifest, key string) []string {
|
|
||||||
if manifest == nil || manifest.Capabilities == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
raw, ok := manifest.Capabilities[key]
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
values, ok := raw.([]any)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
result := make([]string, 0, len(values))
|
|
||||||
for _, value := range values {
|
|
||||||
str, ok := value.(string)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
trimmed := strings.ToLower(strings.TrimSpace(str))
|
|
||||||
if trimmed == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result = append(result, trimmed)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func extensionReplacesBuiltInProvider(ext *loadedExtension, providerID string) bool {
|
|
||||||
if ext == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
normalized := strings.ToLower(strings.TrimSpace(providerID))
|
|
||||||
if normalized == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, replaced := range manifestCapabilityStringList(ext.Manifest, "replacesBuiltInProviders") {
|
|
||||||
if replaced == normalized {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func trimKnownProviderPrefix(trackID, providerID string) string {
|
|
||||||
trimmedID := strings.TrimSpace(trackID)
|
|
||||||
normalizedProvider := strings.ToLower(strings.TrimSpace(providerID))
|
|
||||||
if trimmedID == "" || normalizedProvider == "" {
|
|
||||||
return trimmedID
|
|
||||||
}
|
|
||||||
|
|
||||||
prefix := normalizedProvider + ":"
|
|
||||||
if strings.HasPrefix(strings.ToLower(trimmedID), prefix) {
|
|
||||||
return trimmedID[len(prefix):]
|
|
||||||
}
|
|
||||||
|
|
||||||
return trimmedID
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolvePreferredTrackIDForExtension(ext *loadedExtension, req DownloadRequest, explicitTrackID string) string {
|
|
||||||
candidates := make([]string, 0, 8)
|
|
||||||
appendCandidate := func(value string) {
|
|
||||||
trimmed := strings.TrimSpace(value)
|
|
||||||
if trimmed == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, existing := range candidates {
|
|
||||||
if existing == trimmed {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
candidates = append(candidates, trimmed)
|
|
||||||
}
|
|
||||||
|
|
||||||
appendCandidate(explicitTrackID)
|
|
||||||
|
|
||||||
if extensionReplacesBuiltInProvider(ext, "tidal") {
|
|
||||||
appendCandidate(req.TidalID)
|
|
||||||
appendCandidate(trimKnownProviderPrefix(req.SpotifyID, "tidal"))
|
|
||||||
}
|
|
||||||
if extensionReplacesBuiltInProvider(ext, "qobuz") {
|
|
||||||
appendCandidate(req.QobuzID)
|
|
||||||
appendCandidate(trimKnownProviderPrefix(req.SpotifyID, "qobuz"))
|
|
||||||
}
|
|
||||||
if extensionReplacesBuiltInProvider(ext, "deezer") {
|
|
||||||
appendCandidate(req.DeezerID)
|
|
||||||
appendCandidate(trimKnownProviderPrefix(req.SpotifyID, "deezer"))
|
|
||||||
}
|
|
||||||
if extensionReplacesBuiltInProvider(ext, "spotify") {
|
|
||||||
appendCandidate(trimKnownProviderPrefix(req.SpotifyID, "spotify"))
|
|
||||||
appendCandidate(req.SpotifyID)
|
|
||||||
}
|
|
||||||
|
|
||||||
appendCandidate(req.SpotifyID)
|
|
||||||
appendCandidate(req.TidalID)
|
|
||||||
appendCandidate(req.QobuzID)
|
|
||||||
appendCandidate(req.DeezerID)
|
|
||||||
|
|
||||||
if len(candidates) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return candidates[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeDownloadResultExtension(candidates ...string) string {
|
|
||||||
for _, candidate := range candidates {
|
|
||||||
ext := strings.TrimSpace(strings.ToLower(candidate))
|
|
||||||
if ext == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(ext, ".") {
|
|
||||||
ext = "." + ext
|
|
||||||
}
|
|
||||||
if ext == ".mp4" {
|
|
||||||
return ".m4a"
|
|
||||||
}
|
|
||||||
return ext
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeExtensionDownloadResult(result *ExtDownloadResult) (DownloadResult, bool) {
|
|
||||||
if result == nil {
|
|
||||||
return DownloadResult{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadResult := DownloadResult{
|
|
||||||
FilePath: strings.TrimSpace(result.FilePath),
|
|
||||||
BitDepth: result.BitDepth,
|
|
||||||
SampleRate: result.SampleRate,
|
|
||||||
AudioCodec: strings.TrimSpace(result.AudioCodec),
|
|
||||||
Title: result.Title,
|
|
||||||
Artist: result.Artist,
|
|
||||||
Album: result.Album,
|
|
||||||
ReleaseDate: result.ReleaseDate,
|
|
||||||
TrackNumber: result.TrackNumber,
|
|
||||||
TotalTracks: result.TotalTracks,
|
|
||||||
DiscNumber: result.DiscNumber,
|
|
||||||
TotalDiscs: result.TotalDiscs,
|
|
||||||
ISRC: result.ISRC,
|
|
||||||
CoverURL: result.CoverURL,
|
|
||||||
Genre: result.Genre,
|
|
||||||
Label: result.Label,
|
|
||||||
Copyright: result.Copyright,
|
|
||||||
Composer: result.Composer,
|
|
||||||
LyricsLRC: result.LyricsLRC,
|
|
||||||
DecryptionKey: result.DecryptionKey,
|
|
||||||
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
|
|
||||||
ActualExtension: normalizeDownloadResultExtension(result.ActualExtension, result.OutputExtension),
|
|
||||||
ActualContainer: strings.TrimSpace(result.ActualContainer),
|
|
||||||
RequiresContainerConversion: result.RequiresContainerConversion,
|
|
||||||
}
|
|
||||||
|
|
||||||
alreadyExists := result.AlreadyExists
|
|
||||||
if strings.HasPrefix(downloadResult.FilePath, "EXISTS:") {
|
|
||||||
alreadyExists = true
|
|
||||||
downloadResult.FilePath = strings.TrimPrefix(downloadResult.FilePath, "EXISTS:")
|
|
||||||
}
|
|
||||||
|
|
||||||
enrichResultQualityFromFile(&downloadResult)
|
|
||||||
return downloadResult, alreadyExists
|
|
||||||
}
|
|
||||||
|
|
||||||
// overlayStr sets *dst = src when dst is empty and src is not. If field is
|
|
||||||
// non-empty it logs "<field> from enrichment: <src>" on overlay.
|
|
||||||
func overlayStr(dst *string, src, field string) {
|
|
||||||
if src == "" || *dst != "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
*dst = src
|
|
||||||
if field != "" {
|
|
||||||
GoLog("[DownloadWithExtensionFallback] %s from enrichment: %s\n", field, src)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// overlayStrTrim is overlayStr but treats a whitespace-only dst as empty too.
|
|
||||||
func overlayStrTrim(dst *string, src string) {
|
|
||||||
if src == "" || strings.TrimSpace(*dst) != "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
*dst = src
|
|
||||||
}
|
|
||||||
|
|
||||||
// overlayInt sets *dst = src when dst is zero and src is positive. If field is
|
|
||||||
// non-empty it logs "<field> from enrichment: <src>" on overlay.
|
|
||||||
func overlayInt(dst *int, src int, field string) {
|
|
||||||
if src <= 0 || *dst != 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
*dst = src
|
|
||||||
if field != "" {
|
|
||||||
GoLog("[DownloadWithExtensionFallback] %s from enrichment: %d\n", field, src)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func overlayExtensionDownloadMetadata(resp *DownloadResponse, result *ExtDownloadResult) {
|
|
||||||
if resp == nil || result == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
overlayStrTrim(&resp.Title, result.Title)
|
|
||||||
overlayStrTrim(&resp.Artist, result.Artist)
|
|
||||||
overlayStrTrim(&resp.Album, result.Album)
|
|
||||||
overlayStrTrim(&resp.AlbumArtist, result.AlbumArtist)
|
|
||||||
overlayInt(&resp.TrackNumber, result.TrackNumber, "")
|
|
||||||
overlayInt(&resp.DiscNumber, result.DiscNumber, "")
|
|
||||||
overlayInt(&resp.TotalTracks, result.TotalTracks, "")
|
|
||||||
overlayInt(&resp.TotalDiscs, result.TotalDiscs, "")
|
|
||||||
overlayStrTrim(&resp.ReleaseDate, result.ReleaseDate)
|
|
||||||
overlayStrTrim(&resp.CoverURL, result.CoverURL)
|
|
||||||
overlayStrTrim(&resp.ISRC, result.ISRC)
|
|
||||||
overlayStrTrim(&resp.Genre, result.Genre)
|
|
||||||
overlayStrTrim(&resp.Label, result.Label)
|
|
||||||
overlayStrTrim(&resp.Copyright, result.Copyright)
|
|
||||||
overlayStrTrim(&resp.Composer, result.Composer)
|
|
||||||
if result.LyricsLRC != "" {
|
|
||||||
resp.LyricsLRC = result.LyricsLRC
|
|
||||||
}
|
|
||||||
if result.DecryptionKey != "" {
|
|
||||||
resp.DecryptionKey = result.DecryptionKey
|
|
||||||
}
|
|
||||||
if normalized := normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey); normalized != nil {
|
|
||||||
resp.Decryption = normalized
|
|
||||||
}
|
|
||||||
if ext := normalizeDownloadResultExtension(result.ActualExtension, result.OutputExtension); ext != "" {
|
|
||||||
resp.ActualExtension = ext
|
|
||||||
}
|
|
||||||
if container := strings.TrimSpace(result.ActualContainer); container != "" {
|
|
||||||
resp.ActualContainer = container
|
|
||||||
}
|
|
||||||
if result.RequiresContainerConversion {
|
|
||||||
resp.RequiresContainerConversion = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyExtensionRequestFallbacks(resp *DownloadResponse, req DownloadRequest) {
|
|
||||||
if resp == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
overlayStr(&resp.Album, req.AlbumName, "")
|
|
||||||
overlayStr(&resp.AlbumArtist, req.AlbumArtist, "")
|
|
||||||
overlayStr(&resp.ReleaseDate, req.ReleaseDate, "")
|
|
||||||
overlayStr(&resp.ISRC, req.ISRC, "")
|
|
||||||
overlayInt(&resp.TrackNumber, req.TrackNumber, "")
|
|
||||||
overlayInt(&resp.TotalTracks, req.TotalTracks, "")
|
|
||||||
overlayInt(&resp.DiscNumber, req.DiscNumber, "")
|
|
||||||
overlayInt(&resp.TotalDiscs, req.TotalDiscs, "")
|
|
||||||
overlayStr(&resp.CoverURL, req.CoverURL, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func shouldStopProviderFallback(availability *ExtAvailabilityResult) bool {
|
|
||||||
return availability != nil && availability.SkipFallback
|
|
||||||
}
|
|
||||||
|
|
||||||
func fallbackRuntimeHealthStatus(ext *loadedExtension) string {
|
|
||||||
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
status := strings.ToLower(strings.TrimSpace(CheckExtensionHealthCached(ext).Status))
|
|
||||||
switch status {
|
|
||||||
case "online", "degraded", "offline":
|
|
||||||
return status
|
|
||||||
default:
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func prioritizeFallbackProvidersByHealth(priority []string, extManager *extensionManager, sourceProvider string) []string {
|
|
||||||
if len(priority) == 0 || extManager == nil {
|
|
||||||
return priority
|
|
||||||
}
|
|
||||||
|
|
||||||
online := make([]string, 0, len(priority))
|
|
||||||
degraded := make([]string, 0, len(priority))
|
|
||||||
unknown := make([]string, 0, len(priority))
|
|
||||||
|
|
||||||
for _, rawProviderID := range priority {
|
|
||||||
providerID := strings.TrimSpace(rawProviderID)
|
|
||||||
if providerID == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.EqualFold(providerID, sourceProvider) || !isExtensionFallbackAllowed(providerID) {
|
|
||||||
unknown = append(unknown, providerID)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
ext, err := extManager.GetExtension(providerID)
|
|
||||||
if err != nil || ext == nil || !ext.Enabled || ext.Error != "" || ext.Manifest == nil || !ext.Manifest.IsDownloadProvider() {
|
|
||||||
unknown = append(unknown, providerID)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
switch fallbackRuntimeHealthStatus(ext) {
|
|
||||||
case "online":
|
|
||||||
online = append(online, providerID)
|
|
||||||
case "degraded":
|
|
||||||
degraded = append(degraded, providerID)
|
|
||||||
case "offline":
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Skipping extension provider %s (service health offline)\n", providerID)
|
|
||||||
default:
|
|
||||||
unknown = append(unknown, providerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result := make([]string, 0, len(online)+len(degraded)+len(unknown))
|
|
||||||
result = append(result, online...)
|
|
||||||
result = append(result, degraded...)
|
|
||||||
result = append(result, unknown...)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveExtensionAvailabilityReason(availability *ExtAvailabilityResult, err error) string {
|
|
||||||
if availability != nil {
|
|
||||||
if reason := strings.TrimSpace(availability.Reason); reason != "" {
|
|
||||||
return reason
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err.Error()
|
|
||||||
}
|
|
||||||
return "extension requested no further fallback"
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildExtensionFallbackStoppedResponse(providerID string, availability *ExtAvailabilityResult, err error) *DownloadResponse {
|
|
||||||
reason := resolveExtensionAvailabilityReason(availability, err)
|
|
||||||
errorType := classifyDownloadErrorType(reason)
|
|
||||||
if errorType == "unknown" {
|
|
||||||
errorType = "extension_error"
|
|
||||||
}
|
|
||||||
return &DownloadResponse{
|
|
||||||
Success: false,
|
|
||||||
Error: fmt.Sprintf("Fallback stopped by %s: %s", providerID, reason),
|
|
||||||
ErrorType: errorType,
|
|
||||||
Service: providerID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func shouldAbortCancelledFallback(itemID string, err error) bool {
|
|
||||||
if errors.Is(err, ErrDownloadCancelled) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return itemID != "" && isDownloadCancelled(itemID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeExtensionDownloadErrorType(errorType, message string) string {
|
|
||||||
normalized := strings.TrimSpace(errorType)
|
|
||||||
classified := classifyDownloadErrorType(message)
|
|
||||||
if classified != "" && classified != "unknown" {
|
|
||||||
switch strings.ToLower(normalized) {
|
|
||||||
case "", "unknown", "runtime_error", "api_error", "download_error", "extension_error":
|
|
||||||
return classified
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
// attemptExtensionDownload runs a single provider.Download attempt: builds the
|
// attemptExtensionDownload runs a single provider.Download attempt: builds the
|
||||||
// output path, reports progress, and on success assembles the full
|
// output path, reports progress, and on success assembles the full
|
||||||
// DownloadResponse (overlay, request fallbacks, optional title/artist/composer
|
// DownloadResponse (overlay, request fallbacks, optional title/artist/composer
|
||||||
@@ -391,6 +26,23 @@ func attemptExtensionDownload(
|
|||||||
lastRetryAfterSeconds *int,
|
lastRetryAfterSeconds *int,
|
||||||
) (resp *DownloadResponse, cancelledOuter bool) {
|
) (resp *DownloadResponse, cancelledOuter bool) {
|
||||||
outputPath := buildOutputPathForExtension(req, ext)
|
outputPath := buildOutputPathForExtension(req, ext)
|
||||||
|
if shouldReuseExistingOutput(req, outputPath) {
|
||||||
|
result := DownloadResult{FilePath: outputPath}
|
||||||
|
enrichResultQualityFromFile(&result)
|
||||||
|
built := buildDownloadSuccessResponse(
|
||||||
|
req,
|
||||||
|
result,
|
||||||
|
providerLabel,
|
||||||
|
"File already exists",
|
||||||
|
outputPath,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
if req.ItemID != "" {
|
||||||
|
CompleteItemProgress(req.ItemID)
|
||||||
|
}
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Keeping existing output instead of replacing it: %s\n", outputPath)
|
||||||
|
return &built, false
|
||||||
|
}
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
SetItemPreparingStage(req.ItemID, "resolving_stream")
|
SetItemPreparingStage(req.ItemID, "resolving_stream")
|
||||||
}
|
}
|
||||||
@@ -408,6 +60,23 @@ func attemptExtensionDownload(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
downloadSucceeded := err == nil && result != nil && result.Success
|
downloadSucceeded := err == nil && result != nil && result.Success
|
||||||
|
if downloadSucceeded {
|
||||||
|
resolved := resolvedTrackInfo{
|
||||||
|
Title: result.Title,
|
||||||
|
ArtistName: result.Artist,
|
||||||
|
AlbumName: result.Album,
|
||||||
|
ISRC: result.ISRC,
|
||||||
|
Duration: result.DurationMS / 1000,
|
||||||
|
SkipNameVerification: strings.EqualFold(strings.TrimSpace(req.Source), strings.TrimSpace(providerLabel)) ||
|
||||||
|
ext.Manifest.HasCustomMatching(),
|
||||||
|
}
|
||||||
|
if !trackMatchesRequest(req, resolved, "Extension "+providerLabel) {
|
||||||
|
discardRejectedExtensionOutput(result, outputPath)
|
||||||
|
*lastErr = fmt.Errorf("provider %s returned a different track", providerLabel)
|
||||||
|
*lastErrType = "not_found"
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
if req.ItemID != "" && downloadSucceeded {
|
if req.ItemID != "" && downloadSucceeded {
|
||||||
SetItemFinalizing(req.ItemID)
|
SetItemFinalizing(req.ItemID)
|
||||||
}
|
}
|
||||||
@@ -603,12 +272,37 @@ func attemptVerifiedResumeBeforeMetadata(
|
|||||||
Service: selectedProvider,
|
Service: selectedProvider,
|
||||||
}, false
|
}, false
|
||||||
}
|
}
|
||||||
|
if lastErr != nil && isOutputStorageWriteFailure(errorType, lastErr.Error()) {
|
||||||
|
return buildOutputStorageFailureResponse(
|
||||||
|
selectedProvider,
|
||||||
|
lastErr,
|
||||||
|
lastRetryAfterSeconds,
|
||||||
|
), false
|
||||||
|
}
|
||||||
// A failed fast attempt may still succeed after source enrichment resolves a
|
// A failed fast attempt may still succeed after source enrichment resolves a
|
||||||
// better provider-native ID. The normal path below retains strict/stop-
|
// better provider-native ID. The normal path below retains strict/stop-
|
||||||
// fallback semantics for that prepared retry.
|
// fallback semantics for that prepared retry.
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildOutputStorageFailureResponse(
|
||||||
|
providerID string,
|
||||||
|
err error,
|
||||||
|
retryAfterSeconds int,
|
||||||
|
) *DownloadResponse {
|
||||||
|
message := "Output storage is not writable"
|
||||||
|
if err != nil {
|
||||||
|
message = err.Error()
|
||||||
|
}
|
||||||
|
return &DownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Download failed: " + message,
|
||||||
|
ErrorType: "permission",
|
||||||
|
RetryAfterSeconds: retryAfterSeconds,
|
||||||
|
Service: providerID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) {
|
func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) {
|
||||||
pipelineStartedAt := time.Now()
|
pipelineStartedAt := time.Now()
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -859,6 +553,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
if sourceErrType == "" && lastErr != nil {
|
if sourceErrType == "" && lastErr != nil {
|
||||||
sourceErrType = classifyDownloadErrorType(lastErr.Error())
|
sourceErrType = classifyDownloadErrorType(lastErr.Error())
|
||||||
}
|
}
|
||||||
|
if lastErr != nil && isOutputStorageWriteFailure(sourceErrType, lastErr.Error()) {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Source extension %s hit an unwritable output path; stopping provider fallback\n", req.Source)
|
||||||
|
return buildOutputStorageFailureResponse(
|
||||||
|
req.Source,
|
||||||
|
lastErr,
|
||||||
|
lastRetryAfterSeconds,
|
||||||
|
), nil
|
||||||
|
}
|
||||||
if strings.EqualFold(sourceErrType, "verification_required") {
|
if strings.EqualFold(sourceErrType, "verification_required") {
|
||||||
GoLog("[DownloadWithExtensionFallback] Source extension %s requires verification, not trying other providers\n", req.Source)
|
GoLog("[DownloadWithExtensionFallback] Source extension %s requires verification, not trying other providers\n", req.Source)
|
||||||
cachePreparedDownloadRequest(preparationKey, req)
|
cachePreparedDownloadRequest(preparationKey, req)
|
||||||
@@ -879,7 +581,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
return &DownloadResponse{
|
return &DownloadResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
Error: "Download failed: " + lastErr.Error(),
|
Error: "Download failed: " + lastErr.Error(),
|
||||||
ErrorType: firstNonEmptyString(lastErrType, "extension_error"),
|
ErrorType: firstNonEmptyTrimmed(lastErrType, "extension_error"),
|
||||||
RetryAfterSeconds: lastRetryAfterSeconds,
|
RetryAfterSeconds: lastRetryAfterSeconds,
|
||||||
Service: req.Source,
|
Service: req.Source,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -889,7 +591,12 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
priority = prioritizeFallbackProvidersByHealth(priority, extManager, req.Source)
|
healthProtectedProvider := selectedProvider
|
||||||
|
if strings.TrimSpace(healthProtectedProvider) == "" {
|
||||||
|
healthProtectedProvider = req.Source
|
||||||
|
}
|
||||||
|
priority = prioritizeFallbackProvidersByHealth(priority, extManager, healthProtectedProvider)
|
||||||
|
priority = moveProviderToFront(priority, selectedProvider)
|
||||||
|
|
||||||
for _, providerID := range priority {
|
for _, providerID := range priority {
|
||||||
if isDownloadCancelled(req.ItemID) {
|
if isDownloadCancelled(req.ItemID) {
|
||||||
@@ -992,6 +699,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
if effType == "" {
|
if effType == "" {
|
||||||
effType = classifyDownloadErrorType(lastErr.Error())
|
effType = classifyDownloadErrorType(lastErr.Error())
|
||||||
}
|
}
|
||||||
|
if isOutputStorageWriteFailure(effType, lastErr.Error()) {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] %s hit an unwritable output path; stopping provider fallback\n", providerID)
|
||||||
|
return buildOutputStorageFailureResponse(
|
||||||
|
providerID,
|
||||||
|
lastErr,
|
||||||
|
lastRetryAfterSeconds,
|
||||||
|
), nil
|
||||||
|
}
|
||||||
if strings.EqualFold(effType, "verification_required") {
|
if strings.EqualFold(effType, "verification_required") {
|
||||||
GoLog("[DownloadWithExtensionFallback] %s requires verification; pausing fallback to open the challenge\n", providerID)
|
GoLog("[DownloadWithExtensionFallback] %s requires verification; pausing fallback to open the challenge\n", providerID)
|
||||||
cachePreparedDownloadRequest(preparationKey, req)
|
cachePreparedDownloadRequest(preparationKey, req)
|
||||||
@@ -1013,7 +728,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
if lastErr != nil {
|
if lastErr != nil {
|
||||||
errorType := firstNonEmptyString(lastErrType, classifyDownloadErrorType(lastErr.Error()))
|
errorType := firstNonEmptyTrimmed(lastErrType, classifyDownloadErrorType(lastErr.Error()))
|
||||||
if errorType == "unknown" {
|
if errorType == "unknown" {
|
||||||
errorType = "not_found"
|
errorType = "not_found"
|
||||||
}
|
}
|
||||||
@@ -1031,166 +746,3 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
ErrorType: "not_found",
|
ErrorType: "not_found",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildDownloadFilename renders the sanitized "<name><ext>" filename for req
|
|
||||||
// from its template/metadata, defaulting to "<artist> - <title>.flac".
|
|
||||||
func buildDownloadFilename(req DownloadRequest) string {
|
|
||||||
metadata := map[string]any{
|
|
||||||
"title": req.TrackName,
|
|
||||||
"artist": req.ArtistName,
|
|
||||||
"album": req.AlbumName,
|
|
||||||
"album_artist": req.AlbumArtist,
|
|
||||||
"track": req.TrackNumber,
|
|
||||||
"track_number": req.TrackNumber,
|
|
||||||
"total_tracks": req.TotalTracks,
|
|
||||||
"playlist_position": req.PlaylistPosition,
|
|
||||||
"disc": req.DiscNumber,
|
|
||||||
"disc_number": req.DiscNumber,
|
|
||||||
"total_discs": req.TotalDiscs,
|
|
||||||
"year": extractYear(req.ReleaseDate),
|
|
||||||
"date": req.ReleaseDate,
|
|
||||||
"release_date": req.ReleaseDate,
|
|
||||||
"isrc": req.ISRC,
|
|
||||||
"composer": req.Composer,
|
|
||||||
"quality": req.Quality,
|
|
||||||
"quality_variant": req.QualityVariant,
|
|
||||||
}
|
|
||||||
|
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
|
|
||||||
if strings.TrimSpace(filename) == "" {
|
|
||||||
filename = fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName)
|
|
||||||
}
|
|
||||||
filename = sanitizeFilenamePreservingToken(filename, req.QualityVariant)
|
|
||||||
|
|
||||||
ext := strings.TrimSpace(req.OutputExt)
|
|
||||||
if ext == "" {
|
|
||||||
ext = ".flac"
|
|
||||||
} else if !strings.HasPrefix(ext, ".") {
|
|
||||||
ext = "." + ext
|
|
||||||
}
|
|
||||||
|
|
||||||
return filename + ext
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildOutputPath(req DownloadRequest) string {
|
|
||||||
if strings.TrimSpace(req.OutputPath) != "" {
|
|
||||||
return strings.TrimSpace(req.OutputPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
outputDir := req.OutputDir
|
|
||||||
if strings.TrimSpace(outputDir) == "" {
|
|
||||||
outputDir = filepath.Join(os.TempDir(), "spotiflac-downloads")
|
|
||||||
}
|
|
||||||
os.MkdirAll(outputDir, 0755)
|
|
||||||
AddAllowedDownloadDir(outputDir)
|
|
||||||
|
|
||||||
return filepath.Join(outputDir, buildDownloadFilename(req))
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) string {
|
|
||||||
if strings.TrimSpace(req.OutputPath) != "" {
|
|
||||||
outputPath := strings.TrimSpace(req.OutputPath)
|
|
||||||
AddAllowedDownloadDir(filepath.Dir(outputPath))
|
|
||||||
return outputPath
|
|
||||||
}
|
|
||||||
|
|
||||||
// SAF downloads hand extensions a detached output FD owned by the host.
|
|
||||||
// Extensions still need a real local temp file so Android can copy it into
|
|
||||||
// the target document after provider-specific post-processing completes.
|
|
||||||
if !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
|
|
||||||
return buildOutputPath(req)
|
|
||||||
}
|
|
||||||
|
|
||||||
tempDir := filepath.Join(ext.DataDir, "downloads")
|
|
||||||
os.MkdirAll(tempDir, 0755)
|
|
||||||
AddAllowedDownloadDir(tempDir)
|
|
||||||
|
|
||||||
return filepath.Join(tempDir, buildDownloadFilename(req))
|
|
||||||
}
|
|
||||||
|
|
||||||
func canEmbedGenreLabel(filePath string) bool {
|
|
||||||
path := strings.TrimSpace(filePath)
|
|
||||||
if path == "" || strings.HasPrefix(path, "content://") || strings.HasPrefix(path, "/proc/self/fd/") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if strings.ToLower(filepath.Ext(path)) != ".flac" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if !filepath.IsAbs(path) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
return err == nil && !info.IsDir() && info.Size() > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func embedExtensionDownloadMetadata(resp DownloadResponse, req DownloadRequest, alreadyExists bool) {
|
|
||||||
if alreadyExists || !req.EmbedMetadata {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
filePath := strings.TrimSpace(resp.FilePath)
|
|
||||||
if !canEmbedGenreLabel(filePath) {
|
|
||||||
if req.Genre != "" || req.Label != "" || resp.CoverURL != "" || req.CoverURL != "" {
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Skipping metadata/cover embed for non-local FLAC output path: %q\n", filePath)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
coverURL := firstNonEmptyTrimmed(resp.CoverURL, req.CoverURL)
|
|
||||||
var coverData []byte
|
|
||||||
if coverURL != "" {
|
|
||||||
data, err := downloadCoverToMemory(coverURL, req.EmbedMaxQualityCover)
|
|
||||||
if err != nil {
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Warning: failed to download cover for metadata embed: %v\n", err)
|
|
||||||
} else if len(data) > 0 {
|
|
||||||
coverData = data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata := Metadata{
|
|
||||||
Title: firstNonEmptyTrimmed(resp.Title, req.TrackName),
|
|
||||||
Artist: firstNonEmptyTrimmed(resp.Artist, req.ArtistName),
|
|
||||||
Album: firstNonEmptyTrimmed(resp.Album, req.AlbumName),
|
|
||||||
AlbumArtist: firstNonEmptyTrimmed(resp.AlbumArtist, req.AlbumArtist),
|
|
||||||
ArtistTagMode: req.ArtistTagMode,
|
|
||||||
Date: firstNonEmptyTrimmed(resp.ReleaseDate, req.ReleaseDate),
|
|
||||||
TrackNumber: firstPositiveInt(resp.TrackNumber, req.TrackNumber),
|
|
||||||
TotalTracks: firstPositiveInt(resp.TotalTracks, req.TotalTracks),
|
|
||||||
DiscNumber: firstPositiveInt(resp.DiscNumber, req.DiscNumber),
|
|
||||||
TotalDiscs: firstPositiveInt(resp.TotalDiscs, req.TotalDiscs),
|
|
||||||
ISRC: firstNonEmptyTrimmed(resp.ISRC, req.ISRC),
|
|
||||||
Genre: firstNonEmptyTrimmed(resp.Genre, req.Genre),
|
|
||||||
Label: firstNonEmptyTrimmed(resp.Label, req.Label),
|
|
||||||
Copyright: firstNonEmptyTrimmed(resp.Copyright, req.Copyright),
|
|
||||||
Composer: firstNonEmptyTrimmed(resp.Composer, req.Composer),
|
|
||||||
}
|
|
||||||
if req.EmbedLyrics {
|
|
||||||
metadata.Lyrics = resp.LyricsLRC
|
|
||||||
}
|
|
||||||
|
|
||||||
var err error
|
|
||||||
if len(coverData) > 0 {
|
|
||||||
err = EmbedMetadataWithCoverData(filePath, metadata, coverData)
|
|
||||||
} else {
|
|
||||||
err = EmbedMetadata(filePath, metadata, "")
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed metadata/cover: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(coverData) > 0 {
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Embedded metadata and cover from %q\n", coverURL)
|
|
||||||
} else {
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Embedded metadata without cover\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstPositiveInt(values ...int) int {
|
|
||||||
for _, value := range values {
|
|
||||||
if value > 0 {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,432 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func manifestCapabilityStringList(manifest *ExtensionManifest, key string) []string {
|
||||||
|
if manifest == nil || manifest.Capabilities == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, ok := manifest.Capabilities[key]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
values, ok := raw.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
str, ok := value.(string)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
trimmed := strings.ToLower(strings.TrimSpace(str))
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, trimmed)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func extensionReplacesBuiltInProvider(ext *loadedExtension, providerID string) bool {
|
||||||
|
if ext == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(providerID))
|
||||||
|
if normalized == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, replaced := range manifestCapabilityStringList(ext.Manifest, "replacesBuiltInProviders") {
|
||||||
|
if replaced == normalized {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimKnownProviderPrefix(trackID, providerID string) string {
|
||||||
|
trimmedID := strings.TrimSpace(trackID)
|
||||||
|
normalizedProvider := strings.ToLower(strings.TrimSpace(providerID))
|
||||||
|
if trimmedID == "" || normalizedProvider == "" {
|
||||||
|
return trimmedID
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := normalizedProvider + ":"
|
||||||
|
if strings.HasPrefix(strings.ToLower(trimmedID), prefix) {
|
||||||
|
return trimmedID[len(prefix):]
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmedID
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolvePreferredTrackIDForExtension(ext *loadedExtension, req DownloadRequest, explicitTrackID string) string {
|
||||||
|
candidates := make([]string, 0, 8)
|
||||||
|
appendCandidate := func(value string) {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, existing := range candidates {
|
||||||
|
if existing == trimmed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates = append(candidates, trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
appendCandidate(explicitTrackID)
|
||||||
|
|
||||||
|
if extensionReplacesBuiltInProvider(ext, "tidal") {
|
||||||
|
appendCandidate(req.TidalID)
|
||||||
|
appendCandidate(trimKnownProviderPrefix(req.SpotifyID, "tidal"))
|
||||||
|
}
|
||||||
|
if extensionReplacesBuiltInProvider(ext, "qobuz") {
|
||||||
|
appendCandidate(req.QobuzID)
|
||||||
|
appendCandidate(trimKnownProviderPrefix(req.SpotifyID, "qobuz"))
|
||||||
|
}
|
||||||
|
if extensionReplacesBuiltInProvider(ext, "deezer") {
|
||||||
|
appendCandidate(req.DeezerID)
|
||||||
|
appendCandidate(trimKnownProviderPrefix(req.SpotifyID, "deezer"))
|
||||||
|
}
|
||||||
|
if extensionReplacesBuiltInProvider(ext, "spotify") {
|
||||||
|
appendCandidate(trimKnownProviderPrefix(req.SpotifyID, "spotify"))
|
||||||
|
appendCandidate(req.SpotifyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
appendCandidate(req.SpotifyID)
|
||||||
|
appendCandidate(req.TidalID)
|
||||||
|
appendCandidate(req.QobuzID)
|
||||||
|
appendCandidate(req.DeezerID)
|
||||||
|
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return candidates[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDownloadResultExtension(candidates ...string) string {
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
ext := strings.TrimSpace(strings.ToLower(candidate))
|
||||||
|
if ext == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(ext, ".") {
|
||||||
|
ext = "." + ext
|
||||||
|
}
|
||||||
|
if ext == ".mp4" {
|
||||||
|
return ".m4a"
|
||||||
|
}
|
||||||
|
return ext
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// discardRejectedExtensionOutput removes only a newly downloaded file inside
|
||||||
|
// the host-selected output directory. Existing-library hits are never removed,
|
||||||
|
// nor are paths outside that narrow directory.
|
||||||
|
func discardRejectedExtensionOutput(result *ExtDownloadResult, requestedOutputPath string) {
|
||||||
|
if result == nil || result.AlreadyExists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resultPath := strings.TrimSpace(result.FilePath)
|
||||||
|
requestedPath := strings.TrimSpace(requestedOutputPath)
|
||||||
|
if resultPath == "" || requestedPath == "" ||
|
||||||
|
strings.HasPrefix(resultPath, "content://") ||
|
||||||
|
strings.HasPrefix(resultPath, "/proc/self/fd/") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resultAbs, resultErr := filepath.Abs(resultPath)
|
||||||
|
outputDirAbs, outputErr := filepath.Abs(filepath.Dir(requestedPath))
|
||||||
|
if resultErr != nil || outputErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
relative, err := filepath.Rel(outputDirAbs, resultAbs)
|
||||||
|
if err != nil || relative == "." || relative == ".." ||
|
||||||
|
strings.HasPrefix(relative, ".."+string(filepath.Separator)) ||
|
||||||
|
filepath.IsAbs(relative) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(resultAbs); err != nil && !os.IsNotExist(err) {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Warning: failed to remove rejected provider output %q: %v\n", resultAbs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeExtensionDownloadResult(result *ExtDownloadResult) (DownloadResult, bool) {
|
||||||
|
if result == nil {
|
||||||
|
return DownloadResult{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadResult := DownloadResult{
|
||||||
|
FilePath: strings.TrimSpace(result.FilePath),
|
||||||
|
BitDepth: result.BitDepth,
|
||||||
|
SampleRate: result.SampleRate,
|
||||||
|
AudioCodec: strings.TrimSpace(result.AudioCodec),
|
||||||
|
Title: result.Title,
|
||||||
|
Artist: result.Artist,
|
||||||
|
Album: result.Album,
|
||||||
|
ReleaseDate: result.ReleaseDate,
|
||||||
|
TrackNumber: result.TrackNumber,
|
||||||
|
TotalTracks: result.TotalTracks,
|
||||||
|
DiscNumber: result.DiscNumber,
|
||||||
|
TotalDiscs: result.TotalDiscs,
|
||||||
|
ISRC: result.ISRC,
|
||||||
|
CoverURL: result.CoverURL,
|
||||||
|
Genre: result.Genre,
|
||||||
|
Label: result.Label,
|
||||||
|
Copyright: result.Copyright,
|
||||||
|
Composer: result.Composer,
|
||||||
|
LyricsLRC: result.LyricsLRC,
|
||||||
|
DecryptionKey: result.DecryptionKey,
|
||||||
|
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
|
||||||
|
ActualExtension: normalizeDownloadResultExtension(result.ActualExtension, result.OutputExtension),
|
||||||
|
ActualContainer: strings.TrimSpace(result.ActualContainer),
|
||||||
|
RequiresContainerConversion: result.RequiresContainerConversion,
|
||||||
|
}
|
||||||
|
|
||||||
|
alreadyExists := result.AlreadyExists
|
||||||
|
if strings.HasPrefix(downloadResult.FilePath, "EXISTS:") {
|
||||||
|
alreadyExists = true
|
||||||
|
downloadResult.FilePath = strings.TrimPrefix(downloadResult.FilePath, "EXISTS:")
|
||||||
|
}
|
||||||
|
|
||||||
|
enrichResultQualityFromFile(&downloadResult)
|
||||||
|
return downloadResult, alreadyExists
|
||||||
|
}
|
||||||
|
|
||||||
|
// overlayStr sets *dst = src when dst is empty and src is not. If field is
|
||||||
|
// non-empty it logs "<field> from enrichment: <src>" on overlay.
|
||||||
|
func overlayStr(dst *string, src, field string) {
|
||||||
|
if src == "" || *dst != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
*dst = src
|
||||||
|
if field != "" {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] %s from enrichment: %s\n", field, src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// overlayStrTrim is overlayStr but treats a whitespace-only dst as empty too.
|
||||||
|
func overlayStrTrim(dst *string, src string) {
|
||||||
|
if src == "" || strings.TrimSpace(*dst) != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
*dst = src
|
||||||
|
}
|
||||||
|
|
||||||
|
// overlayInt sets *dst = src when dst is zero and src is positive. If field is
|
||||||
|
// non-empty it logs "<field> from enrichment: <src>" on overlay.
|
||||||
|
func overlayInt(dst *int, src int, field string) {
|
||||||
|
if src <= 0 || *dst != 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
*dst = src
|
||||||
|
if field != "" {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] %s from enrichment: %d\n", field, src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func overlayExtensionDownloadMetadata(resp *DownloadResponse, result *ExtDownloadResult) {
|
||||||
|
if resp == nil || result == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
overlayStrTrim(&resp.Title, result.Title)
|
||||||
|
overlayStrTrim(&resp.Artist, result.Artist)
|
||||||
|
overlayStrTrim(&resp.Album, result.Album)
|
||||||
|
overlayStrTrim(&resp.AlbumArtist, result.AlbumArtist)
|
||||||
|
overlayInt(&resp.TrackNumber, result.TrackNumber, "")
|
||||||
|
overlayInt(&resp.DiscNumber, result.DiscNumber, "")
|
||||||
|
overlayInt(&resp.TotalTracks, result.TotalTracks, "")
|
||||||
|
overlayInt(&resp.TotalDiscs, result.TotalDiscs, "")
|
||||||
|
overlayStrTrim(&resp.ReleaseDate, result.ReleaseDate)
|
||||||
|
overlayStrTrim(&resp.CoverURL, result.CoverURL)
|
||||||
|
overlayStrTrim(&resp.ISRC, result.ISRC)
|
||||||
|
overlayStrTrim(&resp.Genre, result.Genre)
|
||||||
|
overlayStrTrim(&resp.Label, result.Label)
|
||||||
|
overlayStrTrim(&resp.Copyright, result.Copyright)
|
||||||
|
overlayStrTrim(&resp.Composer, result.Composer)
|
||||||
|
if result.LyricsLRC != "" {
|
||||||
|
resp.LyricsLRC = result.LyricsLRC
|
||||||
|
}
|
||||||
|
if result.DecryptionKey != "" {
|
||||||
|
resp.DecryptionKey = result.DecryptionKey
|
||||||
|
}
|
||||||
|
if normalized := normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey); normalized != nil {
|
||||||
|
resp.Decryption = normalized
|
||||||
|
}
|
||||||
|
if ext := normalizeDownloadResultExtension(result.ActualExtension, result.OutputExtension); ext != "" {
|
||||||
|
resp.ActualExtension = ext
|
||||||
|
}
|
||||||
|
if container := strings.TrimSpace(result.ActualContainer); container != "" {
|
||||||
|
resp.ActualContainer = container
|
||||||
|
}
|
||||||
|
if result.RequiresContainerConversion {
|
||||||
|
resp.RequiresContainerConversion = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyExtensionRequestFallbacks(resp *DownloadResponse, req DownloadRequest) {
|
||||||
|
if resp == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
overlayStr(&resp.Album, req.AlbumName, "")
|
||||||
|
overlayStr(&resp.AlbumArtist, req.AlbumArtist, "")
|
||||||
|
overlayStr(&resp.ReleaseDate, req.ReleaseDate, "")
|
||||||
|
overlayStr(&resp.ISRC, req.ISRC, "")
|
||||||
|
overlayInt(&resp.TrackNumber, req.TrackNumber, "")
|
||||||
|
overlayInt(&resp.TotalTracks, req.TotalTracks, "")
|
||||||
|
overlayInt(&resp.DiscNumber, req.DiscNumber, "")
|
||||||
|
overlayInt(&resp.TotalDiscs, req.TotalDiscs, "")
|
||||||
|
overlayStr(&resp.CoverURL, req.CoverURL, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldStopProviderFallback(availability *ExtAvailabilityResult) bool {
|
||||||
|
return availability != nil && availability.SkipFallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallbackRuntimeHealthStatus(ext *loadedExtension) string {
|
||||||
|
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
status := strings.ToLower(strings.TrimSpace(CheckExtensionHealthCached(ext).Status))
|
||||||
|
switch status {
|
||||||
|
case "online", "degraded", "offline":
|
||||||
|
return status
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func prioritizeFallbackProvidersByHealth(priority []string, extManager *extensionManager, protectedProvider string) []string {
|
||||||
|
if len(priority) == 0 || extManager == nil {
|
||||||
|
return priority
|
||||||
|
}
|
||||||
|
|
||||||
|
online := make([]string, 0, len(priority))
|
||||||
|
degraded := make([]string, 0, len(priority))
|
||||||
|
unknown := make([]string, 0, len(priority))
|
||||||
|
|
||||||
|
for _, rawProviderID := range priority {
|
||||||
|
providerID := strings.TrimSpace(rawProviderID)
|
||||||
|
if providerID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.EqualFold(providerID, protectedProvider) || !isExtensionFallbackAllowed(providerID) {
|
||||||
|
unknown = append(unknown, providerID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ext, err := extManager.GetExtension(providerID)
|
||||||
|
if err != nil || ext == nil || !ext.Enabled || ext.Error != "" || ext.Manifest == nil || !ext.Manifest.IsDownloadProvider() {
|
||||||
|
unknown = append(unknown, providerID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch fallbackRuntimeHealthStatus(ext) {
|
||||||
|
case "online":
|
||||||
|
online = append(online, providerID)
|
||||||
|
case "degraded":
|
||||||
|
degraded = append(degraded, providerID)
|
||||||
|
case "offline":
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Skipping extension provider %s (service health offline)\n", providerID)
|
||||||
|
default:
|
||||||
|
unknown = append(unknown, providerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]string, 0, len(online)+len(degraded)+len(unknown))
|
||||||
|
result = append(result, online...)
|
||||||
|
result = append(result, degraded...)
|
||||||
|
result = append(result, unknown...)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// moveProviderToFront preserves the user's explicit provider selection after
|
||||||
|
// health-based fallback sorting. Health may order the remaining fallback
|
||||||
|
// candidates, but it must never silently replace the provider the user picked.
|
||||||
|
func moveProviderToFront(priority []string, providerID string) []string {
|
||||||
|
providerID = strings.TrimSpace(providerID)
|
||||||
|
if providerID == "" || len(priority) < 2 {
|
||||||
|
return priority
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedIndex := -1
|
||||||
|
for i, candidate := range priority {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(candidate), providerID) {
|
||||||
|
selectedIndex = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if selectedIndex <= 0 {
|
||||||
|
return priority
|
||||||
|
}
|
||||||
|
|
||||||
|
reordered := make([]string, 0, len(priority))
|
||||||
|
reordered = append(reordered, priority[selectedIndex])
|
||||||
|
reordered = append(reordered, priority[:selectedIndex]...)
|
||||||
|
reordered = append(reordered, priority[selectedIndex+1:]...)
|
||||||
|
return reordered
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveExtensionAvailabilityReason(availability *ExtAvailabilityResult, err error) string {
|
||||||
|
if availability != nil {
|
||||||
|
if reason := strings.TrimSpace(availability.Reason); reason != "" {
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
return "extension requested no further fallback"
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildExtensionFallbackStoppedResponse(providerID string, availability *ExtAvailabilityResult, err error) *DownloadResponse {
|
||||||
|
reason := resolveExtensionAvailabilityReason(availability, err)
|
||||||
|
errorType := classifyDownloadErrorType(reason)
|
||||||
|
if errorType == "unknown" {
|
||||||
|
errorType = "extension_error"
|
||||||
|
}
|
||||||
|
return &DownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("Fallback stopped by %s: %s", providerID, reason),
|
||||||
|
ErrorType: errorType,
|
||||||
|
Service: providerID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldAbortCancelledFallback(itemID string, err error) bool {
|
||||||
|
if errors.Is(err, ErrDownloadCancelled) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return itemID != "" && isDownloadCancelled(itemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeExtensionDownloadErrorType(errorType, message string) string {
|
||||||
|
normalized := strings.TrimSpace(errorType)
|
||||||
|
classified := classifyDownloadErrorType(message)
|
||||||
|
if classified != "" && classified != "unknown" {
|
||||||
|
switch strings.ToLower(normalized) {
|
||||||
|
case "", "unknown", "runtime_error", "api_error", "download_error", "extension_error":
|
||||||
|
return classified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildDownloadFilename renders the sanitized "<name><ext>" filename for req
|
||||||
|
// from its template/metadata, defaulting to "<artist> - <title>.flac".
|
||||||
|
func buildDownloadFilename(req DownloadRequest) string {
|
||||||
|
metadata := map[string]any{
|
||||||
|
"title": req.TrackName,
|
||||||
|
"artist": req.ArtistName,
|
||||||
|
"album": req.AlbumName,
|
||||||
|
"album_artist": req.AlbumArtist,
|
||||||
|
"track": req.TrackNumber,
|
||||||
|
"track_number": req.TrackNumber,
|
||||||
|
"total_tracks": req.TotalTracks,
|
||||||
|
"playlist_position": req.PlaylistPosition,
|
||||||
|
"disc": req.DiscNumber,
|
||||||
|
"disc_number": req.DiscNumber,
|
||||||
|
"total_discs": req.TotalDiscs,
|
||||||
|
"year": extractYear(req.ReleaseDate),
|
||||||
|
"date": req.ReleaseDate,
|
||||||
|
"release_date": req.ReleaseDate,
|
||||||
|
"isrc": req.ISRC,
|
||||||
|
"composer": req.Composer,
|
||||||
|
"quality": req.Quality,
|
||||||
|
"quality_variant": req.QualityVariant,
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
|
||||||
|
if strings.TrimSpace(filename) == "" {
|
||||||
|
filename = fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName)
|
||||||
|
}
|
||||||
|
filename = sanitizeFilenamePreservingToken(filename, req.QualityVariant)
|
||||||
|
|
||||||
|
ext := strings.TrimSpace(req.OutputExt)
|
||||||
|
if ext == "" {
|
||||||
|
ext = ".flac"
|
||||||
|
} else if !strings.HasPrefix(ext, ".") {
|
||||||
|
ext = "." + ext
|
||||||
|
}
|
||||||
|
|
||||||
|
return filename + ext
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildOutputPath(req DownloadRequest) string {
|
||||||
|
if strings.TrimSpace(req.OutputPath) != "" {
|
||||||
|
return strings.TrimSpace(req.OutputPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
outputDir := req.OutputDir
|
||||||
|
if strings.TrimSpace(outputDir) == "" {
|
||||||
|
outputDir = filepath.Join(os.TempDir(), "spotiflac-downloads")
|
||||||
|
}
|
||||||
|
os.MkdirAll(outputDir, 0755)
|
||||||
|
AddAllowedDownloadDir(outputDir)
|
||||||
|
|
||||||
|
return filepath.Join(outputDir, buildDownloadFilename(req))
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) string {
|
||||||
|
if strings.TrimSpace(req.OutputPath) != "" {
|
||||||
|
outputPath := strings.TrimSpace(req.OutputPath)
|
||||||
|
AddAllowedDownloadDir(filepath.Dir(outputPath))
|
||||||
|
return outputPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAF downloads hand extensions a detached output FD owned by the host.
|
||||||
|
// Extensions still need a real local temp file so Android can copy it into
|
||||||
|
// the target document after provider-specific post-processing completes.
|
||||||
|
if !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
|
||||||
|
return buildOutputPath(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
tempDir := filepath.Join(ext.DataDir, "downloads")
|
||||||
|
os.MkdirAll(tempDir, 0755)
|
||||||
|
AddAllowedDownloadDir(tempDir)
|
||||||
|
|
||||||
|
return filepath.Join(tempDir, buildDownloadFilename(req))
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldReuseExistingOutput(req DownloadRequest, outputPath string) bool {
|
||||||
|
if req.AllowQualityVariant || isFDOutput(req.OutputFD) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
path := strings.TrimSpace(outputPath)
|
||||||
|
if path == "" || strings.HasPrefix(path, "content://") || strings.HasPrefix(path, "/proc/self/fd/") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
return err == nil && info.Mode().IsRegular() && info.Size() > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func canEmbedGenreLabel(filePath string) bool {
|
||||||
|
path := strings.TrimSpace(filePath)
|
||||||
|
if path == "" || strings.HasPrefix(path, "content://") || strings.HasPrefix(path, "/proc/self/fd/") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.ToLower(filepath.Ext(path)) != ".flac" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
return err == nil && !info.IsDir() && info.Size() > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func embedExtensionDownloadMetadata(resp DownloadResponse, req DownloadRequest, alreadyExists bool) {
|
||||||
|
if alreadyExists || !req.EmbedMetadata {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := strings.TrimSpace(resp.FilePath)
|
||||||
|
if !canEmbedGenreLabel(filePath) {
|
||||||
|
if req.Genre != "" || req.Label != "" || resp.CoverURL != "" || req.CoverURL != "" {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Skipping metadata/cover embed for non-local FLAC output path: %q\n", filePath)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
coverURL := firstNonEmptyTrimmed(resp.CoverURL, req.CoverURL)
|
||||||
|
var coverData []byte
|
||||||
|
if coverURL != "" {
|
||||||
|
data, err := downloadCoverToMemory(coverURL, req.EmbedMaxQualityCover)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Warning: failed to download cover for metadata embed: %v\n", err)
|
||||||
|
} else if len(data) > 0 {
|
||||||
|
coverData = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := Metadata{
|
||||||
|
Title: firstNonEmptyTrimmed(resp.Title, req.TrackName),
|
||||||
|
Artist: firstNonEmptyTrimmed(resp.Artist, req.ArtistName),
|
||||||
|
Album: firstNonEmptyTrimmed(resp.Album, req.AlbumName),
|
||||||
|
AlbumArtist: firstNonEmptyTrimmed(resp.AlbumArtist, req.AlbumArtist),
|
||||||
|
ArtistTagMode: req.ArtistTagMode,
|
||||||
|
Date: firstNonEmptyTrimmed(resp.ReleaseDate, req.ReleaseDate),
|
||||||
|
TrackNumber: firstPositiveInt(resp.TrackNumber, req.TrackNumber),
|
||||||
|
TotalTracks: firstPositiveInt(resp.TotalTracks, req.TotalTracks),
|
||||||
|
DiscNumber: firstPositiveInt(resp.DiscNumber, req.DiscNumber),
|
||||||
|
TotalDiscs: firstPositiveInt(resp.TotalDiscs, req.TotalDiscs),
|
||||||
|
ISRC: firstNonEmptyTrimmed(resp.ISRC, req.ISRC),
|
||||||
|
Genre: firstNonEmptyTrimmed(resp.Genre, req.Genre),
|
||||||
|
Label: firstNonEmptyTrimmed(resp.Label, req.Label),
|
||||||
|
Copyright: firstNonEmptyTrimmed(resp.Copyright, req.Copyright),
|
||||||
|
Composer: firstNonEmptyTrimmed(resp.Composer, req.Composer),
|
||||||
|
}
|
||||||
|
if req.EmbedLyrics {
|
||||||
|
metadata.Lyrics = resp.LyricsLRC
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if len(coverData) > 0 {
|
||||||
|
err = EmbedMetadataWithCoverData(filePath, metadata, coverData)
|
||||||
|
} else {
|
||||||
|
err = EmbedMetadata(filePath, metadata, "")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed metadata/cover: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(coverData) > 0 {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Embedded metadata and cover from %q\n", coverURL)
|
||||||
|
} else {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Embedded metadata without cover\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstPositiveInt(values ...int) int {
|
||||||
|
for _, value := range values {
|
||||||
|
if value > 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShouldReuseExistingOutputProtectsCompletedFile(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "Artist - Track.flac")
|
||||||
|
if err := os.WriteFile(path, []byte("existing audio"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write existing output: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !shouldReuseExistingOutput(DownloadRequest{}, path) {
|
||||||
|
t.Fatal("expected completed output to be reused when variants are disabled")
|
||||||
|
}
|
||||||
|
if shouldReuseExistingOutput(
|
||||||
|
DownloadRequest{AllowQualityVariant: true},
|
||||||
|
path,
|
||||||
|
) {
|
||||||
|
t.Fatal("quality-variant staging output must remain independently writable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldReuseExistingOutputIgnoresEmptyStagingFile(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "native_saf_work.flac")
|
||||||
|
if err := os.WriteFile(path, nil, 0o644); err != nil {
|
||||||
|
t.Fatalf("write staging output: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldReuseExistingOutput(DownloadRequest{}, path) {
|
||||||
|
t.Fatal("empty staging output must not be treated as a completed download")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -485,6 +485,7 @@ func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDo
|
|||||||
BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"),
|
BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"),
|
||||||
SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"),
|
SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"),
|
||||||
AudioCodec: gojaObjectString(obj, "audio_codec", "audioCodec", "codec"),
|
AudioCodec: gojaObjectString(obj, "audio_codec", "audioCodec", "codec"),
|
||||||
|
DurationMS: gojaObjectInt(obj, "duration_ms", "durationMs"),
|
||||||
ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"),
|
ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"),
|
||||||
ErrorType: gojaObjectString(obj, "error_type", "errorType"),
|
ErrorType: gojaObjectString(obj, "error_type", "errorType"),
|
||||||
RetryAfterSeconds: gojaObjectInt(obj, "retry_after_seconds", "retryAfterSeconds"),
|
RetryAfterSeconds: gojaObjectInt(obj, "retry_after_seconds", "retryAfterSeconds"),
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ func TestExtensionHealthClassificationAndValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCoverAndIDHSHelpers(t *testing.T) {
|
func TestCoverAndIDHSHelpers(t *testing.T) {
|
||||||
if got := upgradeToMaxQuality("https://cdn-images.dzcdn.net/images/cover/abc/500x500-000000-80-0-0.jpg"); !strings.Contains(got, "1800x1800") {
|
if got := upgradeToMaxQuality("https://cdn-images.dzcdn.net/images/cover/abc/500x500-000000-80-0-0.jpg"); !strings.Contains(got, "1900x1900") {
|
||||||
t.Fatalf("deezer cover = %q", got)
|
t.Fatalf("deezer cover = %q", got)
|
||||||
}
|
}
|
||||||
if got := upgradeToMaxQuality("https://resources.tidal.com/images/id/320x320.jpg"); !strings.Contains(got, "origin.jpg") {
|
if got := upgradeToMaxQuality("https://resources.tidal.com/images/id/320x320.jpg"); !strings.Contains(got, "origin.jpg") {
|
||||||
|
|||||||
+50
-554
@@ -4,121 +4,15 @@ import (
|
|||||||
"archive/zip"
|
"archive/zip"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"path"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
func compareVersions(v1, v2 string) int {
|
|
||||||
parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".")
|
|
||||||
parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".")
|
|
||||||
|
|
||||||
maxLen := len(parts1)
|
|
||||||
if len(parts2) > maxLen {
|
|
||||||
maxLen = len(parts2)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < maxLen; i++ {
|
|
||||||
var n1, n2 int
|
|
||||||
if i < len(parts1) {
|
|
||||||
n1, _ = strconv.Atoi(parts1[i])
|
|
||||||
}
|
|
||||||
if i < len(parts2) {
|
|
||||||
n2, _ = strconv.Atoi(parts2[i])
|
|
||||||
}
|
|
||||||
|
|
||||||
if n1 < n2 {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
if n1 > n2 {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func isExtensionPackagePath(filePath string) bool {
|
|
||||||
lowerPath := strings.ToLower(filePath)
|
|
||||||
return strings.HasSuffix(lowerPath, ".spotiflac-ext") || strings.HasSuffix(lowerPath, ".sflx")
|
|
||||||
}
|
|
||||||
|
|
||||||
func managedExtensionPath(root, extensionID string) (string, error) {
|
|
||||||
if root == "" {
|
|
||||||
return "", fmt.Errorf("extension directory is not configured")
|
|
||||||
}
|
|
||||||
if !extensionIDPattern.MatchString(extensionID) {
|
|
||||||
return "", fmt.Errorf("invalid extension ID %q", extensionID)
|
|
||||||
}
|
|
||||||
fullPath := filepath.Join(root, extensionID)
|
|
||||||
if !isPathWithinBase(root, fullPath) {
|
|
||||||
return "", fmt.Errorf("extension path escapes its managed directory")
|
|
||||||
}
|
|
||||||
return fullPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func safeExtensionAssetPath(root, assetPath string) (string, bool) {
|
|
||||||
if root == "" || assetPath == "" || filepath.IsAbs(assetPath) || strings.Contains(assetPath, `\`) {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
cleaned := path.Clean(assetPath)
|
|
||||||
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
fullPath := filepath.Join(root, filepath.FromSlash(cleaned))
|
|
||||||
return fullPath, isPathWithinBase(root, fullPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractExtensionArchive(zipReader *zip.ReadCloser, destination string) error {
|
|
||||||
for _, file := range zipReader.File {
|
|
||||||
if file.FileInfo().IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if file.FileInfo().Mode()&os.ModeSymlink != 0 || strings.Contains(file.Name, `\`) {
|
|
||||||
return fmt.Errorf("unsafe path in extension archive: %s", file.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
relPath := path.Clean(file.Name)
|
|
||||||
if relPath == "." || relPath == ".." || strings.HasPrefix(relPath, "../") || path.IsAbs(relPath) {
|
|
||||||
return fmt.Errorf("unsafe path in extension archive: %s", file.Name)
|
|
||||||
}
|
|
||||||
destPath := filepath.Join(destination, filepath.FromSlash(relPath))
|
|
||||||
if !isPathWithinBase(destination, destPath) {
|
|
||||||
return fmt.Errorf("unsafe path in extension archive: %s", file.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
|
|
||||||
return fmt.Errorf("failed to create extension directory: %w", err)
|
|
||||||
}
|
|
||||||
destFile, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create extension file: %w", err)
|
|
||||||
}
|
|
||||||
srcFile, err := file.Open()
|
|
||||||
if err != nil {
|
|
||||||
destFile.Close()
|
|
||||||
return fmt.Errorf("failed to open file in archive: %w", err)
|
|
||||||
}
|
|
||||||
_, copyErr := io.Copy(destFile, srcFile)
|
|
||||||
closeSrcErr := srcFile.Close()
|
|
||||||
closeDestErr := destFile.Close()
|
|
||||||
if copyErr != nil {
|
|
||||||
return fmt.Errorf("failed to extract extension file: %w", copyErr)
|
|
||||||
}
|
|
||||||
if closeSrcErr != nil || closeDestErr != nil {
|
|
||||||
return fmt.Errorf("failed to close extracted extension file")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type loadedExtension struct {
|
type loadedExtension struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Manifest *ExtensionManifest `json:"manifest"`
|
Manifest *ExtensionManifest `json:"manifest"`
|
||||||
@@ -159,6 +53,13 @@ func getExtensionInitSettings(extensionID string) map[string]any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ensureRuntimeReadyLocked(ext *loadedExtension, applyStoredSettings bool) error {
|
func ensureRuntimeReadyLocked(ext *loadedExtension, applyStoredSettings bool) error {
|
||||||
|
// Gate enabling too, so a package installed with a failed gate cannot be
|
||||||
|
// switched on anyway.
|
||||||
|
if err := validateManifestGates(ext.Manifest); err != nil {
|
||||||
|
ext.Error = err.Error()
|
||||||
|
ext.Enabled = false
|
||||||
|
return err
|
||||||
|
}
|
||||||
if ext.VM == nil || ext.runtime == nil {
|
if ext.VM == nil || ext.runtime == nil {
|
||||||
if err := initializeVMLocked(ext); err != nil {
|
if err := initializeVMLocked(ext); err != nil {
|
||||||
ext.Error = err.Error()
|
ext.Error = err.Error()
|
||||||
@@ -260,37 +161,9 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded
|
|||||||
}
|
}
|
||||||
defer zipReader.Close()
|
defer zipReader.Close()
|
||||||
|
|
||||||
var manifestData []byte
|
manifest, err := inspectExtensionPackage(zipReader.File)
|
||||||
var hasIndexJS bool
|
|
||||||
for _, file := range zipReader.File {
|
|
||||||
name := filepath.Base(file.Name)
|
|
||||||
if name == "manifest.json" {
|
|
||||||
rc, err := file.Open()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to open manifest.json: %w", err)
|
|
||||||
}
|
|
||||||
manifestData, err = io.ReadAll(rc)
|
|
||||||
rc.Close()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read manifest.json: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if name == "index.js" {
|
|
||||||
hasIndexJS = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if manifestData == nil {
|
|
||||||
return nil, fmt.Errorf("invalid extension package: manifest.json not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hasIndexJS {
|
|
||||||
return nil, fmt.Errorf("invalid extension package: index.js not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
manifest, err := ParseManifest(manifestData)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid extension manifest: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
@@ -377,383 +250,55 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded
|
|||||||
return ext, nil
|
return ext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func initializeVMLocked(ext *loadedExtension) error {
|
var supportedRuntimeFeatures = map[string]int{
|
||||||
ext.VM = nil
|
"signedSession": 3,
|
||||||
ext.runtime = nil
|
"sessionRefresh": 1,
|
||||||
ext.indexProgram = nil
|
"sessionGrant": 1,
|
||||||
ext.initialized = false
|
"globalAction": 1,
|
||||||
vm := goja.New()
|
"webviewAuth": 1,
|
||||||
ext.VM = vm
|
|
||||||
|
|
||||||
indexPath := filepath.Join(ext.SourceDir, "index.js")
|
|
||||||
jsCode, err := os.ReadFile(indexPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read index.js: %w", err)
|
|
||||||
}
|
|
||||||
indexProgram, err := goja.Compile(indexPath, string(jsCode), false)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to compile extension code: %w", err)
|
|
||||||
}
|
|
||||||
ext.indexProgram = indexProgram
|
|
||||||
|
|
||||||
runtime := newExtensionRuntime(ext)
|
|
||||||
ext.runtime = runtime
|
|
||||||
runtime.RegisterAPIs(vm)
|
|
||||||
runtime.RegisterGoBackendAPIs(vm)
|
|
||||||
|
|
||||||
console := vm.NewObject()
|
|
||||||
console.Set("log", func(call goja.FunctionCall) goja.Value {
|
|
||||||
args := make([]any, len(call.Arguments))
|
|
||||||
for i, arg := range call.Arguments {
|
|
||||||
args[i] = arg.Export()
|
|
||||||
}
|
|
||||||
GoLog("[Extension:%s] %v\n", ext.ID, args)
|
|
||||||
return goja.Undefined()
|
|
||||||
})
|
|
||||||
vm.Set("console", console)
|
|
||||||
|
|
||||||
var registeredExtension goja.Value
|
|
||||||
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
|
|
||||||
if len(call.Arguments) > 0 {
|
|
||||||
registeredExtension = call.Arguments[0]
|
|
||||||
vm.Set("extension", call.Arguments[0])
|
|
||||||
}
|
|
||||||
return goja.Undefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err = vm.RunProgram(indexProgram)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to execute extension code: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if registeredExtension == nil || goja.IsUndefined(registeredExtension) {
|
|
||||||
return fmt.Errorf("extension did not call registerExtension()")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) {
|
// validateManifestGates enforces minAppVersion and requiredRuntimeFeatures
|
||||||
vm := goja.New()
|
// on every load path (.sflx install, upgrade, directory load); the Store UI
|
||||||
|
// check alone never covered manual installs. An empty app version (tests,
|
||||||
indexProgram := ext.indexProgram
|
// dev harnesses) skips the version gate.
|
||||||
if indexProgram == nil {
|
func validateManifestGates(manifest *ExtensionManifest) error {
|
||||||
indexPath := filepath.Join(ext.SourceDir, "index.js")
|
if manifest == nil {
|
||||||
jsCode, err := os.ReadFile(indexPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("failed to read index.js: %w", err)
|
|
||||||
}
|
|
||||||
indexProgram, err = goja.Compile(indexPath, string(jsCode), false)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("failed to compile extension code: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
runtime := &extensionRuntime{
|
|
||||||
extensionID: ext.ID,
|
|
||||||
manifest: ext.Manifest,
|
|
||||||
settings: make(map[string]any),
|
|
||||||
cookieJar: nil,
|
|
||||||
dataDir: ext.DataDir,
|
|
||||||
vm: vm,
|
|
||||||
}
|
|
||||||
if ext.runtime != nil && ext.runtime.cookieJar != nil {
|
|
||||||
runtime.cookieJar = ext.runtime.cookieJar
|
|
||||||
} else {
|
|
||||||
jar, _ := newSimpleCookieJar()
|
|
||||||
runtime.cookieJar = jar
|
|
||||||
}
|
|
||||||
runtime.httpClient = newExtensionHTTPClient(ext, runtime.cookieJar, extensionHTTPTimeout(ext, 30*time.Second), true)
|
|
||||||
runtime.downloadClient = newExtensionHTTPClient(ext, runtime.cookieJar, DownloadTimeout, false)
|
|
||||||
runtime.RegisterAPIs(vm)
|
|
||||||
runtime.RegisterGoBackendAPIs(vm)
|
|
||||||
|
|
||||||
console := vm.NewObject()
|
|
||||||
console.Set("log", func(call goja.FunctionCall) goja.Value {
|
|
||||||
args := make([]any, len(call.Arguments))
|
|
||||||
for i, arg := range call.Arguments {
|
|
||||||
args[i] = arg.Export()
|
|
||||||
}
|
|
||||||
GoLog("[Extension:%s] %v\n", ext.ID, args)
|
|
||||||
return goja.Undefined()
|
|
||||||
})
|
|
||||||
vm.Set("console", console)
|
|
||||||
|
|
||||||
var registeredExtension goja.Value
|
|
||||||
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
|
|
||||||
if len(call.Arguments) > 0 {
|
|
||||||
registeredExtension = call.Arguments[0]
|
|
||||||
vm.Set("extension", call.Arguments[0])
|
|
||||||
}
|
|
||||||
return goja.Undefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
if _, err := vm.RunProgram(indexProgram); err != nil {
|
|
||||||
runtime.closeStorageFlusher()
|
|
||||||
return nil, nil, fmt.Errorf("failed to execute extension code: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if registeredExtension == nil || goja.IsUndefined(registeredExtension) {
|
|
||||||
runtime.closeStorageFlusher()
|
|
||||||
return nil, nil, fmt.Errorf("extension did not call registerExtension()")
|
|
||||||
}
|
|
||||||
|
|
||||||
settings := getExtensionInitSettings(ext.ID)
|
|
||||||
if len(settings) > 0 {
|
|
||||||
if err := initializeExtensionRuntimeWithSettings(vm, ext.ID, settings); err != nil {
|
|
||||||
runtime.closeStorageFlusher()
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return vm, runtime, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// A goja runtime plus an executed extension program is several MB of live
|
|
||||||
// heap; rebuilding one per download multiplies that by the number of tracks.
|
|
||||||
// Extensions already serve many calls on the persistent shared VM, so reusing
|
|
||||||
// an initialized isolated runtime for consecutive downloads is the same
|
|
||||||
// lifecycle contract.
|
|
||||||
const maxIdleIsolatedRuntimes = 1
|
|
||||||
|
|
||||||
// acquireIsolatedExtensionRuntime pops an idle pooled runtime or builds one.
|
|
||||||
func acquireIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) {
|
|
||||||
ext.isolatedPoolMu.Lock()
|
|
||||||
if n := len(ext.isolatedPool); n > 0 {
|
|
||||||
handle := ext.isolatedPool[n-1]
|
|
||||||
ext.isolatedPool = ext.isolatedPool[:n-1]
|
|
||||||
ext.isolatedPoolMu.Unlock()
|
|
||||||
return handle.vm, handle.runtime, nil
|
|
||||||
}
|
|
||||||
ext.isolatedPoolMu.Unlock()
|
|
||||||
|
|
||||||
ext.VMMu.Lock()
|
|
||||||
defer ext.VMMu.Unlock()
|
|
||||||
return newIsolatedExtensionRuntime(ext)
|
|
||||||
}
|
|
||||||
|
|
||||||
// releaseIsolatedExtensionRuntime pools a healthy runtime for reuse or tears
|
|
||||||
// it down. Pass healthy=false after an interrupt/timeout/script error, whose
|
|
||||||
// VM state can't be trusted for reuse.
|
|
||||||
func releaseIsolatedExtensionRuntime(ext *loadedExtension, vm *goja.Runtime, runtime *extensionRuntime, healthy, cleanupSafe bool) {
|
|
||||||
if runtime != nil {
|
|
||||||
if err := runtime.flushStorageNow(); err != nil {
|
|
||||||
GoLog("[Extension:%s] isolated download storage flush failed: %v\n", ext.ID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if healthy && vm != nil && runtime != nil && ext.Enabled {
|
|
||||||
ext.isolatedPoolMu.Lock()
|
|
||||||
if len(ext.isolatedPool) < maxIdleIsolatedRuntimes {
|
|
||||||
ext.isolatedPool = append(ext.isolatedPool, &isolatedRuntimeHandle{vm: vm, runtime: runtime})
|
|
||||||
ext.isolatedPoolMu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ext.isolatedPoolMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
if cleanupSafe {
|
|
||||||
if cleanupErr := runCleanupOnVM(vm); cleanupErr != nil {
|
|
||||||
GoLog("[Extension:%s] isolated download cleanup failed: %v\n", ext.ID, cleanupErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if runtime != nil {
|
|
||||||
runtime.closeStorageFlusher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// quarantineRuntimeLocked detaches a VM that remained busy after interrupt.
|
|
||||||
// The caller holds VMMu. Touching or cleaning up that VM would race its stuck
|
|
||||||
// goroutine; a later call will build a fresh runtime from indexProgram.
|
|
||||||
func quarantineRuntimeLocked(ext *loadedExtension, vm *goja.Runtime) {
|
|
||||||
if ext == nil || ext.VM != vm {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ext.VM = nil
|
|
||||||
ext.runtime = nil
|
|
||||||
ext.initialized = false
|
|
||||||
ext.Error = "extension runtime was quarantined after an unresponsive script"
|
|
||||||
}
|
|
||||||
|
|
||||||
// drainIsolatedRuntimePool tears down idle isolated runtimes. Called on
|
|
||||||
// extension teardown and on app-wide memory release.
|
|
||||||
func drainIsolatedRuntimePool(ext *loadedExtension) {
|
|
||||||
ext.isolatedPoolMu.Lock()
|
|
||||||
pool := ext.isolatedPool
|
|
||||||
ext.isolatedPool = nil
|
|
||||||
ext.isolatedPoolMu.Unlock()
|
|
||||||
|
|
||||||
for _, handle := range pool {
|
|
||||||
if cleanupErr := runCleanupOnVM(handle.vm); cleanupErr != nil {
|
|
||||||
GoLog("[Extension:%s] isolated pool cleanup failed: %v\n", ext.ID, cleanupErr)
|
|
||||||
}
|
|
||||||
if handle.runtime != nil {
|
|
||||||
if err := handle.runtime.flushStorageNow(); err != nil {
|
|
||||||
GoLog("[Extension:%s] isolated pool storage flush failed: %v\n", ext.ID, err)
|
|
||||||
}
|
|
||||||
handle.runtime.closeStorageFlusher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// drainAllIsolatedRuntimePools releases every extension's idle isolated
|
|
||||||
// runtimes (memory-pressure hook).
|
|
||||||
func drainAllIsolatedRuntimePools() {
|
|
||||||
m := getExtensionManager()
|
|
||||||
m.mu.RLock()
|
|
||||||
exts := make([]*loadedExtension, 0, len(m.extensions))
|
|
||||||
for _, ext := range m.extensions {
|
|
||||||
exts = append(exts, ext)
|
|
||||||
}
|
|
||||||
m.mu.RUnlock()
|
|
||||||
|
|
||||||
for _, ext := range exts {
|
|
||||||
drainIsolatedRuntimePool(ext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *extensionManager) initializeVM(ext *loadedExtension) error {
|
|
||||||
ext.VMMu.Lock()
|
|
||||||
defer ext.VMMu.Unlock()
|
|
||||||
return initializeVMLocked(ext)
|
|
||||||
}
|
|
||||||
|
|
||||||
func initializeExtensionRuntimeWithSettings(
|
|
||||||
vm *goja.Runtime,
|
|
||||||
extensionID string,
|
|
||||||
settings map[string]any,
|
|
||||||
) error {
|
|
||||||
settingsJSON, err := json.Marshal(settings)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to save settings")
|
|
||||||
}
|
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
|
||||||
(function() {
|
|
||||||
var settings = %s;
|
|
||||||
if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
|
|
||||||
try {
|
|
||||||
extension.initialize(settings);
|
|
||||||
return { success: true };
|
|
||||||
} catch (e) {
|
|
||||||
return { success: false, error: e.toString() };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { success: true, message: 'no initialize function' };
|
|
||||||
})()
|
|
||||||
`, string(settingsJSON))
|
|
||||||
|
|
||||||
result, err := vm.RunString(script)
|
|
||||||
if err != nil {
|
|
||||||
GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if result != nil && !goja.IsUndefined(result) {
|
|
||||||
exported := result.Export()
|
|
||||||
if resultMap, ok := exported.(map[string]any); ok {
|
|
||||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
|
||||||
errMsg := "unknown error"
|
|
||||||
if e, ok := resultMap["error"].(string); ok {
|
|
||||||
errMsg = e
|
|
||||||
}
|
|
||||||
GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, errMsg)
|
|
||||||
return fmt.Errorf("initialize failed: %s", errMsg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func initializeExtensionWithSettingsLocked(
|
|
||||||
ext *loadedExtension,
|
|
||||||
settings map[string]any,
|
|
||||||
) error {
|
|
||||||
if ext.VM == nil {
|
|
||||||
return fmt.Errorf("extension failed to load: please reinstall the extension")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := initializeExtensionRuntimeWithSettings(ext.VM, ext.ID, settings); err != nil {
|
|
||||||
ext.Error = err.Error()
|
|
||||||
ext.Enabled = false
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ext.initialized = true
|
|
||||||
GoLog("[Extension] Initialized %s\n", ext.ID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runCleanupLocked(ext *loadedExtension) error {
|
|
||||||
if ext.VM != nil {
|
|
||||||
if err := runCleanupOnVM(ext.VM); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if ext.VM.Get("extension") != nil {
|
|
||||||
GoLog("[Extension] Cleanup called for %s\n", ext.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runCleanupOnVM(vm *goja.Runtime) error {
|
|
||||||
if vm == nil {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
minVersion := strings.TrimSpace(manifest.MinAppVersion)
|
||||||
script := `
|
appVersion := strings.TrimSpace(GetAppVersion())
|
||||||
(function() {
|
if minVersion != "" && appVersion != "" && compareVersions(appVersion, minVersion) < 0 {
|
||||||
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
return fmt.Errorf("requires app %s or later (installed: %s)", minVersion, appVersion)
|
||||||
try {
|
|
||||||
extension.cleanup();
|
|
||||||
return { success: true };
|
|
||||||
} catch (e) {
|
|
||||||
return { success: false, error: e.toString() };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { success: true, message: 'no cleanup function' };
|
|
||||||
})()
|
|
||||||
`
|
|
||||||
|
|
||||||
result, err := vm.RunString(script)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
for _, raw := range manifest.RequiredRuntimeFeatures {
|
||||||
if result != nil && !goja.IsUndefined(result) {
|
name := strings.TrimSpace(raw)
|
||||||
exported := result.Export()
|
if name == "" {
|
||||||
if resultMap, ok := exported.(map[string]any); ok {
|
continue
|
||||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
}
|
||||||
errMsg := "unknown error"
|
wantVersion := 1
|
||||||
if e, ok := resultMap["error"].(string); ok {
|
if at := strings.LastIndex(name, "@"); at > 0 {
|
||||||
errMsg = e
|
if v, err := strconv.Atoi(name[at+1:]); err == nil && v > 0 {
|
||||||
}
|
wantVersion = v
|
||||||
return fmt.Errorf("cleanup failed: %s", errMsg)
|
|
||||||
}
|
}
|
||||||
|
name = name[:at]
|
||||||
|
}
|
||||||
|
have, ok := supportedRuntimeFeatures[name]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("requires runtime feature %q this app build does not provide", name)
|
||||||
|
}
|
||||||
|
if have < wantVersion {
|
||||||
|
return fmt.Errorf("requires runtime feature %s@%d (app provides @%d)", name, wantVersion, have)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func teardownVMLocked(ext *loadedExtension) {
|
|
||||||
drainIsolatedRuntimePool(ext)
|
|
||||||
if err := runCleanupLocked(ext); err != nil {
|
|
||||||
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
|
|
||||||
}
|
|
||||||
if ext.runtime != nil {
|
|
||||||
if err := ext.runtime.flushStorageNow(); err != nil {
|
|
||||||
GoLog("[Extension] Failed to flush storage for %s: %v\n", ext.ID, err)
|
|
||||||
}
|
|
||||||
ext.runtime.closeStorageFlusher()
|
|
||||||
}
|
|
||||||
ext.runtime = nil
|
|
||||||
ext.VM = nil
|
|
||||||
ext.initialized = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateExtensionLoad(ext *loadedExtension) error {
|
func validateExtensionLoad(ext *loadedExtension) error {
|
||||||
|
if err := validateManifestGates(ext.Manifest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
ext.VMMu.Lock()
|
ext.VMMu.Lock()
|
||||||
defer ext.VMMu.Unlock()
|
defer ext.VMMu.Unlock()
|
||||||
|
|
||||||
@@ -1006,37 +551,9 @@ func (m *extensionManager) upgradeExtensionLocked(filePath string) (*loadedExten
|
|||||||
}
|
}
|
||||||
defer zipReader.Close()
|
defer zipReader.Close()
|
||||||
|
|
||||||
var manifestData []byte
|
newManifest, err := inspectExtensionPackage(zipReader.File)
|
||||||
var hasIndexJS bool
|
|
||||||
for _, file := range zipReader.File {
|
|
||||||
name := filepath.Base(file.Name)
|
|
||||||
if name == "manifest.json" {
|
|
||||||
rc, err := file.Open()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to open manifest.json: %w", err)
|
|
||||||
}
|
|
||||||
manifestData, err = io.ReadAll(rc)
|
|
||||||
rc.Close()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read manifest.json: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if name == "index.js" {
|
|
||||||
hasIndexJS = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if manifestData == nil {
|
|
||||||
return nil, fmt.Errorf("invalid extension package: manifest.json not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hasIndexJS {
|
|
||||||
return nil, fmt.Errorf("invalid extension package: index.js not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
newManifest, err := ParseManifest(manifestData)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid extension manifest: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
@@ -1159,30 +676,9 @@ func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
|||||||
}
|
}
|
||||||
defer zipReader.Close()
|
defer zipReader.Close()
|
||||||
|
|
||||||
var manifestData []byte
|
newManifest, err := inspectExtensionPackage(zipReader.File)
|
||||||
for _, file := range zipReader.File {
|
|
||||||
name := filepath.Base(file.Name)
|
|
||||||
if name == "manifest.json" {
|
|
||||||
rc, err := file.Open()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to open manifest.json")
|
|
||||||
}
|
|
||||||
manifestData, err = io.ReadAll(rc)
|
|
||||||
rc.Close()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read manifest.json")
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if manifestData == nil {
|
|
||||||
return nil, fmt.Errorf("manifest.json not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
newManifest, err := ParseManifest(manifestData)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid manifest: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func compareVersions(v1, v2 string) int {
|
||||||
|
parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".")
|
||||||
|
parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".")
|
||||||
|
|
||||||
|
maxLen := len(parts1)
|
||||||
|
if len(parts2) > maxLen {
|
||||||
|
maxLen = len(parts2)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < maxLen; i++ {
|
||||||
|
var n1, n2 int
|
||||||
|
if i < len(parts1) {
|
||||||
|
n1, _ = strconv.Atoi(parts1[i])
|
||||||
|
}
|
||||||
|
if i < len(parts2) {
|
||||||
|
n2, _ = strconv.Atoi(parts2[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
if n1 < n2 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if n1 > n2 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func isExtensionPackagePath(filePath string) bool {
|
||||||
|
lowerPath := strings.ToLower(filePath)
|
||||||
|
return strings.HasSuffix(lowerPath, ".spotiflac-ext") || strings.HasSuffix(lowerPath, ".sflx")
|
||||||
|
}
|
||||||
|
|
||||||
|
func managedExtensionPath(root, extensionID string) (string, error) {
|
||||||
|
if root == "" {
|
||||||
|
return "", fmt.Errorf("extension directory is not configured")
|
||||||
|
}
|
||||||
|
if !extensionIDPattern.MatchString(extensionID) {
|
||||||
|
return "", fmt.Errorf("invalid extension ID %q", extensionID)
|
||||||
|
}
|
||||||
|
fullPath := filepath.Join(root, extensionID)
|
||||||
|
if !isPathWithinBase(root, fullPath) {
|
||||||
|
return "", fmt.Errorf("extension path escapes its managed directory")
|
||||||
|
}
|
||||||
|
return fullPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeExtensionAssetPath(root, assetPath string) (string, bool) {
|
||||||
|
if root == "" || assetPath == "" || filepath.IsAbs(assetPath) || strings.Contains(assetPath, `\`) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
cleaned := path.Clean(assetPath)
|
||||||
|
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
fullPath := filepath.Join(root, filepath.FromSlash(cleaned))
|
||||||
|
return fullPath, isPathWithinBase(root, fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxExtensionArchiveEntries = 2048
|
||||||
|
maxExtensionArchiveUncompressedBytes = 256 * 1024 * 1024
|
||||||
|
maxExtensionManifestBytes = 1024 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
func validateExtensionArchive(files []*zip.File) error {
|
||||||
|
if len(files) > maxExtensionArchiveEntries {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"extension archive contains too many entries (maximum %d)",
|
||||||
|
maxExtensionArchiveEntries,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
seenPaths := make(map[string]struct{}, len(files))
|
||||||
|
var totalUncompressed uint64
|
||||||
|
for _, file := range files {
|
||||||
|
if file.FileInfo().Mode()&os.ModeSymlink != 0 || strings.Contains(file.Name, `\`) {
|
||||||
|
return fmt.Errorf("unsafe path in extension archive: %s", file.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath := path.Clean(file.Name)
|
||||||
|
if relPath == "." || relPath == ".." || strings.HasPrefix(relPath, "../") || path.IsAbs(relPath) {
|
||||||
|
return fmt.Errorf("unsafe path in extension archive: %s", file.Name)
|
||||||
|
}
|
||||||
|
pathKey := strings.ToLower(relPath)
|
||||||
|
if _, exists := seenPaths[pathKey]; exists {
|
||||||
|
return fmt.Errorf("duplicate path in extension archive: %s", file.Name)
|
||||||
|
}
|
||||||
|
seenPaths[pathKey] = struct{}{}
|
||||||
|
|
||||||
|
if file.FileInfo().IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if file.UncompressedSize64 > maxExtensionArchiveUncompressedBytes-totalUncompressed {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"extension archive exceeds the %d MiB extracted size limit",
|
||||||
|
maxExtensionArchiveUncompressedBytes/(1024*1024),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
totalUncompressed += file.UncompressedSize64
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspectExtensionPackage(files []*zip.File) (*ExtensionManifest, error) {
|
||||||
|
if err := validateExtensionArchive(files); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifestFile *zip.File
|
||||||
|
hasIndexJS := false
|
||||||
|
for _, file := range files {
|
||||||
|
switch path.Clean(file.Name) {
|
||||||
|
case "manifest.json":
|
||||||
|
manifestFile = file
|
||||||
|
case "index.js":
|
||||||
|
hasIndexJS = !file.FileInfo().IsDir()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if manifestFile == nil || manifestFile.FileInfo().IsDir() {
|
||||||
|
return nil, fmt.Errorf("invalid extension package: root manifest.json not found")
|
||||||
|
}
|
||||||
|
if !hasIndexJS {
|
||||||
|
return nil, fmt.Errorf("invalid extension package: root index.js not found")
|
||||||
|
}
|
||||||
|
if manifestFile.UncompressedSize64 > maxExtensionManifestBytes {
|
||||||
|
return nil, fmt.Errorf("invalid extension package: manifest.json is too large")
|
||||||
|
}
|
||||||
|
|
||||||
|
rc, err := manifestFile.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open manifest.json: %w", err)
|
||||||
|
}
|
||||||
|
manifestData, readErr := io.ReadAll(io.LimitReader(rc, maxExtensionManifestBytes+1))
|
||||||
|
closeErr := rc.Close()
|
||||||
|
if readErr != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read manifest.json: %w", readErr)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return nil, fmt.Errorf("failed to close manifest.json: %w", closeErr)
|
||||||
|
}
|
||||||
|
if len(manifestData) > maxExtensionManifestBytes {
|
||||||
|
return nil, fmt.Errorf("invalid extension package: manifest.json is too large")
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, err := ParseManifest(manifestData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid extension manifest: %w", err)
|
||||||
|
}
|
||||||
|
return manifest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractExtensionArchive(zipReader *zip.ReadCloser, destination string) error {
|
||||||
|
if err := validateExtensionArchive(zipReader.File); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, file := range zipReader.File {
|
||||||
|
if file.FileInfo().IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if file.FileInfo().Mode()&os.ModeSymlink != 0 || strings.Contains(file.Name, `\`) {
|
||||||
|
return fmt.Errorf("unsafe path in extension archive: %s", file.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath := path.Clean(file.Name)
|
||||||
|
if relPath == "." || relPath == ".." || strings.HasPrefix(relPath, "../") || path.IsAbs(relPath) {
|
||||||
|
return fmt.Errorf("unsafe path in extension archive: %s", file.Name)
|
||||||
|
}
|
||||||
|
destPath := filepath.Join(destination, filepath.FromSlash(relPath))
|
||||||
|
if !isPathWithinBase(destination, destPath) {
|
||||||
|
return fmt.Errorf("unsafe path in extension archive: %s", file.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create extension directory: %w", err)
|
||||||
|
}
|
||||||
|
destFile, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create extension file: %w", err)
|
||||||
|
}
|
||||||
|
srcFile, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
destFile.Close()
|
||||||
|
return fmt.Errorf("failed to open file in archive: %w", err)
|
||||||
|
}
|
||||||
|
_, copyErr := io.Copy(destFile, srcFile)
|
||||||
|
closeSrcErr := srcFile.Close()
|
||||||
|
closeDestErr := destFile.Close()
|
||||||
|
if copyErr != nil {
|
||||||
|
return fmt.Errorf("failed to extract extension file: %w", copyErr)
|
||||||
|
}
|
||||||
|
if closeSrcErr != nil || closeDestErr != nil {
|
||||||
|
return fmt.Errorf("failed to close extracted extension file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
func initializeVMLocked(ext *loadedExtension) error {
|
||||||
|
ext.VM = nil
|
||||||
|
ext.runtime = nil
|
||||||
|
ext.indexProgram = nil
|
||||||
|
ext.initialized = false
|
||||||
|
vm := goja.New()
|
||||||
|
ext.VM = vm
|
||||||
|
|
||||||
|
indexPath := filepath.Join(ext.SourceDir, "index.js")
|
||||||
|
jsCode, err := os.ReadFile(indexPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read index.js: %w", err)
|
||||||
|
}
|
||||||
|
indexProgram, err := goja.Compile(indexPath, string(jsCode), false)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to compile extension code: %w", err)
|
||||||
|
}
|
||||||
|
ext.indexProgram = indexProgram
|
||||||
|
|
||||||
|
runtime := newExtensionRuntime(ext)
|
||||||
|
ext.runtime = runtime
|
||||||
|
runtime.RegisterAPIs(vm)
|
||||||
|
runtime.RegisterGoBackendAPIs(vm)
|
||||||
|
|
||||||
|
console := vm.NewObject()
|
||||||
|
console.Set("log", func(call goja.FunctionCall) goja.Value {
|
||||||
|
args := make([]any, len(call.Arguments))
|
||||||
|
for i, arg := range call.Arguments {
|
||||||
|
args[i] = arg.Export()
|
||||||
|
}
|
||||||
|
GoLog("[Extension:%s] %v\n", ext.ID, args)
|
||||||
|
return goja.Undefined()
|
||||||
|
})
|
||||||
|
vm.Set("console", console)
|
||||||
|
|
||||||
|
var registeredExtension goja.Value
|
||||||
|
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) > 0 {
|
||||||
|
registeredExtension = call.Arguments[0]
|
||||||
|
vm.Set("extension", call.Arguments[0])
|
||||||
|
}
|
||||||
|
return goja.Undefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err = vm.RunProgram(indexProgram)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to execute extension code: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if registeredExtension == nil || goja.IsUndefined(registeredExtension) {
|
||||||
|
return fmt.Errorf("extension did not call registerExtension()")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) {
|
||||||
|
vm := goja.New()
|
||||||
|
|
||||||
|
indexProgram := ext.indexProgram
|
||||||
|
if indexProgram == nil {
|
||||||
|
indexPath := filepath.Join(ext.SourceDir, "index.js")
|
||||||
|
jsCode, err := os.ReadFile(indexPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to read index.js: %w", err)
|
||||||
|
}
|
||||||
|
indexProgram, err = goja.Compile(indexPath, string(jsCode), false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to compile extension code: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := &extensionRuntime{
|
||||||
|
extensionID: ext.ID,
|
||||||
|
manifest: ext.Manifest,
|
||||||
|
settings: make(map[string]any),
|
||||||
|
cookieJar: nil,
|
||||||
|
dataDir: ext.DataDir,
|
||||||
|
vm: vm,
|
||||||
|
}
|
||||||
|
if ext.runtime != nil && ext.runtime.cookieJar != nil {
|
||||||
|
runtime.cookieJar = ext.runtime.cookieJar
|
||||||
|
} else {
|
||||||
|
jar, _ := newSimpleCookieJar()
|
||||||
|
runtime.cookieJar = jar
|
||||||
|
}
|
||||||
|
runtime.httpClient = newExtensionHTTPClient(ext, runtime.cookieJar, extensionHTTPTimeout(ext, 30*time.Second), true)
|
||||||
|
runtime.downloadClient = newExtensionHTTPClient(ext, runtime.cookieJar, DownloadTimeout, false)
|
||||||
|
runtime.RegisterAPIs(vm)
|
||||||
|
runtime.RegisterGoBackendAPIs(vm)
|
||||||
|
|
||||||
|
console := vm.NewObject()
|
||||||
|
console.Set("log", func(call goja.FunctionCall) goja.Value {
|
||||||
|
args := make([]any, len(call.Arguments))
|
||||||
|
for i, arg := range call.Arguments {
|
||||||
|
args[i] = arg.Export()
|
||||||
|
}
|
||||||
|
GoLog("[Extension:%s] %v\n", ext.ID, args)
|
||||||
|
return goja.Undefined()
|
||||||
|
})
|
||||||
|
vm.Set("console", console)
|
||||||
|
|
||||||
|
var registeredExtension goja.Value
|
||||||
|
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) > 0 {
|
||||||
|
registeredExtension = call.Arguments[0]
|
||||||
|
vm.Set("extension", call.Arguments[0])
|
||||||
|
}
|
||||||
|
return goja.Undefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := vm.RunProgram(indexProgram); err != nil {
|
||||||
|
runtime.closeStorageFlusher()
|
||||||
|
return nil, nil, fmt.Errorf("failed to execute extension code: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if registeredExtension == nil || goja.IsUndefined(registeredExtension) {
|
||||||
|
runtime.closeStorageFlusher()
|
||||||
|
return nil, nil, fmt.Errorf("extension did not call registerExtension()")
|
||||||
|
}
|
||||||
|
|
||||||
|
settings := getExtensionInitSettings(ext.ID)
|
||||||
|
if len(settings) > 0 {
|
||||||
|
if err := initializeExtensionRuntimeWithSettings(vm, ext.ID, settings); err != nil {
|
||||||
|
runtime.closeStorageFlusher()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return vm, runtime, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// A goja runtime plus an executed extension program is several MB of live
|
||||||
|
// heap; rebuilding one per download multiplies that by the number of tracks.
|
||||||
|
// Extensions already serve many calls on the persistent shared VM, so reusing
|
||||||
|
// an initialized isolated runtime for consecutive downloads is the same
|
||||||
|
// lifecycle contract.
|
||||||
|
const maxIdleIsolatedRuntimes = 1
|
||||||
|
|
||||||
|
// acquireIsolatedExtensionRuntime pops an idle pooled runtime or builds one.
|
||||||
|
func acquireIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) {
|
||||||
|
ext.isolatedPoolMu.Lock()
|
||||||
|
if n := len(ext.isolatedPool); n > 0 {
|
||||||
|
handle := ext.isolatedPool[n-1]
|
||||||
|
ext.isolatedPool = ext.isolatedPool[:n-1]
|
||||||
|
ext.isolatedPoolMu.Unlock()
|
||||||
|
return handle.vm, handle.runtime, nil
|
||||||
|
}
|
||||||
|
ext.isolatedPoolMu.Unlock()
|
||||||
|
|
||||||
|
ext.VMMu.Lock()
|
||||||
|
defer ext.VMMu.Unlock()
|
||||||
|
return newIsolatedExtensionRuntime(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// releaseIsolatedExtensionRuntime pools a healthy runtime for reuse or tears
|
||||||
|
// it down. Pass healthy=false after an interrupt/timeout/script error, whose
|
||||||
|
// VM state can't be trusted for reuse.
|
||||||
|
func releaseIsolatedExtensionRuntime(ext *loadedExtension, vm *goja.Runtime, runtime *extensionRuntime, healthy, cleanupSafe bool) {
|
||||||
|
if runtime != nil {
|
||||||
|
if err := runtime.flushStorageNow(); err != nil {
|
||||||
|
GoLog("[Extension:%s] isolated download storage flush failed: %v\n", ext.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if healthy && vm != nil && runtime != nil && ext.Enabled {
|
||||||
|
ext.isolatedPoolMu.Lock()
|
||||||
|
if len(ext.isolatedPool) < maxIdleIsolatedRuntimes {
|
||||||
|
ext.isolatedPool = append(ext.isolatedPool, &isolatedRuntimeHandle{vm: vm, runtime: runtime})
|
||||||
|
ext.isolatedPoolMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ext.isolatedPoolMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
if cleanupSafe {
|
||||||
|
if cleanupErr := runCleanupOnVM(vm); cleanupErr != nil {
|
||||||
|
GoLog("[Extension:%s] isolated download cleanup failed: %v\n", ext.ID, cleanupErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if runtime != nil {
|
||||||
|
runtime.closeStorageFlusher()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// quarantineRuntimeLocked detaches a VM that remained busy after interrupt.
|
||||||
|
// The caller holds VMMu. Touching or cleaning up that VM would race its stuck
|
||||||
|
// goroutine; a later call will build a fresh runtime from indexProgram.
|
||||||
|
func quarantineRuntimeLocked(ext *loadedExtension, vm *goja.Runtime) {
|
||||||
|
if ext == nil || ext.VM != vm {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ext.VM = nil
|
||||||
|
ext.runtime = nil
|
||||||
|
ext.initialized = false
|
||||||
|
ext.Error = "extension runtime was quarantined after an unresponsive script"
|
||||||
|
}
|
||||||
|
|
||||||
|
// drainIsolatedRuntimePool tears down idle isolated runtimes. Called on
|
||||||
|
// extension teardown and on app-wide memory release.
|
||||||
|
func drainIsolatedRuntimePool(ext *loadedExtension) {
|
||||||
|
ext.isolatedPoolMu.Lock()
|
||||||
|
pool := ext.isolatedPool
|
||||||
|
ext.isolatedPool = nil
|
||||||
|
ext.isolatedPoolMu.Unlock()
|
||||||
|
|
||||||
|
for _, handle := range pool {
|
||||||
|
if cleanupErr := runCleanupOnVM(handle.vm); cleanupErr != nil {
|
||||||
|
GoLog("[Extension:%s] isolated pool cleanup failed: %v\n", ext.ID, cleanupErr)
|
||||||
|
}
|
||||||
|
if handle.runtime != nil {
|
||||||
|
if err := handle.runtime.flushStorageNow(); err != nil {
|
||||||
|
GoLog("[Extension:%s] isolated pool storage flush failed: %v\n", ext.ID, err)
|
||||||
|
}
|
||||||
|
handle.runtime.closeStorageFlusher()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// drainAllIsolatedRuntimePools releases every extension's idle isolated
|
||||||
|
// runtimes (memory-pressure hook).
|
||||||
|
func drainAllIsolatedRuntimePools() {
|
||||||
|
m := getExtensionManager()
|
||||||
|
m.mu.RLock()
|
||||||
|
exts := make([]*loadedExtension, 0, len(m.extensions))
|
||||||
|
for _, ext := range m.extensions {
|
||||||
|
exts = append(exts, ext)
|
||||||
|
}
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, ext := range exts {
|
||||||
|
drainIsolatedRuntimePool(ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *extensionManager) initializeVM(ext *loadedExtension) error {
|
||||||
|
ext.VMMu.Lock()
|
||||||
|
defer ext.VMMu.Unlock()
|
||||||
|
return initializeVMLocked(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func initializeExtensionRuntimeWithSettings(
|
||||||
|
vm *goja.Runtime,
|
||||||
|
extensionID string,
|
||||||
|
settings map[string]any,
|
||||||
|
) error {
|
||||||
|
settingsJSON, err := json.Marshal(settings)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to save settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
(function() {
|
||||||
|
var settings = %s;
|
||||||
|
if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
|
||||||
|
try {
|
||||||
|
extension.initialize(settings);
|
||||||
|
return { success: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: e.toString() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true, message: 'no initialize function' };
|
||||||
|
})()
|
||||||
|
`, string(settingsJSON))
|
||||||
|
|
||||||
|
result, err := vm.RunString(script)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != nil && !goja.IsUndefined(result) {
|
||||||
|
exported := result.Export()
|
||||||
|
if resultMap, ok := exported.(map[string]any); ok {
|
||||||
|
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||||
|
errMsg := "unknown error"
|
||||||
|
if e, ok := resultMap["error"].(string); ok {
|
||||||
|
errMsg = e
|
||||||
|
}
|
||||||
|
GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, errMsg)
|
||||||
|
return fmt.Errorf("initialize failed: %s", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func initializeExtensionWithSettingsLocked(
|
||||||
|
ext *loadedExtension,
|
||||||
|
settings map[string]any,
|
||||||
|
) error {
|
||||||
|
if ext.VM == nil {
|
||||||
|
return fmt.Errorf("extension failed to load: please reinstall the extension")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := initializeExtensionRuntimeWithSettings(ext.VM, ext.ID, settings); err != nil {
|
||||||
|
ext.Error = err.Error()
|
||||||
|
ext.Enabled = false
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ext.initialized = true
|
||||||
|
GoLog("[Extension] Initialized %s\n", ext.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCleanupLocked(ext *loadedExtension) error {
|
||||||
|
if ext.VM != nil {
|
||||||
|
if err := runCleanupOnVM(ext.VM); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ext.VM.Get("extension") != nil {
|
||||||
|
GoLog("[Extension] Cleanup called for %s\n", ext.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCleanupOnVM(vm *goja.Runtime) error {
|
||||||
|
if vm == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
script := `
|
||||||
|
(function() {
|
||||||
|
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
||||||
|
try {
|
||||||
|
extension.cleanup();
|
||||||
|
return { success: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: e.toString() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true, message: 'no cleanup function' };
|
||||||
|
})()
|
||||||
|
`
|
||||||
|
|
||||||
|
result, err := vm.RunString(script)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != nil && !goja.IsUndefined(result) {
|
||||||
|
exported := result.Export()
|
||||||
|
if resultMap, ok := exported.(map[string]any); ok {
|
||||||
|
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||||
|
errMsg := "unknown error"
|
||||||
|
if e, ok := resultMap["error"].(string); ok {
|
||||||
|
errMsg = e
|
||||||
|
}
|
||||||
|
return fmt.Errorf("cleanup failed: %s", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func teardownVMLocked(ext *loadedExtension) {
|
||||||
|
drainIsolatedRuntimePool(ext)
|
||||||
|
if err := runCleanupLocked(ext); err != nil {
|
||||||
|
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
|
||||||
|
}
|
||||||
|
if ext.runtime != nil {
|
||||||
|
if err := ext.runtime.flushStorageNow(); err != nil {
|
||||||
|
GoLog("[Extension] Failed to flush storage for %s: %v\n", ext.ID, err)
|
||||||
|
}
|
||||||
|
ext.runtime.closeStorageFlusher()
|
||||||
|
}
|
||||||
|
ext.runtime = nil
|
||||||
|
ext.VM = nil
|
||||||
|
ext.initialized = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// supportedRuntimeFeatures maps every feature name the goja runtime provides
|
||||||
|
// to its current contract version (documented in SIGNED_SESSION_GUIDE.md).
|
||||||
@@ -155,3 +155,41 @@ registerExtension({
|
|||||||
t.Fatal("expected all extensions unloaded")
|
t.Fatal("expected all extensions unloaded")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateManifestGates(t *testing.T) {
|
||||||
|
originalVersion := GetAppVersion()
|
||||||
|
defer SetAppVersion(originalVersion)
|
||||||
|
SetAppVersion("4.5.0")
|
||||||
|
|
||||||
|
if err := validateManifestGates(nil); err != nil {
|
||||||
|
t.Fatalf("nil manifest = %v", err)
|
||||||
|
}
|
||||||
|
if err := validateManifestGates(&ExtensionManifest{MinAppVersion: "4.9.0"}); err == nil {
|
||||||
|
t.Fatal("expected minAppVersion gate to fail")
|
||||||
|
}
|
||||||
|
if err := validateManifestGates(&ExtensionManifest{MinAppVersion: "4.5.0"}); err != nil {
|
||||||
|
t.Fatalf("equal version should pass: %v", err)
|
||||||
|
}
|
||||||
|
SetAppVersion("")
|
||||||
|
if err := validateManifestGates(&ExtensionManifest{MinAppVersion: "9.9.9"}); err != nil {
|
||||||
|
t.Fatalf("empty app version must skip the gate: %v", err)
|
||||||
|
}
|
||||||
|
SetAppVersion("4.5.0")
|
||||||
|
|
||||||
|
pass := &ExtensionManifest{
|
||||||
|
RequiredRuntimeFeatures: []string{"signedSession@3", "sessionGrant"},
|
||||||
|
}
|
||||||
|
if err := validateManifestGates(pass); err != nil {
|
||||||
|
t.Fatalf("supported features should pass: %v", err)
|
||||||
|
}
|
||||||
|
if err := validateManifestGates(&ExtensionManifest{
|
||||||
|
RequiredRuntimeFeatures: []string{"quantumDecrypt"},
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("unknown feature must fail")
|
||||||
|
}
|
||||||
|
if err := validateManifestGates(&ExtensionManifest{
|
||||||
|
RequiredRuntimeFeatures: []string{"signedSession@99"},
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("future contract version must fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -371,17 +371,57 @@ func (m *ExtensionManifest) HasURLHandler() bool {
|
|||||||
return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0
|
return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MatchesURL reports whether one of the handler's patterns matches the URL.
|
||||||
|
// Web patterns are anchored to the URL's host (exact domain or subdomain,
|
||||||
|
// optional path prefix) — never matched as a raw substring, so "spotify.com"
|
||||||
|
// cannot match a URL that merely embeds it in a query parameter. Patterns
|
||||||
|
// ending in ":" (e.g. "spotify:") match custom URI schemes as prefixes.
|
||||||
func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
|
func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
|
||||||
if !m.HasURLHandler() {
|
if !m.HasURLHandler() {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
urlStr = strings.ToLower(strings.TrimSpace(urlStr))
|
urlStr = strings.ToLower(strings.TrimSpace(urlStr))
|
||||||
|
parsed, parseErr := url.Parse(urlStr)
|
||||||
|
|
||||||
for _, pattern := range m.URLHandler.Patterns {
|
for _, pattern := range m.URLHandler.Patterns {
|
||||||
pattern = strings.ToLower(strings.TrimSpace(pattern))
|
pattern = strings.ToLower(strings.TrimSpace(pattern))
|
||||||
if strings.Contains(urlStr, pattern) {
|
if pattern == "" {
|
||||||
return true
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scheme patterns anchor to the front of the URI.
|
||||||
|
if !strings.Contains(pattern, "/") && strings.HasSuffix(pattern, ":") {
|
||||||
|
if strings.HasPrefix(urlStr, pattern) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if parseErr != nil || parsed.Host == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
host := parsed.Hostname()
|
||||||
|
urlPath := parsed.Path
|
||||||
|
if urlPath == "" {
|
||||||
|
urlPath = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
if idx := strings.Index(pattern, "://"); idx >= 0 {
|
||||||
|
pattern = pattern[idx+3:]
|
||||||
|
}
|
||||||
|
patternHost, patternPath, hasPath := strings.Cut(pattern, "/")
|
||||||
|
if patternHost == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if host != patternHost && !strings.HasSuffix(host, "."+patternHost) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if hasPath && patternPath != "" &&
|
||||||
|
!strings.HasPrefix(urlPath, "/"+patternPath) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWriteVerifiedExtensionPackageAcceptsMatchingSHA256(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
dest := filepath.Join(dir, "verified.spotiflac-ext")
|
||||||
|
payload := []byte("extension package")
|
||||||
|
checksum := fmt.Sprintf("%x", sha256.Sum256(payload))
|
||||||
|
|
||||||
|
if err := writeVerifiedExtensionPackage(bytes.NewReader(payload), dest, checksum); err != nil {
|
||||||
|
t.Fatalf("writeVerifiedExtensionPackage: %v", err)
|
||||||
|
}
|
||||||
|
got, err := os.ReadFile(dest)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read verified package: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got, payload) {
|
||||||
|
t.Fatalf("verified package = %q, want %q", got, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteVerifiedExtensionPackageRejectsMismatchBeforeReplace(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
dest := filepath.Join(dir, "cached.spotiflac-ext")
|
||||||
|
if err := os.WriteFile(dest, []byte("existing"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := writeVerifiedExtensionPackage(
|
||||||
|
bytes.NewReader([]byte("tampered")),
|
||||||
|
dest,
|
||||||
|
strings.Repeat("0", sha256.Size*2),
|
||||||
|
)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "SHA-256 mismatch") {
|
||||||
|
t.Fatalf("expected checksum mismatch, got %v", err)
|
||||||
|
}
|
||||||
|
got, readErr := os.ReadFile(dest)
|
||||||
|
if readErr != nil {
|
||||||
|
t.Fatal(readErr)
|
||||||
|
}
|
||||||
|
if string(got) != "existing" {
|
||||||
|
t.Fatalf("checksum failure replaced existing package with %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistrySkipsOnlyExtensionWithMalformedChecksum(t *testing.T) {
|
||||||
|
checksum := strings.Repeat("a", sha256.Size*2)
|
||||||
|
registry, err := parseRegistryBody([]byte(
|
||||||
|
`{"version":1,"extensions":[` +
|
||||||
|
`{"id":"bad","name":"bad","version":"1.0.0","sha256":"not-a-hash"},` +
|
||||||
|
`{"id":"verified","name":"verified","version":"1.0.0","checksumSha256":"sha256:` +
|
||||||
|
checksum +
|
||||||
|
`"},` +
|
||||||
|
`{"id":"legacy","name":"legacy","version":"1.0.0"}` +
|
||||||
|
`]}`,
|
||||||
|
))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse registry: %v", err)
|
||||||
|
}
|
||||||
|
if len(registry.Extensions) != 2 {
|
||||||
|
t.Fatalf("registry extensions = %#v, want valid entries only", registry.Extensions)
|
||||||
|
}
|
||||||
|
if registry.Extensions[0].ID != "verified" || registry.Extensions[1].ID != "legacy" {
|
||||||
|
t.Fatalf("registry extension order = %#v", registry.Extensions)
|
||||||
|
}
|
||||||
|
if got := registry.Extensions[0].getSHA256(); got != checksum {
|
||||||
|
t.Fatalf("normalized checksum = %q, want %q", got, checksum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionPackageRequiresUniqueRootEntrypoints(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
duplicate := filepath.Join(dir, "duplicate.spotiflac-ext")
|
||||||
|
createTestExtensionPackage(
|
||||||
|
t,
|
||||||
|
duplicate,
|
||||||
|
"duplicate-ext",
|
||||||
|
"1.0.0",
|
||||||
|
`registerExtension({});`,
|
||||||
|
map[string]string{"MANIFEST.JSON": "{}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
reader, err := zip.OpenReader(duplicate)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, inspectErr := inspectExtensionPackage(reader.File)
|
||||||
|
_ = reader.Close()
|
||||||
|
if inspectErr == nil || !strings.Contains(inspectErr.Error(), "duplicate path") {
|
||||||
|
t.Fatalf("expected duplicate archive path error, got %v", inspectErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
nested := filepath.Join(dir, "nested.spotiflac-ext")
|
||||||
|
writeTestZip(t, nested, map[string]string{
|
||||||
|
"nested/manifest.json": validSecurityTestManifest("nested-ext"),
|
||||||
|
"nested/index.js": `registerExtension({});`,
|
||||||
|
})
|
||||||
|
reader, err = zip.OpenReader(nested)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, inspectErr = inspectExtensionPackage(reader.File)
|
||||||
|
_ = reader.Close()
|
||||||
|
if inspectErr == nil || !strings.Contains(inspectErr.Error(), "root manifest.json") {
|
||||||
|
t.Fatalf("expected root entrypoint error, got %v", inspectErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionPackageRejectsUnsafeAndOversizedEntries(t *testing.T) {
|
||||||
|
for _, unsafePath := range []string{
|
||||||
|
"../outside.js",
|
||||||
|
`nested\outside.js`,
|
||||||
|
"/absolute.js",
|
||||||
|
} {
|
||||||
|
t.Run(unsafePath, func(t *testing.T) {
|
||||||
|
archivePath := filepath.Join(t.TempDir(), "unsafe.spotiflac-ext")
|
||||||
|
writeTestZip(t, archivePath, map[string]string{
|
||||||
|
"manifest.json": validSecurityTestManifest("unsafe-ext"),
|
||||||
|
"index.js": `registerExtension({});`,
|
||||||
|
unsafePath: "unsafe",
|
||||||
|
})
|
||||||
|
reader, err := zip.OpenReader(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, inspectErr := inspectExtensionPackage(reader.File)
|
||||||
|
_ = reader.Close()
|
||||||
|
if inspectErr == nil || !strings.Contains(inspectErr.Error(), "unsafe path") {
|
||||||
|
t.Fatalf("expected unsafe archive path error, got %v", inspectErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
oversized := &zip.File{FileHeader: zip.FileHeader{
|
||||||
|
Name: "payload.bin",
|
||||||
|
UncompressedSize64: maxExtensionArchiveUncompressedBytes + 1,
|
||||||
|
}}
|
||||||
|
if err := validateExtensionArchive([]*zip.File{oversized}); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "extracted size limit") {
|
||||||
|
t.Fatalf("expected extracted size error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validSecurityTestManifest(name string) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
`{"name":%q,"displayName":%q,"version":"1.0.0","description":"test","type":["metadata_provider"],"permissions":{}}`,
|
||||||
|
name,
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTestZip(t *testing.T, filePath string, files map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
output, err := os.Create(filePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
archive := zip.NewWriter(output)
|
||||||
|
for name, content := range files {
|
||||||
|
writer, createErr := archive.Create(name)
|
||||||
|
if createErr != nil {
|
||||||
|
t.Fatal(createErr)
|
||||||
|
}
|
||||||
|
if _, writeErr := writer.Write([]byte(content)); writeErr != nil {
|
||||||
|
t.Fatal(writeErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := archive.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := output.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,9 +92,9 @@ func TestExtensionProviderWrapperFullSurface(t *testing.T) {
|
|||||||
t.Fatalf("url result = %#v", urlResult)
|
t.Fatalf("url result = %#v", urlResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
post, err := provider.PostProcess(filepath.Join(t.TempDir(), "song.flac"), map[string]any{"title": "Song"}, "hook")
|
post, err := provider.PostProcessV2(PostProcessInput{Path: filepath.Join(t.TempDir(), "song.flac")}, map[string]any{"title": "Song"}, "hook")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("PostProcess: %v", err)
|
t.Fatalf("PostProcessV2: %v", err)
|
||||||
}
|
}
|
||||||
if !post.Success || post.BitDepth != 24 || post.SampleRate != 96000 {
|
if !post.Success || post.BitDepth != 24 || post.SampleRate != 96000 {
|
||||||
t.Fatalf("post = %#v", post)
|
t.Fatalf("post = %#v", post)
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ type ExtDownloadResult struct {
|
|||||||
BitDepth int `json:"bit_depth,omitempty"`
|
BitDepth int `json:"bit_depth,omitempty"`
|
||||||
SampleRate int `json:"sample_rate,omitempty"`
|
SampleRate int `json:"sample_rate,omitempty"`
|
||||||
AudioCodec string `json:"audio_codec,omitempty"`
|
AudioCodec string `json:"audio_codec,omitempty"`
|
||||||
|
DurationMS int `json:"duration_ms,omitempty"`
|
||||||
ErrorMessage string `json:"error_message,omitempty"`
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
ErrorType string `json:"error_type,omitempty"`
|
ErrorType string `json:"error_type,omitempty"`
|
||||||
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
|
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
|
||||||
|
|||||||
@@ -151,11 +151,15 @@ func invokeExtensionMethod(vm *goja.Runtime, method string, args ...any) (goja.V
|
|||||||
return goja.Null(), nil
|
return goja.Null(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return callable(extensionObject, gojaArgumentValues(vm, args)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func gojaArgumentValues(vm *goja.Runtime, args []any) []goja.Value {
|
||||||
values := make([]goja.Value, len(args))
|
values := make([]goja.Value, len(args))
|
||||||
for i, arg := range args {
|
for i, arg := range args {
|
||||||
values[i] = gojaArgumentValue(vm, arg)
|
values[i] = gojaArgumentValue(vm, arg)
|
||||||
}
|
}
|
||||||
return callable(extensionObject, values...)
|
return values
|
||||||
}
|
}
|
||||||
|
|
||||||
func gojaArgumentValue(vm *goja.Runtime, value any) goja.Value {
|
func gojaArgumentValue(vm *goja.Runtime, value any) goja.Value {
|
||||||
@@ -214,11 +218,7 @@ func invokeExtensionOrGlobal(vm *goja.Runtime, method string, args ...any) (goja
|
|||||||
if !ok {
|
if !ok {
|
||||||
return goja.Null(), nil
|
return goja.Null(), nil
|
||||||
}
|
}
|
||||||
values := make([]goja.Value, len(args))
|
return callable(vm.GlobalObject(), gojaArgumentValues(vm, args)...)
|
||||||
for i, arg := range args {
|
|
||||||
values[i] = gojaArgumentValue(vm, arg)
|
|
||||||
}
|
|
||||||
return callable(vm.GlobalObject(), values...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func extensionTrackInput(track *ExtTrackMetadata) map[string]any {
|
func extensionTrackInput(track *ExtTrackMetadata) map[string]any {
|
||||||
@@ -548,7 +548,7 @@ func (p *extensionProviderWrapper) CheckAvailabilityForItemID(isrc, trackName, a
|
|||||||
if !availability.Available && p.extension.runtime != nil {
|
if !availability.Available && p.extension.runtime != nil {
|
||||||
if p.extension.runtime.consumeVerificationRequired() != "" {
|
if p.extension.runtime.consumeVerificationRequired() != "" {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"VERIFY_REQUIRED: extension '%s' needs signed-session verification",
|
"verification_required: extension '%s' needs signed-session verification",
|
||||||
p.extension.ID,
|
p.extension.ID,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -847,11 +847,9 @@ func postProcessInputMap(input PostProcessInput) map[string]any {
|
|||||||
|
|
||||||
const PostProcessTimeout = 2 * time.Minute
|
const PostProcessTimeout = 2 * time.Minute
|
||||||
|
|
||||||
// postProcessCommon backs both PostProcess (V1) and PostProcessV2. V1 probes
|
// PostProcessV2 probes extension.postProcessV2 first, then falls back to the
|
||||||
// only extension.postProcess (its original contract: V2-only extensions are
|
// legacy extension.postProcess contract.
|
||||||
// not invoked via V1); V2 probes postProcessV2 first, then falls back to
|
func (p *extensionProviderWrapper) PostProcessV2(input PostProcessInput, metadata map[string]any, hookID string) (*PostProcessResult, error) {
|
||||||
// postProcess.
|
|
||||||
func (p *extensionProviderWrapper) postProcessCommon(input PostProcessInput, metadata map[string]any, hookID string, preferV2 bool) (*PostProcessResult, error) {
|
|
||||||
if !p.extension.Manifest.HasPostProcessing() {
|
if !p.extension.Manifest.HasPostProcessing() {
|
||||||
return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID)
|
||||||
}
|
}
|
||||||
@@ -861,19 +859,13 @@ func (p *extensionProviderWrapper) postProcessCommon(input PostProcessInput, met
|
|||||||
|
|
||||||
filePath := input.Path
|
filePath := input.Path
|
||||||
|
|
||||||
perfName := "postProcess"
|
perfName := "postProcessV2"
|
||||||
var invoke func(*goja.Runtime) (goja.Value, error)
|
inputMap := postProcessInputMap(input)
|
||||||
if preferV2 {
|
invoke := func(vm *goja.Runtime) (goja.Value, error) {
|
||||||
perfName = "postProcessV2"
|
if hasExtensionMethod(vm, "postProcessV2") {
|
||||||
inputMap := postProcessInputMap(input)
|
return invokeExtensionMethod(vm, "postProcessV2", inputMap, metadata, hookID)
|
||||||
invoke = func(vm *goja.Runtime) (goja.Value, error) {
|
|
||||||
if hasExtensionMethod(vm, "postProcessV2") {
|
|
||||||
return invokeExtensionMethod(vm, "postProcessV2", inputMap, metadata, hookID)
|
|
||||||
}
|
|
||||||
return invokeExtensionMethod(vm, "postProcess", filePath, metadata, hookID)
|
|
||||||
}
|
}
|
||||||
} else {
|
return invokeExtensionMethod(vm, "postProcess", filePath, metadata, hookID)
|
||||||
invoke = extensionMethodInvocation("postProcess", filePath, metadata, hookID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := callExtension(p, extCallOpts{
|
result, err := callExtension(p, extCallOpts{
|
||||||
@@ -898,14 +890,6 @@ func (p *extensionProviderWrapper) postProcessCommon(input PostProcessInput, met
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *extensionProviderWrapper) PostProcess(filePath string, metadata map[string]any, hookID string) (*PostProcessResult, error) {
|
|
||||||
return p.postProcessCommon(PostProcessInput{Path: filePath}, metadata, hookID, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *extensionProviderWrapper) PostProcessV2(input PostProcessInput, metadata map[string]any, hookID string) (*PostProcessResult, error) {
|
|
||||||
return p.postProcessCommon(input, metadata, hookID, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExtLyricsResult struct {
|
type ExtLyricsResult struct {
|
||||||
Lines []ExtLyricsLine `json:"lines"`
|
Lines []ExtLyricsLine `json:"lines"`
|
||||||
SyncType string `json:"syncType"`
|
SyncType string `json:"syncType"`
|
||||||
|
|||||||
@@ -34,49 +34,6 @@ func (m *extensionManager) GetDownloadProviders() []*extensionProviderWrapper {
|
|||||||
return providers
|
return providers
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *extensionManager) SearchTracksWithExtensions(query string, limit int) ([]ExtTrackMetadata, error) {
|
|
||||||
providers := m.GetMetadataProviders()
|
|
||||||
if len(providers) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
providerByID := make(map[string]*extensionProviderWrapper, len(providers))
|
|
||||||
orderedProviders := make([]*extensionProviderWrapper, 0, len(providers))
|
|
||||||
for _, provider := range providers {
|
|
||||||
providerByID[provider.extension.ID] = provider
|
|
||||||
}
|
|
||||||
for _, providerID := range GetMetadataProviderPriority() {
|
|
||||||
if provider := providerByID[providerID]; provider != nil {
|
|
||||||
orderedProviders = append(orderedProviders, provider)
|
|
||||||
delete(providerByID, providerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(providerByID) > 0 {
|
|
||||||
remainingIDs := make([]string, 0, len(providerByID))
|
|
||||||
for providerID := range providerByID {
|
|
||||||
remainingIDs = append(remainingIDs, providerID)
|
|
||||||
}
|
|
||||||
sort.Strings(remainingIDs)
|
|
||||||
for _, providerID := range remainingIDs {
|
|
||||||
orderedProviders = append(orderedProviders, providerByID[providerID])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var allTracks []ExtTrackMetadata
|
|
||||||
for _, provider := range orderedProviders {
|
|
||||||
result, err := provider.SearchTracks(query, limit)
|
|
||||||
if err != nil {
|
|
||||||
GoLog("[Extension] Search error from %s: %v\n", provider.extension.ID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if result != nil {
|
|
||||||
allTracks = append(allTracks, result.Tracks...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return allTracks, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func metadataTrackDedupKey(track ExtTrackMetadata) string {
|
func metadataTrackDedupKey(track ExtTrackMetadata) string {
|
||||||
if isrc := strings.TrimSpace(track.ISRC); isrc != "" {
|
if isrc := strings.TrimSpace(track.ISRC); isrc != "" {
|
||||||
return "isrc:" + strings.ToUpper(isrc)
|
return "isrc:" + strings.ToUpper(isrc)
|
||||||
@@ -94,6 +51,46 @@ func (m *extensionManager) SearchTracksWithMetadataProviders(query string, limit
|
|||||||
return m.SearchTracksWithMetadataProvidersForItemID(query, limit, includeExtensions, "")
|
return m.SearchTracksWithMetadataProvidersForItemID(query, limit, includeExtensions, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchTracksWithMetadataProvider searches one explicitly selected metadata
|
||||||
|
// provider. Unlike the priority-based search, this never falls through to a
|
||||||
|
// different extension, so callers can reliably attribute the returned fields
|
||||||
|
// to the provider selected by the user.
|
||||||
|
func (m *extensionManager) SearchTracksWithMetadataProvider(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
|
||||||
|
providerID = strings.TrimSpace(providerID)
|
||||||
|
if providerID == "" {
|
||||||
|
return nil, fmt.Errorf("metadata provider ID is required")
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
ext, err := m.GetExtension(providerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ext == nil || ext.Manifest == nil || !ext.Manifest.IsMetadataProvider() {
|
||||||
|
return nil, fmt.Errorf("extension '%s' is not a metadata provider", providerID)
|
||||||
|
}
|
||||||
|
if !ext.Enabled {
|
||||||
|
return nil, fmt.Errorf("extension '%s' is disabled", providerID)
|
||||||
|
}
|
||||||
|
if ext.Error != "" {
|
||||||
|
return nil, fmt.Errorf("extension '%s' is unavailable: %s", providerID, ext.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := newExtensionProviderWrapper(ext).SearchTracks(query, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if result == nil || len(result.Tracks) <= limit {
|
||||||
|
if result == nil {
|
||||||
|
return []ExtTrackMetadata{}, nil
|
||||||
|
}
|
||||||
|
return result.Tracks, nil
|
||||||
|
}
|
||||||
|
return result.Tracks[:limit], nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *extensionManager) SearchTracksWithMetadataProvidersForItemID(query string, limit int, includeExtensions bool, itemID string) ([]ExtTrackMetadata, error) {
|
func (m *extensionManager) SearchTracksWithMetadataProvidersForItemID(query string, limit int, includeExtensions bool, itemID string) ([]ExtTrackMetadata, error) {
|
||||||
priority := GetMetadataProviderPriority()
|
priority := GetMetadataProviderPriority()
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
@@ -176,42 +173,44 @@ func (m *extensionManager) SearchTracksWithMetadataProvidersForItemID(query stri
|
|||||||
return tracks, nil
|
return tracks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *extensionManager) GetSearchProviders() []*extensionProviderWrapper {
|
// FindURLHandler returns the enabled handler matching the URL. When several
|
||||||
m.mu.RLock()
|
// extensions match (e.g. two Spotify handlers), the user's metadata provider
|
||||||
defer m.mu.RUnlock()
|
// priority breaks the tie deterministically instead of Go's random map
|
||||||
|
// iteration order.
|
||||||
var providers []*extensionProviderWrapper
|
|
||||||
for _, ext := range m.extensions {
|
|
||||||
if ext.Enabled && ext.Manifest.HasCustomSearch() && ext.Error == "" {
|
|
||||||
providers = append(providers, newExtensionProviderWrapper(ext))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return providers
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *extensionManager) GetURLHandlers() []*extensionProviderWrapper {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
|
|
||||||
var providers []*extensionProviderWrapper
|
|
||||||
for _, ext := range m.extensions {
|
|
||||||
if ext.Enabled && ext.Manifest.HasURLHandler() && ext.Error == "" {
|
|
||||||
providers = append(providers, newExtensionProviderWrapper(ext))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return providers
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *extensionManager) FindURLHandler(url string) *extensionProviderWrapper {
|
func (m *extensionManager) FindURLHandler(url string) *extensionProviderWrapper {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
matches := make([]*loadedExtension, 0, 2)
|
||||||
|
|
||||||
for _, ext := range m.extensions {
|
for _, ext := range m.extensions {
|
||||||
if ext.Enabled && ext.Manifest.MatchesURL(url) && ext.Error == "" {
|
if ext.Enabled && ext.Manifest.MatchesURL(url) && ext.Error == "" {
|
||||||
return newExtensionProviderWrapper(ext)
|
matches = append(matches, ext)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(matches) > 1 {
|
||||||
|
rank := map[string]int{}
|
||||||
|
for i, id := range GetMetadataProviderPriority() {
|
||||||
|
rank[strings.ToLower(strings.TrimSpace(id))] = i
|
||||||
|
}
|
||||||
|
sort.SliceStable(matches, func(i, j int) bool {
|
||||||
|
ri, oki := rank[strings.ToLower(matches[i].ID)]
|
||||||
|
rj, okj := rank[strings.ToLower(matches[j].ID)]
|
||||||
|
switch {
|
||||||
|
case oki && okj:
|
||||||
|
return ri < rj
|
||||||
|
case oki:
|
||||||
|
return true
|
||||||
|
case okj:
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return matches[i].ID < matches[j].ID
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return newExtensionProviderWrapper(matches[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExtURLHandleResultWithExtID struct {
|
type ExtURLHandleResultWithExtID struct {
|
||||||
@@ -252,21 +251,13 @@ func (m *extensionManager) GetPostProcessingProviders() []*extensionProviderWrap
|
|||||||
return providers
|
return providers
|
||||||
}
|
}
|
||||||
|
|
||||||
// runPostProcessingCommon backs both RunPostProcessing (V1) and
|
func (m *extensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]any) (*PostProcessResult, error) {
|
||||||
// RunPostProcessingV2. V1 delegates into this shared loop with an equivalent
|
|
||||||
// PostProcessInput; preferV2 controls whether each hook is invoked via
|
|
||||||
// provider.PostProcessV2 or provider.PostProcess, so V1 keeps calling
|
|
||||||
// PostProcess (not PostProcessV2) exactly as it did before.
|
|
||||||
func (m *extensionManager) runPostProcessingCommon(input PostProcessInput, metadata map[string]any, preferV2 bool) (*PostProcessResult, error) {
|
|
||||||
providers := m.GetPostProcessingProviders()
|
providers := m.GetPostProcessingProviders()
|
||||||
if len(providers) == 0 {
|
if len(providers) == 0 {
|
||||||
return &PostProcessResult{Success: true, NewFilePath: input.Path, NewFileURI: input.URI}, nil
|
return &PostProcessResult{Success: true, NewFilePath: input.Path, NewFileURI: input.URI}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
logTag := "[PostProcess]"
|
logTag := "[PostProcessV2]"
|
||||||
if preferV2 {
|
|
||||||
logTag = "[PostProcessV2]"
|
|
||||||
}
|
|
||||||
|
|
||||||
currentInput := input
|
currentInput := input
|
||||||
for _, provider := range providers {
|
for _, provider := range providers {
|
||||||
@@ -295,13 +286,7 @@ func (m *extensionManager) runPostProcessingCommon(input PostProcessInput, metad
|
|||||||
|
|
||||||
GoLog("%s Running hook %s from %s on %s\n", logTag, hook.ID, provider.extension.ID, currentInput.Path)
|
GoLog("%s Running hook %s from %s on %s\n", logTag, hook.ID, provider.extension.ID, currentInput.Path)
|
||||||
|
|
||||||
var result *PostProcessResult
|
result, err := provider.PostProcessV2(currentInput, metadata, hook.ID)
|
||||||
var err error
|
|
||||||
if preferV2 {
|
|
||||||
result, err = provider.PostProcessV2(currentInput, metadata, hook.ID)
|
|
||||||
} else {
|
|
||||||
result, err = provider.PostProcess(currentInput.Path, metadata, hook.ID)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("%s Hook %s failed: %v\n", logTag, hook.ID, err)
|
GoLog("%s Hook %s failed: %v\n", logTag, hook.ID, err)
|
||||||
continue
|
continue
|
||||||
@@ -360,18 +345,6 @@ func validatePostProcessResult(ext *loadedExtension, input PostProcessInput, res
|
|||||||
return fmt.Errorf("replacement file path is outside allowed directories")
|
return fmt.Errorf("replacement file path is outside allowed directories")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *extensionManager) RunPostProcessing(filePath string, metadata map[string]any) (*PostProcessResult, error) {
|
|
||||||
result, err := m.runPostProcessingCommon(PostProcessInput{Path: filePath}, metadata, false)
|
|
||||||
if err != nil {
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
return &PostProcessResult{Success: result.Success, NewFilePath: result.NewFilePath}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *extensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]any) (*PostProcessResult, error) {
|
|
||||||
return m.runPostProcessingCommon(input, metadata, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *extensionManager) GetLyricsProviders() []*extensionProviderWrapper {
|
func (m *extensionManager) GetLyricsProviders() []*extensionProviderWrapper {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -457,6 +458,58 @@ func TestShouldStopProviderFallback(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMoveProviderToFrontPreservesExplicitSelection(t *testing.T) {
|
||||||
|
priority := []string{"qobuz-web", "amazon-web", "tidal-web"}
|
||||||
|
got := moveProviderToFront(priority, "AMAZON-WEB")
|
||||||
|
want := []string{"amazon-web", "qobuz-web", "tidal-web"}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("moveProviderToFront() = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(priority, []string{"qobuz-web", "amazon-web", "tidal-web"}) {
|
||||||
|
t.Fatalf("moveProviderToFront mutated input: %#v", priority)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscardRejectedExtensionOutputStaysInsideRequestedDirectory(t *testing.T) {
|
||||||
|
outputDir := t.TempDir()
|
||||||
|
requestedPath := filepath.Join(outputDir, "Artist - Song.flac")
|
||||||
|
rejectedPath := filepath.Join(outputDir, "Artist - Song.m4a")
|
||||||
|
outsidePath := filepath.Join(t.TempDir(), "keep.flac")
|
||||||
|
if err := os.WriteFile(rejectedPath, []byte("wrong audio"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(outsidePath, []byte("keep"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
discardRejectedExtensionOutput(&ExtDownloadResult{FilePath: rejectedPath}, requestedPath)
|
||||||
|
if _, err := os.Stat(rejectedPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("rejected output was not removed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
discardRejectedExtensionOutput(&ExtDownloadResult{FilePath: outsidePath}, requestedPath)
|
||||||
|
if _, err := os.Stat(outsidePath); err != nil {
|
||||||
|
t.Fatalf("output outside requested directory was removed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscardRejectedExtensionOutputPreservesExistingLibraryHit(t *testing.T) {
|
||||||
|
outputDir := t.TempDir()
|
||||||
|
path := filepath.Join(outputDir, "existing.flac")
|
||||||
|
if err := os.WriteFile(path, []byte("existing audio"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
discardRejectedExtensionOutput(&ExtDownloadResult{
|
||||||
|
FilePath: path,
|
||||||
|
AlreadyExists: true,
|
||||||
|
}, filepath.Join(outputDir, "requested.flac"))
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
t.Fatalf("existing library file was removed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildExtensionFallbackStoppedResponsePrefersAvailabilityReason(t *testing.T) {
|
func TestBuildExtensionFallbackStoppedResponsePrefersAvailabilityReason(t *testing.T) {
|
||||||
resp := buildExtensionFallbackStoppedResponse("soundcloud", &ExtAvailabilityResult{
|
resp := buildExtensionFallbackStoppedResponse("soundcloud", &ExtAvailabilityResult{
|
||||||
Reason: "direct SoundCloud track ID",
|
Reason: "direct SoundCloud track ID",
|
||||||
@@ -671,6 +724,7 @@ func TestParseExtensionMetadataAndDownloadResults(t *testing.T) {
|
|||||||
alreadyExists: true,
|
alreadyExists: true,
|
||||||
bitDepth: 24,
|
bitDepth: 24,
|
||||||
sampleRate: 96000,
|
sampleRate: 96000,
|
||||||
|
durationMs: 181000,
|
||||||
title: "Song",
|
title: "Song",
|
||||||
albumArtist: "Album Artist",
|
albumArtist: "Album Artist",
|
||||||
lyricsLrc: "[00:00.00]Line",
|
lyricsLrc: "[00:00.00]Line",
|
||||||
@@ -692,6 +746,7 @@ func TestParseExtensionMetadataAndDownloadResults(t *testing.T) {
|
|||||||
!download.AlreadyExists ||
|
!download.AlreadyExists ||
|
||||||
download.BitDepth != 24 ||
|
download.BitDepth != 24 ||
|
||||||
download.SampleRate != 96000 ||
|
download.SampleRate != 96000 ||
|
||||||
|
download.DurationMS != 181000 ||
|
||||||
download.AlbumArtist != "Album Artist" ||
|
download.AlbumArtist != "Album Artist" ||
|
||||||
download.LyricsLRC != "[00:00.00]Line" ||
|
download.LyricsLRC != "[00:00.00]Line" ||
|
||||||
download.Decryption == nil ||
|
download.Decryption == nil ||
|
||||||
@@ -777,3 +832,50 @@ func TestParseExtensionAuxiliaryResults(t *testing.T) {
|
|||||||
t.Fatalf("unexpected lyrics result: %+v", lyrics)
|
t.Fatalf("unexpected lyrics result: %+v", lyrics)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMatchesURLHostAnchored(t *testing.T) {
|
||||||
|
manifest := &ExtensionManifest{
|
||||||
|
URLHandler: &URLHandlerConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Patterns: []string{"spotify.com", "deezer.page.link", "spotify:"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, urlStr := range []string{
|
||||||
|
"https://open.spotify.com/track/abc",
|
||||||
|
"https://spotify.com/track/abc",
|
||||||
|
"HTTPS://OPEN.SPOTIFY.COM/track/ABC",
|
||||||
|
"https://deezer.page.link/xyz",
|
||||||
|
"spotify:track:abc123",
|
||||||
|
} {
|
||||||
|
if !manifest.MatchesURL(urlStr) {
|
||||||
|
t.Fatalf("expected match for %q", urlStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, urlStr := range []string{
|
||||||
|
// The old substring matching accepted all of these.
|
||||||
|
"https://evil.example/?next=https://spotify.com/track/abc",
|
||||||
|
"https://notspotify.com/track/abc",
|
||||||
|
"https://spotify.com.evil.example/track/abc",
|
||||||
|
"https://example.com/spotify.com",
|
||||||
|
"not a url at all",
|
||||||
|
} {
|
||||||
|
if manifest.MatchesURL(urlStr) {
|
||||||
|
t.Fatalf("expected no match for %q", urlStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
withPath := &ExtensionManifest{
|
||||||
|
URLHandler: &URLHandlerConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Patterns: []string{"youtube.com/watch"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if !withPath.MatchesURL("https://www.youtube.com/watch?v=abc") {
|
||||||
|
t.Fatal("expected host+path prefix to match")
|
||||||
|
}
|
||||||
|
if withPath.MatchesURL("https://www.youtube.com/playlist?list=abc") {
|
||||||
|
t.Fatal("expected different path to not match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+125
-10
@@ -1,6 +1,8 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -34,10 +36,13 @@ type repoExtension struct {
|
|||||||
Downloads int `json:"downloads"`
|
Downloads int `json:"downloads"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
MinAppVersion string `json:"min_app_version,omitempty"`
|
MinAppVersion string `json:"min_app_version,omitempty"`
|
||||||
|
SHA256 string `json:"sha256,omitempty"`
|
||||||
|
ChecksumSHA256 string `json:"checksum_sha256,omitempty"`
|
||||||
DisplayNameAlt string `json:"displayName,omitempty"`
|
DisplayNameAlt string `json:"displayName,omitempty"`
|
||||||
DownloadURLAlt string `json:"downloadUrl,omitempty"`
|
DownloadURLAlt string `json:"downloadUrl,omitempty"`
|
||||||
IconURLAlt string `json:"iconUrl,omitempty"`
|
IconURLAlt string `json:"iconUrl,omitempty"`
|
||||||
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
|
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
|
||||||
|
ChecksumAlt string `json:"checksumSha256,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *repoExtension) getDisplayName() string {
|
func (e *repoExtension) getDisplayName() string {
|
||||||
@@ -71,6 +76,14 @@ func (e *repoExtension) getMinAppVersion() string {
|
|||||||
return e.MinAppVersionAlt
|
return e.MinAppVersionAlt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *repoExtension) getRawSHA256() string {
|
||||||
|
return firstNonEmptyTrimmed(e.SHA256, e.ChecksumSHA256, e.ChecksumAlt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *repoExtension) getSHA256() string {
|
||||||
|
return normalizeSHA256(e.getRawSHA256())
|
||||||
|
}
|
||||||
|
|
||||||
type repoRegistry struct {
|
type repoRegistry struct {
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
@@ -90,6 +103,7 @@ type repoExtensionResponse struct {
|
|||||||
Downloads int `json:"downloads"`
|
Downloads int `json:"downloads"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
MinAppVersion string `json:"min_app_version,omitempty"`
|
MinAppVersion string `json:"min_app_version,omitempty"`
|
||||||
|
SHA256 string `json:"sha256,omitempty"`
|
||||||
IsInstalled bool `json:"is_installed"`
|
IsInstalled bool `json:"is_installed"`
|
||||||
InstalledVersion string `json:"installed_version,omitempty"`
|
InstalledVersion string `json:"installed_version,omitempty"`
|
||||||
HasUpdate bool `json:"has_update"`
|
HasUpdate bool `json:"has_update"`
|
||||||
@@ -108,6 +122,7 @@ func (e *repoExtension) toResponse() repoExtensionResponse {
|
|||||||
Downloads: e.Downloads,
|
Downloads: e.Downloads,
|
||||||
UpdatedAt: e.UpdatedAt,
|
UpdatedAt: e.UpdatedAt,
|
||||||
MinAppVersion: e.getMinAppVersion(),
|
MinAppVersion: e.getMinAppVersion(),
|
||||||
|
SHA256: e.getSHA256(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(e.Tags) > 0 {
|
if len(e.Tags) > 0 {
|
||||||
@@ -316,6 +331,22 @@ func parseRegistryBody(body []byte) (*repoRegistry, error) {
|
|||||||
}
|
}
|
||||||
return nil, fmt.Errorf("failed to parse registry: %w", err)
|
return nil, fmt.Errorf("failed to parse registry: %w", err)
|
||||||
}
|
}
|
||||||
|
validExtensions := make([]repoExtension, 0, len(registry.Extensions))
|
||||||
|
for index := range registry.Extensions {
|
||||||
|
ext := ®istry.Extensions[index]
|
||||||
|
rawChecksum := ext.getRawSHA256()
|
||||||
|
if rawChecksum != "" && normalizeSHA256(rawChecksum) == "" {
|
||||||
|
LogWarn(
|
||||||
|
"ExtensionRepo",
|
||||||
|
"Skipping registry extension %q at index %d: invalid SHA-256 checksum",
|
||||||
|
ext.ID,
|
||||||
|
index,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
validExtensions = append(validExtensions, *ext)
|
||||||
|
}
|
||||||
|
registry.Extensions = validExtensions
|
||||||
return ®istry, nil
|
return ®istry, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,22 +429,106 @@ func (s *extensionRepo) downloadExtension(extensionID string, destPath string) e
|
|||||||
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
|
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(destPath)
|
if err := writeVerifiedExtensionPackage(
|
||||||
if err != nil {
|
resp.Body,
|
||||||
return fmt.Errorf("failed to create file: %w", err)
|
destPath,
|
||||||
}
|
ext.getRawSHA256(),
|
||||||
defer out.Close()
|
); err != nil {
|
||||||
|
return err
|
||||||
_, err = io.Copy(out, resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
os.Remove(destPath)
|
|
||||||
return fmt.Errorf("failed to write file: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LogInfo("ExtensionRepo", "Downloaded %s to %s", ext.getDisplayName(), destPath)
|
LogInfo("ExtensionRepo", "Downloaded %s to %s", ext.getDisplayName(), destPath)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxExtensionPackageBytes int64 = 64 * 1024 * 1024
|
||||||
|
|
||||||
|
func normalizeSHA256(value string) string {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||||
|
normalized = strings.TrimPrefix(normalized, "sha256:")
|
||||||
|
if len(normalized) != sha256.Size*2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, char := range normalized {
|
||||||
|
if (char < '0' || char > '9') && (char < 'a' || char > 'f') {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeVerifiedExtensionPackage(
|
||||||
|
reader io.Reader,
|
||||||
|
destPath string,
|
||||||
|
expectedSHA256 string,
|
||||||
|
) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to prepare extension download directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tempFile, err := os.CreateTemp(
|
||||||
|
filepath.Dir(destPath),
|
||||||
|
"."+filepath.Base(destPath)+".download-*",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create extension download: %w", err)
|
||||||
|
}
|
||||||
|
tempPath := tempFile.Name()
|
||||||
|
committed := false
|
||||||
|
defer func() {
|
||||||
|
_ = tempFile.Close()
|
||||||
|
if !committed {
|
||||||
|
_ = os.Remove(tempPath)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
hasher := sha256.New()
|
||||||
|
limited := &io.LimitedReader{R: reader, N: maxExtensionPackageBytes + 1}
|
||||||
|
written, copyErr := io.Copy(io.MultiWriter(tempFile, hasher), limited)
|
||||||
|
if copyErr != nil {
|
||||||
|
return fmt.Errorf("failed to write extension package: %w", copyErr)
|
||||||
|
}
|
||||||
|
if written > maxExtensionPackageBytes {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"extension package exceeds the %d MiB size limit",
|
||||||
|
maxExtensionPackageBytes/(1024*1024),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err := tempFile.Sync(); err != nil {
|
||||||
|
return fmt.Errorf("failed to flush extension package: %w", err)
|
||||||
|
}
|
||||||
|
if err := tempFile.Close(); err != nil {
|
||||||
|
return fmt.Errorf("failed to close extension package: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := normalizeSHA256(expectedSHA256)
|
||||||
|
if strings.TrimSpace(expectedSHA256) != "" && expected == "" {
|
||||||
|
return fmt.Errorf("registry contains an invalid extension SHA-256 checksum")
|
||||||
|
}
|
||||||
|
if expected != "" {
|
||||||
|
actual := fmt.Sprintf("%x", hasher.Sum(nil))
|
||||||
|
if subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) != 1 {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"extension package integrity check failed: SHA-256 mismatch",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LogWarn(
|
||||||
|
"ExtensionRepo",
|
||||||
|
"Registry entry has no SHA-256 checksum; package integrity cannot be verified",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(destPath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("failed to replace cached extension package: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tempPath, destPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to publish extension package: %w", err)
|
||||||
|
}
|
||||||
|
committed = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func resolveRegistryURL(input string) (string, error) {
|
func resolveRegistryURL(input string) (string, error) {
|
||||||
input = strings.TrimSpace(input)
|
input = strings.TrimSpace(input)
|
||||||
if input == "" {
|
if input == "" {
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
var onProgress goja.Callable
|
var onProgress goja.Callable
|
||||||
var headers map[string]string
|
var headers map[string]string
|
||||||
var chunkedDownload bool
|
var chunkedDownload bool
|
||||||
|
var resumeDownload bool
|
||||||
trackItemBytes := true
|
trackItemBytes := true
|
||||||
var chunkSize int64
|
var chunkSize int64
|
||||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||||
@@ -170,6 +171,11 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if resume, ok := opts["resume"]; ok {
|
||||||
|
if v, ok := resume.(bool); ok {
|
||||||
|
resumeDownload = v
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,6 +292,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var written int64
|
var written int64
|
||||||
|
var lastProgressNotify int64
|
||||||
buf := make([]byte, 32*1024)
|
buf := make([]byte, 32*1024)
|
||||||
|
|
||||||
// copyBody streams resp into progressWriter. fatal is a terminal JS error
|
// copyBody streams resp into progressWriter. fatal is a terminal JS error
|
||||||
@@ -315,7 +322,11 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
return r.jsError("short write"), nil
|
return r.jsError("short write"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if onProgress != nil && contentLength > 0 {
|
// Throttle the JS callback like the native progress writer:
|
||||||
|
// per-read invocation is interpreter work inside the copy loop.
|
||||||
|
if onProgress != nil && contentLength > 0 &&
|
||||||
|
(written-lastProgressNotify >= progressUpdateThreshold || written >= contentLength) {
|
||||||
|
lastProgressNotify = written
|
||||||
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength))
|
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -328,14 +339,14 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mid-body network failures resume from the current offset instead of
|
// Mid-body resume is opt-in because switching networks can route a stable
|
||||||
// failing the whole download. Only attempted when the server gave a
|
// URL to a different CDN object even when its validator is unchanged. The
|
||||||
// validator (If-Range guards against splicing two versions of the file)
|
// safe default is to fail and delete the staged partial file. Extensions
|
||||||
// and the caller did not set its own Range. A server that ignores Range
|
// that know their origin supports byte-identical Range resumes can request
|
||||||
// answers 200 and the download restarts from zero — same as today.
|
// it explicitly with { resume: true }.
|
||||||
validator := resumeValidator(resp.Header)
|
validator := resumeValidator(resp.Header)
|
||||||
_, callerSetRange := headers["Range"]
|
_, callerSetRange := headers["Range"]
|
||||||
canResume := validator != "" && !callerSetRange
|
canResume := resumeDownload && validator != "" && !callerSetRange
|
||||||
|
|
||||||
const maxResumes = 3
|
const maxResumes = 3
|
||||||
resumes := 0
|
resumes := 0
|
||||||
@@ -401,7 +412,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
validator = resumeValidator(resp.Header)
|
validator = resumeValidator(resp.Header)
|
||||||
canResume = validator != ""
|
canResume = resumeDownload && validator != ""
|
||||||
default:
|
default:
|
||||||
code := resp.StatusCode
|
code := resp.StatusCode
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
@@ -529,6 +540,7 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
|
|||||||
}
|
}
|
||||||
|
|
||||||
var totalWritten int64
|
var totalWritten int64
|
||||||
|
var lastProgressNotify int64
|
||||||
buf := make([]byte, 32*1024)
|
buf := make([]byte, 32*1024)
|
||||||
maxRetries := 3
|
maxRetries := 3
|
||||||
|
|
||||||
@@ -610,7 +622,9 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
|
|||||||
return r.jsError("short write")
|
return r.jsError("short write")
|
||||||
}
|
}
|
||||||
|
|
||||||
if onProgress != nil && totalSize > 0 {
|
if onProgress != nil && totalSize > 0 &&
|
||||||
|
(totalWritten-lastProgressNotify >= progressUpdateThreshold || totalWritten >= totalSize) {
|
||||||
|
lastProgressNotify = totalWritten
|
||||||
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(totalWritten), r.vm.ToValue(totalSize))
|
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(totalWritten), r.vm.ToValue(totalSize))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,7 +267,42 @@ func TestFileDownloadFailureLeavesNoFinalFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileDownloadResumesAfterMidBodyCut(t *testing.T) {
|
func TestFileDownloadDoesNotResumeMidBodyCutByDefault(t *testing.T) {
|
||||||
|
const full = "hello-world!"
|
||||||
|
var attempts int
|
||||||
|
runtime := newFileDownloadTestRuntime(t, func(req *http.Request) (*http.Response, error) {
|
||||||
|
attempts++
|
||||||
|
h := make(http.Header)
|
||||||
|
h.Set("ETag", `"v1"`)
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: 200,
|
||||||
|
Header: h,
|
||||||
|
Body: io.NopCloser(&failingBodyReader{data: []byte(full[:6])}),
|
||||||
|
ContentLength: int64(len(full)),
|
||||||
|
Request: req,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
result := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{
|
||||||
|
runtime.vm.ToValue("https://cdn.example.com/track.flac"),
|
||||||
|
runtime.vm.ToValue("out/track.flac"),
|
||||||
|
}}).Export().(map[string]any)
|
||||||
|
if result["success"] != false {
|
||||||
|
t.Fatalf("expected failed download, got %#v", result)
|
||||||
|
}
|
||||||
|
if attempts != 1 {
|
||||||
|
t.Fatalf("attempts = %d, want no automatic resume", attempts)
|
||||||
|
}
|
||||||
|
finalPath := filepath.Join(runtime.dataDir, "out", "track.flac")
|
||||||
|
if _, err := os.Stat(finalPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("partial download visible at final path: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(stagedDownloadPath(finalPath)); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("staged file left behind: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileDownloadResumesAfterMidBodyCutWhenEnabled(t *testing.T) {
|
||||||
const full = "hello-world!"
|
const full = "hello-world!"
|
||||||
var attempts int
|
var attempts int
|
||||||
var resumeRange, resumeIfRange string
|
var resumeRange, resumeIfRange string
|
||||||
@@ -300,6 +335,7 @@ func TestFileDownloadResumesAfterMidBodyCut(t *testing.T) {
|
|||||||
result := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{
|
result := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{
|
||||||
runtime.vm.ToValue("https://cdn.example.com/track.flac"),
|
runtime.vm.ToValue("https://cdn.example.com/track.flac"),
|
||||||
runtime.vm.ToValue("out/track.flac"),
|
runtime.vm.ToValue("out/track.flac"),
|
||||||
|
runtime.vm.ToValue(map[string]any{"resume": true}),
|
||||||
}}).Export().(map[string]any)
|
}}).Export().(map[string]any)
|
||||||
if result["success"] != true {
|
if result["success"] != true {
|
||||||
t.Fatalf("download result = %#v", result)
|
t.Fatalf("download result = %#v", result)
|
||||||
@@ -342,6 +378,7 @@ func TestFileDownloadResumeRestartsWhenRangeIgnored(t *testing.T) {
|
|||||||
result := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{
|
result := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{
|
||||||
runtime.vm.ToValue("https://cdn.example.com/track.flac"),
|
runtime.vm.ToValue("https://cdn.example.com/track.flac"),
|
||||||
runtime.vm.ToValue("out/track.flac"),
|
runtime.vm.ToValue("out/track.flac"),
|
||||||
|
runtime.vm.ToValue(map[string]any{"resume": true}),
|
||||||
}}).Export().(map[string]any)
|
}}).Export().(map[string]any)
|
||||||
if result["success"] != true {
|
if result["success"] != true {
|
||||||
t.Fatalf("download result = %#v", result)
|
t.Fatalf("download result = %#v", result)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -24,11 +25,94 @@ import (
|
|||||||
|
|
||||||
const signedSessionRefreshSkew = time.Hour
|
const signedSessionRefreshSkew = time.Hour
|
||||||
|
|
||||||
|
const (
|
||||||
|
signedSessionExchangeMaxAttempts = 3
|
||||||
|
signedSessionMaxRetryAfter = 5 * time.Minute
|
||||||
|
signedSessionMaxSessionRetries = 1
|
||||||
|
signedSessionMaxProviderRetries = 2
|
||||||
|
signedSessionProviderRetryDelay = time.Second
|
||||||
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
pendingSignedSessionGrants = make(map[string]string)
|
pendingSignedSessionGrants = make(map[string]string)
|
||||||
pendingSignedSessionGrantsMu sync.Mutex
|
pendingSignedSessionGrantsMu sync.Mutex
|
||||||
|
signedSessionCoordinators sync.Map
|
||||||
|
signedSessionRetryWait = time.Sleep
|
||||||
|
signedSessionProviderWait = sleepRetry
|
||||||
|
signedSessionRequestNow = time.Now
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// signedSessionCoordinator serializes authentication state for every runtime
|
||||||
|
// that shares one persisted signed-session file. Parallel downloads use
|
||||||
|
// isolated extension runtimes, so a runtime-local mutex cannot prevent two
|
||||||
|
// bootstraps or an old 401 response from overwriting a newly exchanged session.
|
||||||
|
type signedSessionCoordinator struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
authURL string
|
||||||
|
callbackURL string
|
||||||
|
challengeCreatedAt time.Time
|
||||||
|
pendingExtensionIDs map[string]struct{}
|
||||||
|
completedGrantHash string
|
||||||
|
blockedGeneration string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) signedSessionCoordinator(config SignedSessionConfig) (*signedSessionCoordinator, error) {
|
||||||
|
path, err := r.signedSessionFilePath(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
value, _ := signedSessionCoordinators.LoadOrStore(path, &signedSessionCoordinator{})
|
||||||
|
return value.(*signedSessionCoordinator), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *signedSessionCoordinator) clearChallenge() {
|
||||||
|
for extensionID := range c.pendingExtensionIDs {
|
||||||
|
ClearPendingAuthRequest(extensionID)
|
||||||
|
}
|
||||||
|
c.authURL = ""
|
||||||
|
c.callbackURL = ""
|
||||||
|
c.challengeCreatedAt = time.Time{}
|
||||||
|
c.pendingExtensionIDs = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *signedSessionCoordinator) rememberChallenge(extensionID, authURL, callbackURL string) {
|
||||||
|
if c.pendingExtensionIDs == nil {
|
||||||
|
c.pendingExtensionIDs = make(map[string]struct{})
|
||||||
|
}
|
||||||
|
c.authURL = authURL
|
||||||
|
c.callbackURL = callbackURL
|
||||||
|
c.challengeCreatedAt = time.Now()
|
||||||
|
c.pendingExtensionIDs[extensionID] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *signedSessionCoordinator) activeChallenge() bool {
|
||||||
|
return strings.TrimSpace(c.authURL) != "" &&
|
||||||
|
!c.challengeCreatedAt.IsZero() &&
|
||||||
|
time.Since(c.challengeCreatedAt) < pendingAuthRequestTTL
|
||||||
|
}
|
||||||
|
|
||||||
|
func signedSessionGeneration(record *signedSessionRecord) string {
|
||||||
|
if record == nil || record.SessionID == "" || record.SessionSecret == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(record.SessionID + "\n" + record.SessionSecret))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *signedSessionCoordinator) blockGeneration(record *signedSessionRecord) {
|
||||||
|
c.blockedGeneration = signedSessionGeneration(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *signedSessionCoordinator) generationIsBlocked(record *signedSessionRecord) bool {
|
||||||
|
generation := signedSessionGeneration(record)
|
||||||
|
return generation != "" && generation == c.blockedGeneration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *signedSessionCoordinator) clearBlockedGeneration() {
|
||||||
|
c.blockedGeneration = ""
|
||||||
|
}
|
||||||
|
|
||||||
type signedSessionRecord struct {
|
type signedSessionRecord struct {
|
||||||
InstallID string `json:"install_id"`
|
InstallID string `json:"install_id"`
|
||||||
SessionID string `json:"session_id,omitempty"`
|
SessionID string `json:"session_id,omitempty"`
|
||||||
@@ -49,6 +133,20 @@ type signedSessionExchangeResponse struct {
|
|||||||
AuthURL string `json:"auth_url,omitempty"`
|
AuthURL string `json:"auth_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// signedSessionErrorContract is the gateway-owned error envelope. Decisions
|
||||||
|
// that mutate authentication state must use these fields together with the
|
||||||
|
// HTTP status; a provider response must never be able to masquerade as a
|
||||||
|
// gateway session failure merely by returning 401 or 403 upstream.
|
||||||
|
type signedSessionErrorContract struct {
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Code string `json:"code,omitempty"`
|
||||||
|
Origin string `json:"origin,omitempty"`
|
||||||
|
Action string `json:"action,omitempty"`
|
||||||
|
Retryable bool `json:"retryable,omitempty"`
|
||||||
|
RetryMode string `json:"retry_mode,omitempty"`
|
||||||
|
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
func signedSessionConfigWithDefaults(config *SignedSessionConfig) SignedSessionConfig {
|
func signedSessionConfigWithDefaults(config *SignedSessionConfig) SignedSessionConfig {
|
||||||
if config == nil {
|
if config == nil {
|
||||||
return SignedSessionConfig{}
|
return SignedSessionConfig{}
|
||||||
@@ -94,9 +192,6 @@ func (r *extensionRuntime) signedSessionFilePath(config SignedSessionConfig) (st
|
|||||||
baseDir = r.dataDir
|
baseDir = r.dataDir
|
||||||
}
|
}
|
||||||
dir := filepath.Join(baseDir, "signed_sessions")
|
dir := filepath.Join(baseDir, "signed_sessions")
|
||||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
scope := strings.Join([]string{
|
scope := strings.Join([]string{
|
||||||
namespace,
|
namespace,
|
||||||
strings.TrimSpace(strings.ToLower(config.BaseURL)),
|
strings.TrimSpace(strings.ToLower(config.BaseURL)),
|
||||||
@@ -127,32 +222,41 @@ func (r *extensionRuntime) loadSignedSession(config SignedSessionConfig) (*signe
|
|||||||
if data, err := os.ReadFile(path); err == nil {
|
if data, err := os.ReadFile(path); err == nil {
|
||||||
_ = json.Unmarshal(data, record)
|
_ = json.Unmarshal(data, record)
|
||||||
}
|
}
|
||||||
|
changed := false
|
||||||
if strings.TrimSpace(record.InstallID) == "" {
|
if strings.TrimSpace(record.InstallID) == "" {
|
||||||
record.InstallID = randomHex(16)
|
record.InstallID = randomHex(16)
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
normalizeSignedSessionRecordScope(config, record)
|
if normalizeSignedSessionRecordScope(config, record) {
|
||||||
if err := r.saveSignedSession(config, record); err != nil {
|
changed = true
|
||||||
return nil, err
|
}
|
||||||
|
// Only rewrite the file when the record actually changed; loads happen on
|
||||||
|
// every signed request and preflight.
|
||||||
|
if changed {
|
||||||
|
if err := r.saveSignedSession(config, record); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return record, nil
|
return record, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeSignedSessionRecordScope(config SignedSessionConfig, record *signedSessionRecord) {
|
// normalizeSignedSessionRecordScope stamps the config scope onto the record,
|
||||||
|
// resetting the session when the scope changed. Returns whether the record
|
||||||
|
// was modified.
|
||||||
|
func normalizeSignedSessionRecordScope(config SignedSessionConfig, record *signedSessionRecord) bool {
|
||||||
namespace := sanitizeSignedSessionNamespace(config.Namespace)
|
namespace := sanitizeSignedSessionNamespace(config.Namespace)
|
||||||
baseURL := strings.TrimSpace(config.BaseURL)
|
baseURL := strings.TrimSpace(config.BaseURL)
|
||||||
appVersion := strings.TrimSpace(config.AppVersion)
|
appVersion := strings.TrimSpace(config.AppVersion)
|
||||||
platform := strings.TrimSpace(config.Platform)
|
platform := strings.TrimSpace(config.Platform)
|
||||||
if record.Namespace == "" && record.BaseURL == "" && record.AppVersion == "" && record.Platform == "" {
|
if record.Namespace == namespace &&
|
||||||
record.Namespace = namespace
|
record.BaseURL == baseURL &&
|
||||||
record.BaseURL = baseURL
|
record.AppVersion == appVersion &&
|
||||||
record.AppVersion = appVersion
|
record.Platform == platform {
|
||||||
record.Platform = platform
|
return false
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if record.Namespace != namespace ||
|
blankScope := record.Namespace == "" && record.BaseURL == "" &&
|
||||||
record.BaseURL != baseURL ||
|
record.AppVersion == "" && record.Platform == ""
|
||||||
record.AppVersion != appVersion ||
|
if !blankScope {
|
||||||
record.Platform != platform {
|
|
||||||
record.SessionID = ""
|
record.SessionID = ""
|
||||||
record.SessionSecret = ""
|
record.SessionSecret = ""
|
||||||
record.ExpiresAt = ""
|
record.ExpiresAt = ""
|
||||||
@@ -161,6 +265,7 @@ func normalizeSignedSessionRecordScope(config SignedSessionConfig, record *signe
|
|||||||
record.BaseURL = baseURL
|
record.BaseURL = baseURL
|
||||||
record.AppVersion = appVersion
|
record.AppVersion = appVersion
|
||||||
record.Platform = platform
|
record.Platform = platform
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) saveSignedSession(config SignedSessionConfig, record *signedSessionRecord) error {
|
func (r *extensionRuntime) saveSignedSession(config SignedSessionConfig, record *signedSessionRecord) error {
|
||||||
@@ -168,6 +273,9 @@ func (r *extensionRuntime) saveSignedSession(config SignedSessionConfig, record
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
data, err := json.MarshalIndent(record, "", " ")
|
data, err := json.MarshalIndent(record, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -211,11 +319,78 @@ func signedSessionRecordIsUsable(record *signedSessionRecord) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sameSignedSession(a, b *signedSessionRecord) bool {
|
||||||
|
return a != nil && b != nil &&
|
||||||
|
a.SessionID != "" &&
|
||||||
|
a.SessionID == b.SessionID &&
|
||||||
|
a.SessionSecret == b.SessionSecret
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSignedSessionErrorContract(body []byte) (signedSessionErrorContract, bool) {
|
||||||
|
var contract signedSessionErrorContract
|
||||||
|
if len(body) == 0 || json.Unmarshal(body, &contract) != nil {
|
||||||
|
return signedSessionErrorContract{}, false
|
||||||
|
}
|
||||||
|
contract.Error = strings.TrimSpace(contract.Error)
|
||||||
|
contract.Code = strings.ToUpper(strings.TrimSpace(contract.Code))
|
||||||
|
contract.Origin = strings.ToLower(strings.TrimSpace(contract.Origin))
|
||||||
|
contract.Action = strings.ToLower(strings.TrimSpace(contract.Action))
|
||||||
|
contract.RetryMode = strings.ToLower(strings.TrimSpace(contract.RetryMode))
|
||||||
|
if contract.RetryAfterSeconds < 0 {
|
||||||
|
contract.RetryAfterSeconds = 0
|
||||||
|
}
|
||||||
|
return contract, contract.Code != "" || contract.Origin != "" || contract.Action != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func signedSessionGatewayAction(statusCode int, contract signedSessionErrorContract) string {
|
||||||
|
if contract.Origin != "gateway" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case statusCode == http.StatusUnauthorized &&
|
||||||
|
contract.Code == "SESSION_INVALID" &&
|
||||||
|
contract.Action == "bootstrap_session":
|
||||||
|
return "bootstrap_session"
|
||||||
|
case statusCode == http.StatusPreconditionRequired &&
|
||||||
|
contract.Code == "VERIFY_REQUIRED" &&
|
||||||
|
contract.Action == "verify":
|
||||||
|
return "verify"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func signedSessionSameOperationRetry(statusCode int, contract signedSessionErrorContract) bool {
|
||||||
|
return statusCode == http.StatusServiceUnavailable &&
|
||||||
|
contract.Origin == "provider" &&
|
||||||
|
contract.Code == "PROVIDER_UNAVAILABLE" &&
|
||||||
|
contract.Retryable &&
|
||||||
|
contract.RetryMode == "same_operation"
|
||||||
|
}
|
||||||
|
|
||||||
|
func signedSessionRequestAuthInvalid(statusCode int, contract signedSessionErrorContract) bool {
|
||||||
|
return statusCode == http.StatusForbidden &&
|
||||||
|
contract.Origin == "gateway" &&
|
||||||
|
contract.Code == "REQUEST_AUTH_INVALID" &&
|
||||||
|
contract.Action == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func signedSessionProviderRetryDuration(resp *http.Response, contract signedSessionErrorContract) time.Duration {
|
||||||
|
if retryAfter := getRetryAfterDuration(resp); retryAfter > 0 {
|
||||||
|
return retryAfter
|
||||||
|
}
|
||||||
|
if contract.RetryAfterSeconds > 0 {
|
||||||
|
maxSeconds := int(maxRetryAfterDelay / time.Second)
|
||||||
|
return time.Duration(min(contract.RetryAfterSeconds, maxSeconds)) * time.Second
|
||||||
|
}
|
||||||
|
return signedSessionProviderRetryDelay
|
||||||
|
}
|
||||||
|
|
||||||
// preflightSignedSession prepares a signed session before download metadata
|
// preflightSignedSession prepares a signed session before download metadata
|
||||||
// enrichment starts. A fresh pending challenge is reused, while bootstrap
|
// enrichment starts. A fresh pending challenge is reused, while bootstrap
|
||||||
// responses that can issue a session silently are accepted without prompting
|
// responses that can issue a session silently are accepted without prompting
|
||||||
// the user. Bootstrap failures remain non-fatal to the caller so the normal
|
// the user. Bootstrap failures are returned so callers do not continue into
|
||||||
// provider path can still surface its more specific error.
|
// the provider and accidentally issue the same failing bootstrap repeatedly.
|
||||||
func (r *extensionRuntime) preflightSignedSession() (bool, error) {
|
func (r *extensionRuntime) preflightSignedSession() (bool, error) {
|
||||||
if r == nil || r.manifest == nil || r.manifest.SignedSession == nil {
|
if r == nil || r.manifest == nil || r.manifest.SignedSession == nil {
|
||||||
return false, nil
|
return false, nil
|
||||||
@@ -225,24 +400,25 @@ func (r *extensionRuntime) preflightSignedSession() (bool, error) {
|
|||||||
if config.Namespace == "" || config.BaseURL == "" {
|
if config.Namespace == "" || config.BaseURL == "" {
|
||||||
return false, fmt.Errorf("signedSession is not configured")
|
return false, fmt.Errorf("signedSession is not configured")
|
||||||
}
|
}
|
||||||
|
coordinator, err := r.signedSessionCoordinator(config)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
coordinator.mu.Lock()
|
||||||
|
defer coordinator.mu.Unlock()
|
||||||
|
|
||||||
record, err := r.loadSignedSession(config)
|
record, err := r.loadSignedSession(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
if signedSessionRecordIsUsable(record) {
|
if signedSessionRecordIsUsable(record) &&
|
||||||
|
!coordinator.generationIsBlocked(record) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if pending := GetPendingAuthRequest(r.extensionID); pending != nil {
|
if authURL, err := r.startSignedSessionVerificationLocked(config, coordinator, "download-preflight"); err != nil {
|
||||||
if time.Since(pending.CreatedAt) < pendingAuthRequestTTL &&
|
return false, err
|
||||||
strings.TrimSpace(pending.AuthURL) != "" {
|
} else if authURL != "" {
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
ClearPendingAuthRequest(r.extensionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if authURL := r.startSignedSessionVerification(config, "download-preflight"); authURL != "" {
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,23 +440,37 @@ func (r *extensionRuntime) signedSessionStatus(call goja.FunctionCall) goja.Valu
|
|||||||
if config.Namespace == "" || config.BaseURL == "" {
|
if config.Namespace == "" || config.BaseURL == "" {
|
||||||
return r.vm.ToValue(map[string]any{"authenticated": false, "error": "signedSession is not configured"})
|
return r.vm.ToValue(map[string]any{"authenticated": false, "error": "signedSession is not configured"})
|
||||||
}
|
}
|
||||||
|
coordinator, err := r.signedSessionCoordinator(config)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]any{"authenticated": false, "error": err.Error()})
|
||||||
|
}
|
||||||
|
coordinator.mu.Lock()
|
||||||
|
defer coordinator.mu.Unlock()
|
||||||
record, err := r.loadSignedSession(config)
|
record, err := r.loadSignedSession(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]any{"authenticated": false, "error": err.Error()})
|
return r.vm.ToValue(map[string]any{"authenticated": false, "error": err.Error()})
|
||||||
}
|
}
|
||||||
authenticated := signedSessionRecordIsUsable(record)
|
blocked := coordinator.generationIsBlocked(record)
|
||||||
|
authenticated := signedSessionRecordIsUsable(record) && !blocked
|
||||||
return r.vm.ToValue(map[string]any{
|
return r.vm.ToValue(map[string]any{
|
||||||
"authenticated": authenticated,
|
"authenticated": authenticated,
|
||||||
"expires_at": record.ExpiresAt,
|
"verification_required": blocked,
|
||||||
"install_id": record.InstallID,
|
"expires_at": record.ExpiresAt,
|
||||||
"session_id": record.SessionID,
|
"install_id": record.InstallID,
|
||||||
"app_version": config.AppVersion,
|
"session_id": record.SessionID,
|
||||||
"platform": config.Platform,
|
"app_version": config.AppVersion,
|
||||||
|
"platform": config.Platform,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) signedSessionClear(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) signedSessionClear(call goja.FunctionCall) goja.Value {
|
||||||
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
||||||
|
coordinator, err := r.signedSessionCoordinator(config)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]any{"success": false, "error": err.Error()})
|
||||||
|
}
|
||||||
|
coordinator.mu.Lock()
|
||||||
|
defer coordinator.mu.Unlock()
|
||||||
record, err := r.loadSignedSession(config)
|
record, err := r.loadSignedSession(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]any{"success": false, "error": err.Error()})
|
return r.vm.ToValue(map[string]any{"success": false, "error": err.Error()})
|
||||||
@@ -291,6 +481,8 @@ func (r *extensionRuntime) signedSessionClear(call goja.FunctionCall) goja.Value
|
|||||||
if err := r.saveSignedSession(config, record); err != nil {
|
if err := r.saveSignedSession(config, record); err != nil {
|
||||||
return r.vm.ToValue(map[string]any{"success": false, "error": err.Error()})
|
return r.vm.ToValue(map[string]any{"success": false, "error": err.Error()})
|
||||||
}
|
}
|
||||||
|
coordinator.clearBlockedGeneration()
|
||||||
|
coordinator.clearChallenge()
|
||||||
ClearPendingAuthRequest(r.extensionID)
|
ClearPendingAuthRequest(r.extensionID)
|
||||||
return r.vm.ToValue(map[string]any{"success": true})
|
return r.vm.ToValue(map[string]any{"success": true})
|
||||||
}
|
}
|
||||||
@@ -300,10 +492,12 @@ func (r *extensionRuntime) signedSessionCompleteGrant(call goja.FunctionCall) go
|
|||||||
if len(call.Arguments) > 0 {
|
if len(call.Arguments) > 0 {
|
||||||
grant = strings.TrimSpace(call.Arguments[0].String())
|
grant = strings.TrimSpace(call.Arguments[0].String())
|
||||||
}
|
}
|
||||||
|
if grant != "" {
|
||||||
|
setPendingSignedSessionGrant(r.extensionID, grant)
|
||||||
|
}
|
||||||
if grant == "" {
|
if grant == "" {
|
||||||
pendingSignedSessionGrantsMu.Lock()
|
pendingSignedSessionGrantsMu.Lock()
|
||||||
grant = pendingSignedSessionGrants[r.extensionID]
|
grant = pendingSignedSessionGrants[r.extensionID]
|
||||||
delete(pendingSignedSessionGrants, r.extensionID)
|
|
||||||
pendingSignedSessionGrantsMu.Unlock()
|
pendingSignedSessionGrantsMu.Unlock()
|
||||||
}
|
}
|
||||||
if grant == "" {
|
if grant == "" {
|
||||||
@@ -312,16 +506,44 @@ func (r *extensionRuntime) signedSessionCompleteGrant(call goja.FunctionCall) go
|
|||||||
if err := r.exchangeSignedSessionGrant(grant); err != nil {
|
if err := r.exchangeSignedSessionGrant(grant); err != nil {
|
||||||
return r.vm.ToValue(map[string]any{"success": false, "error": err.Error()})
|
return r.vm.ToValue(map[string]any{"success": false, "error": err.Error()})
|
||||||
}
|
}
|
||||||
|
pendingSignedSessionGrantsMu.Lock()
|
||||||
|
delete(pendingSignedSessionGrants, r.extensionID)
|
||||||
|
pendingSignedSessionGrantsMu.Unlock()
|
||||||
ClearPendingAuthRequest(r.extensionID)
|
ClearPendingAuthRequest(r.extensionID)
|
||||||
return r.vm.ToValue(map[string]any{"success": true})
|
return r.vm.ToValue(map[string]any{"success": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) exchangeSignedSessionGrant(grant string) error {
|
func (r *extensionRuntime) exchangeSignedSessionGrant(grant string) error {
|
||||||
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
||||||
|
coordinator, err := r.signedSessionCoordinator(config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
coordinator.mu.Lock()
|
||||||
|
defer coordinator.mu.Unlock()
|
||||||
|
return r.exchangeSignedSessionGrantLocked(config, coordinator, grant)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) exchangeSignedSessionGrantLocked(
|
||||||
|
config SignedSessionConfig,
|
||||||
|
coordinator *signedSessionCoordinator,
|
||||||
|
grant string,
|
||||||
|
) error {
|
||||||
record, err := r.loadSignedSession(config)
|
record, err := r.loadSignedSession(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
grantHashBytes := sha256.Sum256([]byte(grant))
|
||||||
|
grantHash := hex.EncodeToString(grantHashBytes[:])
|
||||||
|
// A duplicated callback may arrive after another runtime already exchanged
|
||||||
|
// the same one-time grant. Treat that exact shared result as completed
|
||||||
|
// instead of consuming the grant again.
|
||||||
|
if coordinator.completedGrantHash == grantHash &&
|
||||||
|
signedSessionRecordIsUsable(record) {
|
||||||
|
coordinator.clearBlockedGeneration()
|
||||||
|
coordinator.clearChallenge()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
endpoint, err := signedSessionURL(config, config.Endpoints.Exchange)
|
endpoint, err := signedSessionURL(config, config.Endpoints.Exchange)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -333,24 +555,51 @@ func (r *extensionRuntime) exchangeSignedSessionGrant(grant string) error {
|
|||||||
"platform": config.Platform,
|
"platform": config.Platform,
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(payload)
|
body, _ := json.Marshal(payload)
|
||||||
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
var respBody []byte
|
||||||
if err != nil {
|
for attempt := 1; attempt <= signedSessionExchangeMaxAttempts; attempt++ {
|
||||||
return err
|
req, requestErr := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
||||||
}
|
if requestErr != nil {
|
||||||
req.Header.Set("Content-Type", "application/json")
|
return requestErr
|
||||||
req.Header.Set("Accept", "application/json")
|
}
|
||||||
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
|
req.Header.Set("Content-Type", "application/json")
|
||||||
resp, err := r.httpClient.Do(req)
|
req.Header.Set("Accept", "application/json")
|
||||||
if err != nil {
|
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
|
||||||
return err
|
resp, requestErr := r.httpClient.Do(req)
|
||||||
}
|
if requestErr != nil {
|
||||||
defer resp.Body.Close()
|
return requestErr
|
||||||
respBody, err := readExtensionHTTPResponseBody(resp)
|
}
|
||||||
if err != nil {
|
respBody, requestErr = readExtensionHTTPResponseBody(resp)
|
||||||
return err
|
resp.Body.Close()
|
||||||
}
|
if requestErr != nil {
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
return requestErr
|
||||||
return fmt.Errorf("session exchange failed: HTTP %d", resp.StatusCode)
|
}
|
||||||
|
if resp.StatusCode == http.StatusTooManyRequests && attempt < signedSessionExchangeMaxAttempts {
|
||||||
|
retryAfter := time.Duration(signedSessionRetryAfterSeconds(resp)) * time.Second
|
||||||
|
if retryAfter <= 0 {
|
||||||
|
retryAfter = time.Second
|
||||||
|
}
|
||||||
|
if retryAfter > signedSessionMaxRetryAfter {
|
||||||
|
retryAfter = signedSessionMaxRetryAfter
|
||||||
|
}
|
||||||
|
LogWarn(
|
||||||
|
"SignedSession",
|
||||||
|
"Grant exchange rate limited for extension %s; retrying in %s (attempt %d/%d)",
|
||||||
|
r.extensionID,
|
||||||
|
retryAfter,
|
||||||
|
attempt+1,
|
||||||
|
signedSessionExchangeMaxAttempts,
|
||||||
|
)
|
||||||
|
signedSessionRetryWait(retryAfter)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
message := fmt.Sprintf("session exchange failed: HTTP %d", resp.StatusCode)
|
||||||
|
if retryAfter := signedSessionRetryAfterSeconds(resp); retryAfter > 0 {
|
||||||
|
message += fmt.Sprintf("; retry-after seconds: %d", retryAfter)
|
||||||
|
}
|
||||||
|
return errors.New(message)
|
||||||
|
}
|
||||||
|
break
|
||||||
}
|
}
|
||||||
var exchanged signedSessionExchangeResponse
|
var exchanged signedSessionExchangeResponse
|
||||||
if err := json.Unmarshal(respBody, &exchanged); err != nil {
|
if err := json.Unmarshal(respBody, &exchanged); err != nil {
|
||||||
@@ -362,7 +611,13 @@ func (r *extensionRuntime) exchangeSignedSessionGrant(grant string) error {
|
|||||||
record.SessionID = exchanged.SessionID
|
record.SessionID = exchanged.SessionID
|
||||||
record.SessionSecret = exchanged.SessionSecret
|
record.SessionSecret = exchanged.SessionSecret
|
||||||
record.ExpiresAt = exchanged.ExpiresAt
|
record.ExpiresAt = exchanged.ExpiresAt
|
||||||
return r.saveSignedSession(config, record)
|
if err := r.saveSignedSession(config, record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
coordinator.completedGrantHash = grantHash
|
||||||
|
coordinator.clearBlockedGeneration()
|
||||||
|
coordinator.clearChallenge()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value {
|
||||||
@@ -392,36 +647,226 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value
|
|||||||
}
|
}
|
||||||
extraHeaders := parseGojaHeaders(call.Argument(3).Export())
|
extraHeaders := parseGojaHeaders(call.Argument(3).Export())
|
||||||
|
|
||||||
|
coordinator, err := r.signedSessionCoordinator(config)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
}
|
||||||
|
coordinator.mu.Lock()
|
||||||
record, err := r.ensureSignedSession(config)
|
record, err := r.ensureSignedSession(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" {
|
authURL, verificationErr := r.startSignedSessionVerificationLocked(config, coordinator, "signed-fetch")
|
||||||
|
coordinator.mu.Unlock()
|
||||||
|
if authURL != "" {
|
||||||
return r.signedSessionVerificationRequiredValue(authURL)
|
return r.signedSessionVerificationRequiredValue(authURL)
|
||||||
|
} else if verificationErr != nil {
|
||||||
|
return r.vm.ToValue(map[string]any{"ok": false, "error": verificationErr.Error()})
|
||||||
}
|
}
|
||||||
return r.vm.ToValue(map[string]any{"ok": false, "error": err.Error()})
|
return r.vm.ToValue(map[string]any{"ok": false, "error": err.Error()})
|
||||||
}
|
}
|
||||||
|
if coordinator.generationIsBlocked(record) {
|
||||||
|
authURL, verificationErr := r.startSignedSessionVerificationLocked(
|
||||||
|
config,
|
||||||
|
coordinator,
|
||||||
|
"signed-fetch-blocked-generation",
|
||||||
|
)
|
||||||
|
if authURL != "" {
|
||||||
|
coordinator.mu.Unlock()
|
||||||
|
return r.signedSessionVerificationRequiredValue(authURL)
|
||||||
|
}
|
||||||
|
if verificationErr != nil {
|
||||||
|
coordinator.mu.Unlock()
|
||||||
|
return r.vm.ToValue(map[string]any{"ok": false, "error": verificationErr.Error()})
|
||||||
|
}
|
||||||
|
record, err = r.loadSignedSession(config)
|
||||||
|
if err != nil {
|
||||||
|
coordinator.mu.Unlock()
|
||||||
|
return r.vm.ToValue(map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
}
|
||||||
|
if !signedSessionRecordIsUsable(record) || coordinator.generationIsBlocked(record) {
|
||||||
|
coordinator.mu.Unlock()
|
||||||
|
return r.vm.ToValue(map[string]any{
|
||||||
|
"ok": false,
|
||||||
|
"error": "verification_required: signed-session generation is blocked",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
coordinator.mu.Unlock()
|
||||||
|
|
||||||
resp, respBody, respHeaders, err := r.doSignedSessionRequest(config, record, method, requestPath, body, extraHeaders)
|
// A request that loses a race with a successful grant exchange may return a
|
||||||
if err != nil {
|
// canonical SESSION_INVALID response for the old secret. Reload and retry
|
||||||
return r.vm.ToValue(map[string]any{"ok": false, "error": err.Error()})
|
// with the newer shared session; never let that stale response erase its
|
||||||
}
|
// replacement. Provider retries have an independent, bounded budget.
|
||||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusPreconditionRequired {
|
sessionRetries := 0
|
||||||
record.SessionID = ""
|
providerRetries := 0
|
||||||
record.SessionSecret = ""
|
requestAuthRetryUsed := false
|
||||||
record.ExpiresAt = ""
|
for {
|
||||||
_ = r.saveSignedSession(config, record)
|
resp, respBody, respHeaders, requestErr := r.doSignedSessionRequest(
|
||||||
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" {
|
config,
|
||||||
return r.signedSessionVerificationRequiredValue(authURL)
|
record,
|
||||||
|
method,
|
||||||
|
requestPath,
|
||||||
|
body,
|
||||||
|
extraHeaders,
|
||||||
|
)
|
||||||
|
if requestErr != nil {
|
||||||
|
return r.vm.ToValue(map[string]any{"ok": false, "error": requestErr.Error()})
|
||||||
}
|
}
|
||||||
|
contract, _ := parseSignedSessionErrorContract(respBody)
|
||||||
|
|
||||||
|
if signedSessionSameOperationRetry(resp.StatusCode, contract) {
|
||||||
|
if providerRetries >= signedSessionMaxProviderRetries {
|
||||||
|
return r.signedSessionResponseValue(resp, respBody, respHeaders)
|
||||||
|
}
|
||||||
|
providerRetries++
|
||||||
|
delay := signedSessionProviderRetryDuration(resp, contract)
|
||||||
|
LogWarn(
|
||||||
|
"SignedSession",
|
||||||
|
"Provider temporarily unavailable for extension %s; retrying in %s (attempt %d/%d)",
|
||||||
|
r.extensionID,
|
||||||
|
delay,
|
||||||
|
providerRetries+1,
|
||||||
|
signedSessionMaxProviderRetries+1,
|
||||||
|
)
|
||||||
|
if waitErr := signedSessionProviderWait(resp.Request.Context(), delay); waitErr != nil {
|
||||||
|
return r.vm.ToValue(map[string]any{"ok": false, "error": waitErr.Error()})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if signedSessionRequestAuthInvalid(resp.StatusCode, contract) {
|
||||||
|
coordinator.mu.Lock()
|
||||||
|
latest, loadErr := r.loadSignedSession(config)
|
||||||
|
if loadErr != nil {
|
||||||
|
coordinator.mu.Unlock()
|
||||||
|
return r.vm.ToValue(map[string]any{"ok": false, "error": loadErr.Error()})
|
||||||
|
}
|
||||||
|
if !requestAuthRetryUsed &&
|
||||||
|
signedSessionRecordIsUsable(latest) &&
|
||||||
|
!sameSignedSession(latest, record) {
|
||||||
|
requestAuthRetryUsed = true
|
||||||
|
record = latest
|
||||||
|
coordinator.mu.Unlock()
|
||||||
|
LogDebug(
|
||||||
|
"SignedSession",
|
||||||
|
"Retrying stale REQUEST_AUTH_INVALID for extension %s with the current session generation",
|
||||||
|
r.extensionID,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
coordinator.mu.Unlock()
|
||||||
|
LogWarn(
|
||||||
|
"SignedSession",
|
||||||
|
"REQUEST_AUTH_INVALID for extension %s on the current session generation; preserving session state",
|
||||||
|
r.extensionID,
|
||||||
|
)
|
||||||
|
return r.signedSessionResponseValue(resp, respBody, respHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
gatewayAction := signedSessionGatewayAction(resp.StatusCode, contract)
|
||||||
|
if gatewayAction == "" {
|
||||||
|
return r.signedSessionResponseValue(resp, respBody, respHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
coordinator.mu.Lock()
|
||||||
|
latest, loadErr := r.loadSignedSession(config)
|
||||||
|
if loadErr != nil {
|
||||||
|
coordinator.mu.Unlock()
|
||||||
|
return r.vm.ToValue(map[string]any{"ok": false, "error": loadErr.Error()})
|
||||||
|
}
|
||||||
|
if signedSessionRecordIsUsable(latest) &&
|
||||||
|
!sameSignedSession(latest, record) {
|
||||||
|
if sessionRetries >= signedSessionMaxSessionRetries {
|
||||||
|
coordinator.mu.Unlock()
|
||||||
|
return r.vm.ToValue(map[string]any{"ok": false, "error": "signed-session retry limit reached"})
|
||||||
|
}
|
||||||
|
sessionRetries++
|
||||||
|
record = latest
|
||||||
|
coordinator.mu.Unlock()
|
||||||
|
LogDebug(
|
||||||
|
"SignedSession",
|
||||||
|
"Discarding stale %s response for extension %s and retrying with the exchanged session",
|
||||||
|
contract.Code,
|
||||||
|
r.extensionID,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// VERIFY_REQUIRED is an explicit challenge request, not a revocation.
|
||||||
|
// Only SESSION_INVALID is allowed to clear the current gateway session.
|
||||||
|
if gatewayAction == "bootstrap_session" && sameSignedSession(latest, record) {
|
||||||
|
coordinator.clearBlockedGeneration()
|
||||||
|
latest.SessionID = ""
|
||||||
|
latest.SessionSecret = ""
|
||||||
|
latest.ExpiresAt = ""
|
||||||
|
if saveErr := r.saveSignedSession(config, latest); saveErr != nil {
|
||||||
|
coordinator.mu.Unlock()
|
||||||
|
return r.vm.ToValue(map[string]any{"ok": false, "error": saveErr.Error()})
|
||||||
|
}
|
||||||
|
} else if gatewayAction == "verify" && sameSignedSession(latest, record) {
|
||||||
|
// Stop subsequent requests from repeatedly hitting the gateway with
|
||||||
|
// a generation that is known to require human verification.
|
||||||
|
coordinator.blockGeneration(record)
|
||||||
|
}
|
||||||
|
authURL, verificationErr := r.startSignedSessionVerificationLocked(
|
||||||
|
config,
|
||||||
|
coordinator,
|
||||||
|
"signed-fetch-"+gatewayAction,
|
||||||
|
)
|
||||||
|
if authURL != "" {
|
||||||
|
coordinator.mu.Unlock()
|
||||||
|
return r.signedSessionVerificationRequiredValue(authURL)
|
||||||
|
} else if verificationErr != nil {
|
||||||
|
coordinator.mu.Unlock()
|
||||||
|
return r.vm.ToValue(map[string]any{"ok": false, "error": verificationErr.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bootstrap may silently issue a replacement session instead of a
|
||||||
|
// challenge. Retry the original operation with that generation.
|
||||||
|
bootstrapped, loadErr := r.loadSignedSession(config)
|
||||||
|
if loadErr != nil {
|
||||||
|
coordinator.mu.Unlock()
|
||||||
|
return r.vm.ToValue(map[string]any{"ok": false, "error": loadErr.Error()})
|
||||||
|
}
|
||||||
|
if signedSessionRecordIsUsable(bootstrapped) &&
|
||||||
|
!sameSignedSession(bootstrapped, record) &&
|
||||||
|
sessionRetries < signedSessionMaxSessionRetries {
|
||||||
|
sessionRetries++
|
||||||
|
record = bootstrapped
|
||||||
|
coordinator.mu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
coordinator.mu.Unlock()
|
||||||
|
return r.signedSessionResponseValue(resp, respBody, respHeaders)
|
||||||
}
|
}
|
||||||
return r.vm.ToValue(map[string]any{
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) signedSessionResponseValue(
|
||||||
|
resp *http.Response,
|
||||||
|
respBody []byte,
|
||||||
|
respHeaders map[string]any,
|
||||||
|
) goja.Value {
|
||||||
|
contract, hasContract := parseSignedSessionErrorContract(respBody)
|
||||||
|
retryAfterSeconds := signedSessionRetryAfterSeconds(resp)
|
||||||
|
if retryAfterSeconds <= 0 && contract.RetryAfterSeconds > 0 {
|
||||||
|
retryAfterSeconds = contract.RetryAfterSeconds
|
||||||
|
}
|
||||||
|
result := map[string]any{
|
||||||
"statusCode": resp.StatusCode,
|
"statusCode": resp.StatusCode,
|
||||||
"status": resp.StatusCode,
|
"status": resp.StatusCode,
|
||||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
"url": resp.Request.URL.String(),
|
"url": resp.Request.URL.String(),
|
||||||
"body": string(respBody),
|
"body": string(respBody),
|
||||||
"headers": respHeaders,
|
"headers": respHeaders,
|
||||||
"retryAfterSeconds": signedSessionRetryAfterSeconds(resp),
|
"retryAfterSeconds": retryAfterSeconds,
|
||||||
})
|
}
|
||||||
|
if hasContract {
|
||||||
|
result["error"] = contract.Error
|
||||||
|
result["code"] = contract.Code
|
||||||
|
result["origin"] = contract.Origin
|
||||||
|
result["action"] = contract.Action
|
||||||
|
result["retryable"] = contract.Retryable
|
||||||
|
result["retryMode"] = contract.RetryMode
|
||||||
|
}
|
||||||
|
return r.vm.ToValue(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) signedSessionVerificationRequiredValue(authURL string) goja.Value {
|
func (r *extensionRuntime) signedSessionVerificationRequiredValue(authURL string) goja.Value {
|
||||||
@@ -490,45 +935,138 @@ func (r *extensionRuntime) refreshSignedSession(config SignedSessionConfig, reco
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) startSignedSessionVerification(config SignedSessionConfig, _ string) string {
|
func (r *extensionRuntime) startSignedSessionVerification(config SignedSessionConfig, reason string) (string, error) {
|
||||||
|
coordinator, err := r.signedSessionCoordinator(config)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
coordinator.mu.Lock()
|
||||||
|
defer coordinator.mu.Unlock()
|
||||||
|
return r.startSignedSessionVerificationLocked(config, coordinator, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) startSignedSessionVerificationLocked(
|
||||||
|
config SignedSessionConfig,
|
||||||
|
coordinator *signedSessionCoordinator,
|
||||||
|
reason string,
|
||||||
|
) (string, error) {
|
||||||
|
if coordinator.activeChallenge() {
|
||||||
|
pendingAuthRequestsMu.Lock()
|
||||||
|
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||||
|
ExtensionID: r.extensionID,
|
||||||
|
AuthURL: coordinator.authURL,
|
||||||
|
CallbackURL: coordinator.callbackURL,
|
||||||
|
CreatedAt: coordinator.challengeCreatedAt,
|
||||||
|
}
|
||||||
|
pendingAuthRequestsMu.Unlock()
|
||||||
|
coordinator.pendingExtensionIDs[r.extensionID] = struct{}{}
|
||||||
|
return coordinator.authURL, nil
|
||||||
|
}
|
||||||
|
if coordinator.authURL != "" {
|
||||||
|
coordinator.clearChallenge()
|
||||||
|
}
|
||||||
|
if pending := GetPendingAuthRequest(r.extensionID); pending != nil {
|
||||||
|
if time.Since(pending.CreatedAt) < pendingAuthRequestTTL &&
|
||||||
|
strings.TrimSpace(pending.AuthURL) != "" {
|
||||||
|
coordinator.rememberChallenge(
|
||||||
|
r.extensionID,
|
||||||
|
pending.AuthURL,
|
||||||
|
pending.CallbackURL,
|
||||||
|
)
|
||||||
|
return pending.AuthURL, nil
|
||||||
|
}
|
||||||
|
ClearPendingAuthRequest(r.extensionID)
|
||||||
|
}
|
||||||
|
|
||||||
record, err := r.loadSignedSession(config)
|
record, err := r.loadSignedSession(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return "", fmt.Errorf("load signed-session bootstrap state: %w", err)
|
||||||
}
|
}
|
||||||
bootstrapURL, err := signedSessionURL(config, config.Endpoints.Bootstrap)
|
bootstrapURL, err := signedSessionURL(config, config.Endpoints.Bootstrap)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return "", fmt.Errorf("build signed-session bootstrap URL: %w", err)
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(bootstrapURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("parse signed-session bootstrap URL: %w", err)
|
||||||
}
|
}
|
||||||
parsed, _ := url.Parse(bootstrapURL)
|
|
||||||
query := parsed.Query()
|
query := parsed.Query()
|
||||||
query.Set("app_version", config.AppVersion)
|
query.Set("app_version", config.AppVersion)
|
||||||
query.Set("install_id", record.InstallID)
|
query.Set("install_id", record.InstallID)
|
||||||
parsed.RawQuery = query.Encode()
|
parsed.RawQuery = query.Encode()
|
||||||
req, err := http.NewRequest(http.MethodGet, parsed.String(), nil)
|
if r.httpClient == nil {
|
||||||
if err != nil {
|
return "", fmt.Errorf("signed-session bootstrap HTTP client is unavailable")
|
||||||
return ""
|
}
|
||||||
|
|
||||||
|
var resp *http.Response
|
||||||
|
for attempt := 0; attempt < 2; attempt++ {
|
||||||
|
req, requestErr := http.NewRequest(http.MethodGet, parsed.String(), nil)
|
||||||
|
if requestErr != nil {
|
||||||
|
return "", fmt.Errorf("build signed-session bootstrap request: %w", requestErr)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
|
||||||
|
resp, err = r.httpClient.Do(req)
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if resp != nil && resp.Body != nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
resp = nil
|
||||||
|
}
|
||||||
|
if attempt == 0 {
|
||||||
|
// Android can retain pooled connections across a Wi-Fi/cellular
|
||||||
|
// transition. Rebuild the GET once after dropping those sockets.
|
||||||
|
r.httpClient.CloseIdleConnections()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
|
|
||||||
resp, err := r.httpClient.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
var urlErr *url.Error
|
||||||
|
if errors.As(err, &urlErr) && urlErr.Err != nil {
|
||||||
|
err = urlErr.Err
|
||||||
|
}
|
||||||
|
bootstrapErr := fmt.Errorf(
|
||||||
|
"signed-session bootstrap network request to %s failed: %v",
|
||||||
|
parsed.Host,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
LogWarn("SignedSession", "Bootstrap failed for extension %s (%s): %v", r.extensionID, reason, bootstrapErr)
|
||||||
|
return "", bootstrapErr
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxExtensionHTTPResponseBytes))
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
// Drain a bounded error response so the transport can reuse the
|
||||||
return ""
|
// connection without exposing response bodies that may contain secrets.
|
||||||
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
|
||||||
|
message := fmt.Sprintf("signed-session bootstrap returned HTTP %d", resp.StatusCode)
|
||||||
|
if resp.StatusCode >= 500 {
|
||||||
|
message = fmt.Sprintf("signed-session bootstrap network request returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if retryAfter := signedSessionRetryAfterSeconds(resp); retryAfter > 0 {
|
||||||
|
message += fmt.Sprintf("; retry-after seconds: %d", retryAfter)
|
||||||
|
}
|
||||||
|
bootstrapErr := errors.New(message)
|
||||||
|
LogWarn("SignedSession", "Bootstrap failed for extension %s (%s): %v", r.extensionID, reason, bootstrapErr)
|
||||||
|
return "", bootstrapErr
|
||||||
|
}
|
||||||
|
body, err := readExtensionHTTPResponseBody(resp)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read signed-session bootstrap response: %w", err)
|
||||||
}
|
}
|
||||||
var boot signedSessionExchangeResponse
|
var boot signedSessionExchangeResponse
|
||||||
if err := json.Unmarshal(body, &boot); err != nil {
|
if err := json.Unmarshal(body, &boot); err != nil {
|
||||||
return ""
|
return "", fmt.Errorf("decode signed-session bootstrap response: %w", err)
|
||||||
}
|
}
|
||||||
if boot.SessionID != "" && boot.SessionSecret != "" && boot.ExpiresAt != "" {
|
if boot.SessionID != "" && boot.SessionSecret != "" && boot.ExpiresAt != "" {
|
||||||
record.SessionID = boot.SessionID
|
record.SessionID = boot.SessionID
|
||||||
record.SessionSecret = boot.SessionSecret
|
record.SessionSecret = boot.SessionSecret
|
||||||
record.ExpiresAt = boot.ExpiresAt
|
record.ExpiresAt = boot.ExpiresAt
|
||||||
_ = r.saveSignedSession(config, record)
|
if err := r.saveSignedSession(config, record); err != nil {
|
||||||
return ""
|
return "", fmt.Errorf("save bootstrapped signed session: %w", err)
|
||||||
|
}
|
||||||
|
coordinator.clearBlockedGeneration()
|
||||||
|
coordinator.clearChallenge()
|
||||||
|
return "", nil
|
||||||
}
|
}
|
||||||
authURL := boot.AuthURL
|
authURL := boot.AuthURL
|
||||||
if authURL == "" && boot.ChallengeURL != "" {
|
if authURL == "" && boot.ChallengeURL != "" {
|
||||||
@@ -537,17 +1075,19 @@ func (r *extensionRuntime) startSignedSessionVerification(config SignedSessionCo
|
|||||||
if authURL == "" && boot.ChallengeID != "" {
|
if authURL == "" && boot.ChallengeID != "" {
|
||||||
authURL = r.buildSignedSessionChallengeURL(config, boot.ChallengeID)
|
authURL = r.buildSignedSessionChallengeURL(config, boot.ChallengeID)
|
||||||
}
|
}
|
||||||
if authURL != "" {
|
if authURL == "" {
|
||||||
pendingAuthRequestsMu.Lock()
|
return "", fmt.Errorf("signed-session bootstrap did not return a session or verification challenge")
|
||||||
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
|
||||||
ExtensionID: r.extensionID,
|
|
||||||
AuthURL: authURL,
|
|
||||||
CallbackURL: config.CallbackURL,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
pendingAuthRequestsMu.Unlock()
|
|
||||||
}
|
}
|
||||||
return authURL
|
pendingAuthRequestsMu.Lock()
|
||||||
|
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||||
|
ExtensionID: r.extensionID,
|
||||||
|
AuthURL: authURL,
|
||||||
|
CallbackURL: config.CallbackURL,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
pendingAuthRequestsMu.Unlock()
|
||||||
|
coordinator.rememberChallenge(r.extensionID, authURL, config.CallbackURL)
|
||||||
|
return authURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) buildSignedSessionChallengeURL(config SignedSessionConfig, challengeID string) string {
|
func (r *extensionRuntime) buildSignedSessionChallengeURL(config SignedSessionConfig, challengeID string) string {
|
||||||
@@ -608,7 +1148,7 @@ func (r *extensionRuntime) doSignedSessionRequest(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
ts := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
ts := signedSessionRequestNow().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||||
nonce := randomHex(12)
|
nonce := randomHex(12)
|
||||||
bodyHashBytes := sha256.Sum256(body)
|
bodyHashBytes := sha256.Sum256(body)
|
||||||
bodyHash := hex.EncodeToString(bodyHashBytes[:])
|
bodyHash := hex.EncodeToString(bodyHashBytes[:])
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -379,9 +379,9 @@ func TestExtensionRuntime_BindDownloadCancelContext(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
req = runtime.bindDownloadCancelContext(req)
|
req = runtime.bindDownloadCancelContext(req)
|
||||||
cancelMu.Lock()
|
downloadCancels.mu.Lock()
|
||||||
refs := cancelMap["test-item"].refs
|
refs := downloadCancels.entries["test-item"].refs
|
||||||
cancelMu.Unlock()
|
downloadCancels.mu.Unlock()
|
||||||
if refs != 1 {
|
if refs != 1 {
|
||||||
t.Fatalf("binding a request leaked a cancellation reference: %d", refs)
|
t.Fatalf("binding a request leaked a cancellation reference: %d", refs)
|
||||||
}
|
}
|
||||||
@@ -468,9 +468,9 @@ func TestExtensionRuntime_BindExtensionRequestCancelContext(t *testing.T) {
|
|||||||
t.Fatalf("new request: %v", err)
|
t.Fatalf("new request: %v", err)
|
||||||
}
|
}
|
||||||
req = runtime.bindDownloadCancelContext(req)
|
req = runtime.bindDownloadCancelContext(req)
|
||||||
extensionRequestCancelMu.Lock()
|
extensionRequestCancels.mu.Lock()
|
||||||
refs := extensionRequestCancelMap[requestID].refs
|
refs := extensionRequestCancels.entries[requestID].refs
|
||||||
extensionRequestCancelMu.Unlock()
|
extensionRequestCancels.mu.Unlock()
|
||||||
if refs != 1 {
|
if refs != 1 {
|
||||||
t.Fatalf("binding a request leaked a cancellation reference: %d", refs)
|
t.Fatalf("binding a request leaked a cancellation reference: %d", refs)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ require (
|
|||||||
github.com/go-flac/go-flac/v2 v2.0.4
|
github.com/go-flac/go-flac/v2 v2.0.4
|
||||||
github.com/refraction-networking/utls v1.8.2
|
github.com/refraction-networking/utls v1.8.2
|
||||||
golang.org/x/crypto v0.54.0
|
golang.org/x/crypto v0.54.0
|
||||||
golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5
|
golang.org/x/mobile v0.0.0-20260730202154-c700fe717e6e
|
||||||
golang.org/x/net v0.57.0
|
golang.org/x/net v0.57.0
|
||||||
golang.org/x/text v0.40.0
|
golang.org/x/text v0.40.0
|
||||||
)
|
)
|
||||||
|
|||||||
+2
-2
@@ -34,8 +34,8 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ
|
|||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||||
golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5 h1:Mn1OzFmF0ZKX/ZayHz/UdnWHufPp1wlD9lZ5U8LRDFY=
|
golang.org/x/mobile v0.0.0-20260730202154-c700fe717e6e h1:v5PGTbI7uer7FhpDeGryGytfbI8t7CDWkKukozvIsNU=
|
||||||
golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5/go.mod h1:YX+n47s+53POxN3dx9cIGxG3hGUm/lD64hvrRJFbcSA=
|
golang.org/x/mobile v0.0.0-20260730202154-c700fe717e6e/go.mod h1:YX+n47s+53POxN3dx9cIGxG3hGUm/lD64hvrRJFbcSA=
|
||||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||||
|
|||||||
@@ -126,8 +126,8 @@ var metadataTransport = &http.Transport{
|
|||||||
ReadBufferSize: 32 * 1024,
|
ReadBufferSize: 32 * 1024,
|
||||||
// Metadata responses are JSON; transparent gzip cuts transfer size several
|
// Metadata responses are JSON; transparent gzip cuts transfer size several
|
||||||
// times over. Downloads stay on sharedTransport with compression disabled.
|
// times over. Downloads stay on sharedTransport with compression disabled.
|
||||||
DisableCompression: false,
|
DisableCompression: false,
|
||||||
TLSClientConfig: newTLSCompatibilityConfig(false),
|
TLSClientConfig: newTLSCompatibilityConfig(false),
|
||||||
}
|
}
|
||||||
|
|
||||||
var sharedClient = &http.Client{
|
var sharedClient = &http.Client{
|
||||||
|
|||||||
+1
-666
@@ -1,13 +1,11 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -32,7 +30,7 @@ type LibraryScanResult struct {
|
|||||||
ReleaseDate string `json:"releaseDate,omitempty"`
|
ReleaseDate string `json:"releaseDate,omitempty"`
|
||||||
BitDepth int `json:"bitDepth,omitempty"`
|
BitDepth int `json:"bitDepth,omitempty"`
|
||||||
SampleRate int `json:"sampleRate,omitempty"`
|
SampleRate int `json:"sampleRate,omitempty"`
|
||||||
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis)
|
Bitrate int `json:"bitrate,omitempty"` // average kbps for both lossless and lossy audio
|
||||||
Genre string `json:"genre,omitempty"`
|
Genre string `json:"genre,omitempty"`
|
||||||
Composer string `json:"composer,omitempty"`
|
Composer string `json:"composer,omitempty"`
|
||||||
Label string `json:"label,omitempty"`
|
Label string `json:"label,omitempty"`
|
||||||
@@ -467,390 +465,6 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanAudioFileWithKnownModTime(filePath, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
|
||||||
return scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, "", "", scanTime, knownModTime)
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displayNameHint, coverCacheKey, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
|
||||||
ext := resolveLibraryAudioExt(filePath, displayNameHint)
|
|
||||||
|
|
||||||
result := &LibraryScanResult{
|
|
||||||
ID: generateLibraryID(filePath),
|
|
||||||
FilePath: filePath,
|
|
||||||
ScannedAt: scanTime,
|
|
||||||
Format: strings.TrimPrefix(ext, "."),
|
|
||||||
}
|
|
||||||
|
|
||||||
if knownModTime > 0 {
|
|
||||||
result.FileModTime = knownModTime
|
|
||||||
} else if info, err := os.Stat(filePath); err == nil {
|
|
||||||
result.FileModTime = info.ModTime().UnixMilli()
|
|
||||||
}
|
|
||||||
|
|
||||||
libraryCoverCacheMu.RLock()
|
|
||||||
coverCacheDir := libraryCoverCacheDir
|
|
||||||
libraryCoverCacheMu.RUnlock()
|
|
||||||
if ext == ".flac" {
|
|
||||||
return scanFLACFileWithCoverCache(filePath, result, displayNameHint, coverCacheDir, coverCacheKey)
|
|
||||||
}
|
|
||||||
if ext == ".m4a" || ext == ".mp4" || ext == ".aac" {
|
|
||||||
return scanM4AFileWithCoverCache(filePath, result, displayNameHint, coverCacheDir, coverCacheKey)
|
|
||||||
}
|
|
||||||
if coverCacheDir != "" {
|
|
||||||
coverPath, err := SaveCoverToCacheWithHintAndKey(
|
|
||||||
filePath,
|
|
||||||
displayNameHint,
|
|
||||||
coverCacheDir,
|
|
||||||
coverCacheKey,
|
|
||||||
)
|
|
||||||
if err == nil && coverPath != "" {
|
|
||||||
result.CoverPath = coverPath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch ext {
|
|
||||||
case ".mp3":
|
|
||||||
return scanMP3File(filePath, result, displayNameHint)
|
|
||||||
case ".opus", ".ogg":
|
|
||||||
return scanOggFile(filePath, result, displayNameHint)
|
|
||||||
case ".ape", ".wv", ".mpc":
|
|
||||||
return scanAPEFile(filePath, result, displayNameHint)
|
|
||||||
case ".wav":
|
|
||||||
return scanWAVFile(filePath, result, displayNameHint)
|
|
||||||
case ".aiff", ".aif", ".aifc":
|
|
||||||
return scanAIFFFile(filePath, result, displayNameHint)
|
|
||||||
default:
|
|
||||||
return scanFromFilename(filePath, displayNameHint, result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func embeddedCoverMIME(data []byte) string {
|
|
||||||
if len(data) >= 8 &&
|
|
||||||
data[0] == 0x89 &&
|
|
||||||
data[1] == 0x50 &&
|
|
||||||
data[2] == 0x4e &&
|
|
||||||
data[3] == 0x47 {
|
|
||||||
return "image/png"
|
|
||||||
}
|
|
||||||
return "image/jpeg"
|
|
||||||
}
|
|
||||||
|
|
||||||
func cacheScannedCover(filePath, cacheDir, coverCacheKey string, coverData []byte) string {
|
|
||||||
if cacheDir == "" || len(coverData) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
|
|
||||||
path, err := saveLibraryCoverDataToCache(cacheDir, cacheKey, coverData, embeddedCoverMIME(coverData))
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveLibraryAudioExt(filePath, displayNameHint string) string {
|
|
||||||
ext := strings.ToLower(filepath.Ext(filePath))
|
|
||||||
if ext != "" {
|
|
||||||
return ext
|
|
||||||
}
|
|
||||||
return strings.ToLower(filepath.Ext(displayNameHint))
|
|
||||||
}
|
|
||||||
|
|
||||||
func libraryDisplayNameOrPath(filePath, displayNameHint string) string {
|
|
||||||
if displayNameHint != "" {
|
|
||||||
return displayNameHint
|
|
||||||
}
|
|
||||||
return filePath
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyDefaultLibraryMetadata(filePath, displayNameHint string, result *LibraryScanResult) {
|
|
||||||
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
|
|
||||||
if result.TrackName == "" {
|
|
||||||
result.TrackName = strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
|
|
||||||
}
|
|
||||||
if result.ArtistName == "" {
|
|
||||||
result.ArtistName = "Unknown Artist"
|
|
||||||
}
|
|
||||||
if result.AlbumName == "" {
|
|
||||||
result.AlbumName = "Unknown Album"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
|
||||||
return scanFLACFileWithCoverCache(filePath, result, displayNameHint, "", "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanFLACFileWithCoverCache(filePath string, result *LibraryScanResult, displayNameHint, coverCacheDir, coverCacheKey string) (*LibraryScanResult, error) {
|
|
||||||
f, err := parseFlacFile(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return scanFromFilename(filePath, displayNameHint, result)
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
metadata := metadataFromParsedFlac(f)
|
|
||||||
|
|
||||||
result.TrackName = metadata.Title
|
|
||||||
result.ArtistName = metadata.Artist
|
|
||||||
result.AlbumName = metadata.Album
|
|
||||||
result.AlbumArtist = metadata.AlbumArtist
|
|
||||||
result.ISRC = metadata.ISRC
|
|
||||||
result.TrackNumber = metadata.TrackNumber
|
|
||||||
result.TotalTracks = metadata.TotalTracks
|
|
||||||
result.DiscNumber = metadata.DiscNumber
|
|
||||||
result.TotalDiscs = metadata.TotalDiscs
|
|
||||||
result.ReleaseDate = metadata.Date
|
|
||||||
result.Genre = metadata.Genre
|
|
||||||
result.Composer = metadata.Composer
|
|
||||||
result.Label = metadata.Label
|
|
||||||
result.Copyright = metadata.Copyright
|
|
||||||
|
|
||||||
quality, err := audioQualityFromParsedFlac(f)
|
|
||||||
if err == nil {
|
|
||||||
result.BitDepth = quality.BitDepth
|
|
||||||
result.SampleRate = quality.SampleRate
|
|
||||||
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
|
||||||
result.Duration = int(quality.TotalSamples / int64(quality.SampleRate))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if coverCacheDir != "" {
|
|
||||||
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
|
|
||||||
if existing := existingLibraryCoverCachePath(coverCacheDir, cacheKey); existing != "" {
|
|
||||||
result.CoverPath = existing
|
|
||||||
} else if coverData, coverErr := coverArtFromParsedFlac(f); coverErr == nil {
|
|
||||||
result.CoverPath = cacheScannedCover(filePath, coverCacheDir, cacheKey, coverData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
|
||||||
return scanM4AFileWithCoverCache(filePath, result, displayNameHint, "", "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanM4AFileWithCoverCache(filePath string, result *LibraryScanResult, displayNameHint, coverCacheDir, coverCacheKey string) (*LibraryScanResult, error) {
|
|
||||||
f, err := os.Open(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return scanFromFilename(filePath, displayNameHint, result)
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
info, err := f.Stat()
|
|
||||||
if err != nil {
|
|
||||||
return scanFromFilename(filePath, displayNameHint, result)
|
|
||||||
}
|
|
||||||
fileSize := info.Size()
|
|
||||||
|
|
||||||
var metadata *AudioMetadata
|
|
||||||
ilst, ilstErr := findM4AIlstAtom(f, fileSize)
|
|
||||||
if ilstErr == nil {
|
|
||||||
metadata, err = readM4ATagsFromIlst(f, fileSize, ilst)
|
|
||||||
} else {
|
|
||||||
err = ilstErr
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
GoLog("[LibraryScan] M4A read error for %s: %v\n", filePath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if metadata != nil {
|
|
||||||
applyAudioMetadataToScan(metadata, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
quality, err := m4aQualityFromFile(f, fileSize)
|
|
||||||
if err == nil {
|
|
||||||
result.BitDepth = quality.BitDepth
|
|
||||||
result.SampleRate = quality.SampleRate
|
|
||||||
result.Duration = quality.Duration
|
|
||||||
if quality.Bitrate > 0 {
|
|
||||||
result.Bitrate = quality.Bitrate
|
|
||||||
}
|
|
||||||
if format := libraryFormatForM4ACodec(quality.Codec); format != "" {
|
|
||||||
result.Format = format
|
|
||||||
if isLosslessLibraryFormat(format) {
|
|
||||||
result.Bitrate = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if coverCacheDir != "" {
|
|
||||||
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
|
|
||||||
if existing := existingLibraryCoverCachePath(coverCacheDir, cacheKey); existing != "" {
|
|
||||||
result.CoverPath = existing
|
|
||||||
} else if ilstErr == nil {
|
|
||||||
if coverData, coverErr := extractCoverFromM4AIlst(f, fileSize, ilst); coverErr == nil {
|
|
||||||
result.CoverPath = cacheScannedCover(filePath, coverCacheDir, cacheKey, coverData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if metadata == nil {
|
|
||||||
return scanFromFilename(filePath, displayNameHint, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func libraryFormatForM4ACodec(codec string) string {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(codec)) {
|
|
||||||
case "flac":
|
|
||||||
return "flac"
|
|
||||||
case "alac":
|
|
||||||
return "alac"
|
|
||||||
case "eac3", "ec-3":
|
|
||||||
return "eac3"
|
|
||||||
case "ac3", "ac-3":
|
|
||||||
return "ac3"
|
|
||||||
case "ac4", "ac-4":
|
|
||||||
return "ac4"
|
|
||||||
case "aac", "mp4a":
|
|
||||||
return "m4a"
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isLosslessLibraryFormat(format string) bool {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
|
||||||
case "flac", "alac", "wav", "aiff", "aif", "aifc":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
|
||||||
metadata, err := ReadID3Tags(filePath)
|
|
||||||
if err != nil {
|
|
||||||
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
|
|
||||||
return scanFromFilename(filePath, displayNameHint, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
applyAudioMetadataToScan(metadata, result)
|
|
||||||
|
|
||||||
quality, err := GetMP3Quality(filePath)
|
|
||||||
if err == nil {
|
|
||||||
result.SampleRate = quality.SampleRate
|
|
||||||
result.BitDepth = quality.BitDepth // 0 for lossy
|
|
||||||
result.Duration = quality.Duration
|
|
||||||
if quality.Bitrate > 0 {
|
|
||||||
result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
|
||||||
metadata, err := ReadOggVorbisComments(filePath)
|
|
||||||
if err != nil {
|
|
||||||
GoLog("[LibraryScan] Ogg/Opus read error for %s: %v\n", filePath, err)
|
|
||||||
return scanFromFilename(filePath, displayNameHint, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
result.TrackName = metadata.Title
|
|
||||||
result.ArtistName = metadata.Artist
|
|
||||||
result.AlbumName = metadata.Album
|
|
||||||
result.AlbumArtist = metadata.AlbumArtist
|
|
||||||
result.ISRC = metadata.ISRC
|
|
||||||
result.TrackNumber = metadata.TrackNumber
|
|
||||||
result.TotalTracks = metadata.TotalTracks
|
|
||||||
result.DiscNumber = metadata.DiscNumber
|
|
||||||
result.TotalDiscs = metadata.TotalDiscs
|
|
||||||
result.Genre = metadata.Genre
|
|
||||||
result.ReleaseDate = metadata.Date
|
|
||||||
result.Composer = metadata.Composer
|
|
||||||
result.Label = metadata.Label
|
|
||||||
result.Copyright = metadata.Copyright
|
|
||||||
|
|
||||||
quality, err := GetOggQuality(filePath)
|
|
||||||
if err == nil {
|
|
||||||
result.SampleRate = quality.SampleRate
|
|
||||||
result.BitDepth = quality.BitDepth // 0 for lossy
|
|
||||||
result.Duration = quality.Duration
|
|
||||||
if quality.Bitrate > 0 {
|
|
||||||
result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanAPEFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
|
||||||
tag, err := ReadAPETags(filePath)
|
|
||||||
if err != nil {
|
|
||||||
GoLog("[LibraryScan] APE tag read error for %s: %v\n", filePath, err)
|
|
||||||
return scanFromFilename(filePath, displayNameHint, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata := APETagToAudioMetadata(tag)
|
|
||||||
if metadata == nil {
|
|
||||||
return scanFromFilename(filePath, displayNameHint, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
applyAudioMetadataToScan(metadata, result)
|
|
||||||
|
|
||||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
|
||||||
result.MetadataFromFilename = true
|
|
||||||
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
|
|
||||||
filename := strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
|
|
||||||
|
|
||||||
parts := strings.SplitN(filename, " - ", 2)
|
|
||||||
if len(parts) == 2 {
|
|
||||||
if len(parts[0]) <= 3 && isNumeric(parts[0]) {
|
|
||||||
result.TrackName = parts[1]
|
|
||||||
result.ArtistName = "Unknown Artist"
|
|
||||||
} else {
|
|
||||||
result.ArtistName = parts[0]
|
|
||||||
result.TrackName = parts[1]
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if len(filename) > 3 && isNumeric(filename[:2]) {
|
|
||||||
title := strings.TrimLeft(filename[2:], " .-")
|
|
||||||
result.TrackName = title
|
|
||||||
} else {
|
|
||||||
result.TrackName = filename
|
|
||||||
}
|
|
||||||
result.ArtistName = "Unknown Artist"
|
|
||||||
}
|
|
||||||
|
|
||||||
dir := filepath.Dir(filePath)
|
|
||||||
result.AlbumName = filepath.Base(dir)
|
|
||||||
if result.AlbumName == "." || result.AlbumName == "" || result.AlbumName == "fd" || result.AlbumName == "self" {
|
|
||||||
result.AlbumName = "Unknown Album"
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isNumeric(s string) bool {
|
|
||||||
for _, c := range s {
|
|
||||||
if c < '0' || c > '9' {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return len(s) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateLibraryID(filePath string) string {
|
|
||||||
return fmt.Sprintf("lib_%x", hashString(filePath))
|
|
||||||
}
|
|
||||||
|
|
||||||
func hashString(s string) uint32 {
|
|
||||||
var hash uint32 = 5381
|
|
||||||
for _, c := range s {
|
|
||||||
hash = ((hash << 5) + hash) + uint32(c)
|
|
||||||
}
|
|
||||||
return hash
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetLibraryScanProgress() string {
|
func GetLibraryScanProgress() string {
|
||||||
libraryScanProgressMu.RLock()
|
libraryScanProgressMu.RLock()
|
||||||
defer libraryScanProgressMu.RUnlock()
|
defer libraryScanProgressMu.RUnlock()
|
||||||
@@ -897,282 +511,3 @@ func ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayNameHint,
|
|||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) {
|
|
||||||
existingFiles := make(map[string]int64)
|
|
||||||
if snapshotPath == "" {
|
|
||||||
return existingFiles, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := os.Open(snapshotPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(file)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parts := strings.SplitN(line, "\t", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
modTime, err := strconv.ParseInt(parts[0], 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
existingFiles[parts[1]] = modTime
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return existingFiles, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFiles map[string]int64) (string, error) {
|
|
||||||
if folderPath == "" {
|
|
||||||
return "{}", fmt.Errorf("folder path is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := os.Stat(folderPath)
|
|
||||||
if err != nil {
|
|
||||||
return "{}", fmt.Errorf("folder not found: %w", err)
|
|
||||||
}
|
|
||||||
if !info.IsDir() {
|
|
||||||
return "{}", fmt.Errorf("path is not a folder: %s", folderPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles))
|
|
||||||
|
|
||||||
libraryScanProgressMu.Lock()
|
|
||||||
libraryScanProgress = LibraryScanProgress{}
|
|
||||||
libraryScanProgressMu.Unlock()
|
|
||||||
|
|
||||||
libraryScanCancelMu.Lock()
|
|
||||||
if libraryScanCancel != nil {
|
|
||||||
close(libraryScanCancel)
|
|
||||||
}
|
|
||||||
libraryScanCancel = make(chan struct{})
|
|
||||||
cancelCh := libraryScanCancel
|
|
||||||
libraryScanCancelMu.Unlock()
|
|
||||||
|
|
||||||
currentFiles, err := collectLibraryAudioFiles(folderPath, cancelCh)
|
|
||||||
if err != nil {
|
|
||||||
return "{}", err
|
|
||||||
}
|
|
||||||
currentPathSet := make(map[string]bool, len(currentFiles))
|
|
||||||
for _, fileInfo := range currentFiles {
|
|
||||||
currentPathSet[fileInfo.path] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
totalFiles := len(currentFiles)
|
|
||||||
libraryScanProgressMu.Lock()
|
|
||||||
libraryScanProgress.TotalFiles = totalFiles
|
|
||||||
libraryScanProgressMu.Unlock()
|
|
||||||
|
|
||||||
var filesToScan []libraryAudioFileInfo
|
|
||||||
skippedCount := 0
|
|
||||||
existingCueTrackModTimes := make(map[string]int64)
|
|
||||||
for existingPath, modTime := range existingFiles {
|
|
||||||
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
|
|
||||||
baseCuePath := existingPath[:idx]
|
|
||||||
if _, exists := existingCueTrackModTimes[baseCuePath]; !exists {
|
|
||||||
existingCueTrackModTimes[baseCuePath] = modTime
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, f := range currentFiles {
|
|
||||||
existingModTime, exists := existingFiles[f.path]
|
|
||||||
if !exists {
|
|
||||||
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
|
|
||||||
if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks {
|
|
||||||
if f.modTime == cueTrackModTime {
|
|
||||||
skippedCount++
|
|
||||||
} else {
|
|
||||||
filesToScan = append(filesToScan, f)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
filesToScan = append(filesToScan, f)
|
|
||||||
} else if f.modTime != existingModTime {
|
|
||||||
filesToScan = append(filesToScan, f)
|
|
||||||
} else {
|
|
||||||
skippedCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var deletedPaths []string
|
|
||||||
for existingPath := range existingFiles {
|
|
||||||
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
|
|
||||||
baseCuePath := existingPath[:idx]
|
|
||||||
if currentPathSet[baseCuePath] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
deletedPaths = append(deletedPaths, existingPath)
|
|
||||||
} else if !currentPathSet[existingPath] {
|
|
||||||
deletedPaths = append(deletedPaths, existingPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[LibraryScan] Incremental: %d to scan, %d skipped, %d deleted\n",
|
|
||||||
len(filesToScan), skippedCount, len(deletedPaths))
|
|
||||||
|
|
||||||
if len(filesToScan) == 0 {
|
|
||||||
libraryScanProgressMu.Lock()
|
|
||||||
libraryScanProgress.ScannedFiles = totalFiles
|
|
||||||
libraryScanProgress.IsComplete = true
|
|
||||||
libraryScanProgress.ProgressPct = 100
|
|
||||||
libraryScanProgressMu.Unlock()
|
|
||||||
|
|
||||||
result := IncrementalScanResult{
|
|
||||||
Scanned: []LibraryScanResult{},
|
|
||||||
DeletedPaths: deletedPaths,
|
|
||||||
SkippedCount: skippedCount,
|
|
||||||
TotalFiles: totalFiles,
|
|
||||||
}
|
|
||||||
jsonBytes, _ := json.Marshal(result)
|
|
||||||
return string(jsonBytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
results := make([]LibraryScanResult, 0, len(filesToScan))
|
|
||||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
|
||||||
errorCount := 0
|
|
||||||
|
|
||||||
cueReferencedAudioFilesInc := make(map[string]bool)
|
|
||||||
parsedCueFiles := make(map[string]scannedCueFileInfo)
|
|
||||||
for _, f := range filesToScan {
|
|
||||||
ext := strings.ToLower(filepath.Ext(f.path))
|
|
||||||
if ext == ".cue" {
|
|
||||||
sheet, err := ParseCueFile(f.path)
|
|
||||||
if err == nil && sheet.FileName != "" {
|
|
||||||
audioPath := ResolveCueAudioPath(f.path, sheet.FileName)
|
|
||||||
if audioPath != "" {
|
|
||||||
parsedCueFiles[f.path] = scannedCueFileInfo{
|
|
||||||
sheet: sheet,
|
|
||||||
audioPath: audioPath,
|
|
||||||
}
|
|
||||||
cueReferencedAudioFilesInc[audioPath] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resultsByIndex := make(map[int][]LibraryScanResult, len(filesToScan))
|
|
||||||
audioTasks := make([]libraryScanTask, 0, len(filesToScan))
|
|
||||||
completedFiles := skippedCount
|
|
||||||
|
|
||||||
for i, f := range filesToScan {
|
|
||||||
select {
|
|
||||||
case <-cancelCh:
|
|
||||||
return "{}", fmt.Errorf("scan cancelled")
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
ext := strings.ToLower(filepath.Ext(f.path))
|
|
||||||
|
|
||||||
if ext == ".cue" {
|
|
||||||
var cueResults []LibraryScanResult
|
|
||||||
cueInfo, ok := parsedCueFiles[f.path]
|
|
||||||
if ok {
|
|
||||||
cueResults, err = scanCueSheetForLibrary(
|
|
||||||
f.path,
|
|
||||||
cueInfo.sheet,
|
|
||||||
cueInfo.audioPath,
|
|
||||||
"",
|
|
||||||
f.modTime,
|
|
||||||
"",
|
|
||||||
scanTime,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
cueResults, err = ScanCueFileForLibrary(f.path, scanTime)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
errorCount++
|
|
||||||
GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err)
|
|
||||||
completedFiles++
|
|
||||||
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
resultsByIndex[i] = cueResults
|
|
||||||
completedFiles++
|
|
||||||
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if cueReferencedAudioFilesInc[f.path] {
|
|
||||||
completedFiles++
|
|
||||||
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
audioTasks = append(audioTasks, libraryScanTask{index: i, info: f})
|
|
||||||
}
|
|
||||||
|
|
||||||
audioResults, audioErrors, err := scanLibraryAudioTasksParallel(
|
|
||||||
audioTasks,
|
|
||||||
scanTime,
|
|
||||||
cancelCh,
|
|
||||||
totalFiles,
|
|
||||||
&completedFiles,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return "{}", err
|
|
||||||
}
|
|
||||||
errorCount += audioErrors
|
|
||||||
for index, scanResults := range audioResults {
|
|
||||||
resultsByIndex[index] = scanResults
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range filesToScan {
|
|
||||||
results = append(results, resultsByIndex[i]...)
|
|
||||||
}
|
|
||||||
|
|
||||||
libraryScanProgressMu.Lock()
|
|
||||||
libraryScanProgress.ErrorCount = errorCount
|
|
||||||
libraryScanProgress.IsComplete = true
|
|
||||||
libraryScanProgress.ScannedFiles = totalFiles
|
|
||||||
libraryScanProgress.ProgressPct = 100
|
|
||||||
libraryScanProgressMu.Unlock()
|
|
||||||
|
|
||||||
GoLog("[LibraryScan] Incremental scan complete: %d scanned, %d skipped, %d deleted, %d errors\n",
|
|
||||||
len(results), skippedCount, len(deletedPaths), errorCount)
|
|
||||||
|
|
||||||
scanResult := IncrementalScanResult{
|
|
||||||
Scanned: results,
|
|
||||||
DeletedPaths: deletedPaths,
|
|
||||||
SkippedCount: skippedCount,
|
|
||||||
TotalFiles: totalFiles,
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(scanResult)
|
|
||||||
if err != nil {
|
|
||||||
return "{}", fmt.Errorf("failed to marshal results: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
|
|
||||||
existingFiles := make(map[string]int64)
|
|
||||||
if existingFilesJSON != "" && existingFilesJSON != "{}" {
|
|
||||||
if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil {
|
|
||||||
GoLog("[LibraryScan] Warning: failed to parse existing files JSON: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath string) (string, error) {
|
|
||||||
existingFiles, err := loadExistingFilesSnapshot(snapshotPath)
|
|
||||||
if err != nil {
|
|
||||||
return "{}", fmt.Errorf("failed to load incremental snapshot: %w", err)
|
|
||||||
}
|
|
||||||
return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func scanAudioFileWithKnownModTime(filePath, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
||||||
|
return scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, "", "", scanTime, knownModTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displayNameHint, coverCacheKey, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
||||||
|
ext := resolveLibraryAudioExt(filePath, displayNameHint)
|
||||||
|
|
||||||
|
result := &LibraryScanResult{
|
||||||
|
ID: generateLibraryID(filePath),
|
||||||
|
FilePath: filePath,
|
||||||
|
ScannedAt: scanTime,
|
||||||
|
Format: strings.TrimPrefix(ext, "."),
|
||||||
|
}
|
||||||
|
|
||||||
|
if knownModTime > 0 {
|
||||||
|
result.FileModTime = knownModTime
|
||||||
|
} else if info, err := os.Stat(filePath); err == nil {
|
||||||
|
result.FileModTime = info.ModTime().UnixMilli()
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryCoverCacheMu.RLock()
|
||||||
|
coverCacheDir := libraryCoverCacheDir
|
||||||
|
libraryCoverCacheMu.RUnlock()
|
||||||
|
if ext == ".flac" {
|
||||||
|
return scanFLACFileWithCoverCache(filePath, result, displayNameHint, coverCacheDir, coverCacheKey)
|
||||||
|
}
|
||||||
|
if ext == ".m4a" || ext == ".mp4" || ext == ".aac" {
|
||||||
|
return scanM4AFileWithCoverCache(filePath, result, displayNameHint, coverCacheDir, coverCacheKey)
|
||||||
|
}
|
||||||
|
if coverCacheDir != "" {
|
||||||
|
coverPath, err := SaveCoverToCacheWithHintAndKey(
|
||||||
|
filePath,
|
||||||
|
displayNameHint,
|
||||||
|
coverCacheDir,
|
||||||
|
coverCacheKey,
|
||||||
|
)
|
||||||
|
if err == nil && coverPath != "" {
|
||||||
|
result.CoverPath = coverPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ext {
|
||||||
|
case ".mp3":
|
||||||
|
return scanMP3File(filePath, result, displayNameHint)
|
||||||
|
case ".opus", ".ogg":
|
||||||
|
return scanOggFile(filePath, result, displayNameHint)
|
||||||
|
case ".ape", ".wv", ".mpc":
|
||||||
|
return scanAPEFile(filePath, result, displayNameHint)
|
||||||
|
case ".wav":
|
||||||
|
return scanWAVFile(filePath, result, displayNameHint)
|
||||||
|
case ".aiff", ".aif", ".aifc":
|
||||||
|
return scanAIFFFile(filePath, result, displayNameHint)
|
||||||
|
default:
|
||||||
|
return scanFromFilename(filePath, displayNameHint, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func embeddedCoverMIME(data []byte) string {
|
||||||
|
if len(data) >= 8 &&
|
||||||
|
data[0] == 0x89 &&
|
||||||
|
data[1] == 0x50 &&
|
||||||
|
data[2] == 0x4e &&
|
||||||
|
data[3] == 0x47 {
|
||||||
|
return "image/png"
|
||||||
|
}
|
||||||
|
return "image/jpeg"
|
||||||
|
}
|
||||||
|
|
||||||
|
func cacheScannedCover(filePath, cacheDir, coverCacheKey string, coverData []byte) string {
|
||||||
|
if cacheDir == "" || len(coverData) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
|
||||||
|
path, err := saveLibraryCoverDataToCache(cacheDir, cacheKey, coverData, embeddedCoverMIME(coverData))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveLibraryAudioExt(filePath, displayNameHint string) string {
|
||||||
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
if ext != "" {
|
||||||
|
return ext
|
||||||
|
}
|
||||||
|
return strings.ToLower(filepath.Ext(displayNameHint))
|
||||||
|
}
|
||||||
|
|
||||||
|
func libraryDisplayNameOrPath(filePath, displayNameHint string) string {
|
||||||
|
if displayNameHint != "" {
|
||||||
|
return displayNameHint
|
||||||
|
}
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyDefaultLibraryMetadata(filePath, displayNameHint string, result *LibraryScanResult) {
|
||||||
|
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
|
||||||
|
if result.TrackName == "" {
|
||||||
|
result.TrackName = strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
|
||||||
|
}
|
||||||
|
if result.ArtistName == "" {
|
||||||
|
result.ArtistName = "Unknown Artist"
|
||||||
|
}
|
||||||
|
if result.AlbumName == "" {
|
||||||
|
result.AlbumName = "Unknown Album"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||||
|
return scanFLACFileWithCoverCache(filePath, result, displayNameHint, "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanFLACFileWithCoverCache(filePath string, result *LibraryScanResult, displayNameHint, coverCacheDir, coverCacheKey string) (*LibraryScanResult, error) {
|
||||||
|
f, err := parseFlacFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return scanFromFilename(filePath, displayNameHint, result)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
metadata := metadataFromParsedFlac(f)
|
||||||
|
|
||||||
|
result.TrackName = metadata.Title
|
||||||
|
result.ArtistName = metadata.Artist
|
||||||
|
result.AlbumName = metadata.Album
|
||||||
|
result.AlbumArtist = metadata.AlbumArtist
|
||||||
|
result.ISRC = metadata.ISRC
|
||||||
|
result.TrackNumber = metadata.TrackNumber
|
||||||
|
result.TotalTracks = metadata.TotalTracks
|
||||||
|
result.DiscNumber = metadata.DiscNumber
|
||||||
|
result.TotalDiscs = metadata.TotalDiscs
|
||||||
|
result.ReleaseDate = metadata.Date
|
||||||
|
result.Genre = metadata.Genre
|
||||||
|
result.Composer = metadata.Composer
|
||||||
|
result.Label = metadata.Label
|
||||||
|
result.Copyright = metadata.Copyright
|
||||||
|
|
||||||
|
quality, err := audioQualityFromParsedFlac(f)
|
||||||
|
if err == nil {
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
||||||
|
durationSeconds := float64(quality.TotalSamples) / float64(quality.SampleRate)
|
||||||
|
result.Duration = int(durationSeconds)
|
||||||
|
if info, statErr := os.Stat(filePath); statErr == nil && info.Size() > 0 {
|
||||||
|
result.Bitrate = int(float64(info.Size()) * 8 / durationSeconds / 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if coverCacheDir != "" {
|
||||||
|
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
|
||||||
|
if existing := existingLibraryCoverCachePath(coverCacheDir, cacheKey); existing != "" {
|
||||||
|
result.CoverPath = existing
|
||||||
|
} else if coverData, coverErr := coverArtFromParsedFlac(f); coverErr == nil {
|
||||||
|
result.CoverPath = cacheScannedCover(filePath, coverCacheDir, cacheKey, coverData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||||
|
return scanM4AFileWithCoverCache(filePath, result, displayNameHint, "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanM4AFileWithCoverCache(filePath string, result *LibraryScanResult, displayNameHint, coverCacheDir, coverCacheKey string) (*LibraryScanResult, error) {
|
||||||
|
f, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return scanFromFilename(filePath, displayNameHint, result)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return scanFromFilename(filePath, displayNameHint, result)
|
||||||
|
}
|
||||||
|
fileSize := info.Size()
|
||||||
|
|
||||||
|
var metadata *AudioMetadata
|
||||||
|
ilst, ilstErr := findM4AIlstAtom(f, fileSize)
|
||||||
|
if ilstErr == nil {
|
||||||
|
metadata, err = readM4ATagsFromIlst(f, fileSize, ilst)
|
||||||
|
} else {
|
||||||
|
err = ilstErr
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[LibraryScan] M4A read error for %s: %v\n", filePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata != nil {
|
||||||
|
applyAudioMetadataToScan(metadata, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
quality, err := m4aQualityFromFile(f, fileSize)
|
||||||
|
if err == nil {
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
result.Duration = quality.Duration
|
||||||
|
if quality.Bitrate > 0 {
|
||||||
|
result.Bitrate = quality.Bitrate
|
||||||
|
}
|
||||||
|
if format := libraryFormatForM4ACodec(quality.Codec); format != "" {
|
||||||
|
result.Format = format
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if coverCacheDir != "" {
|
||||||
|
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
|
||||||
|
if existing := existingLibraryCoverCachePath(coverCacheDir, cacheKey); existing != "" {
|
||||||
|
result.CoverPath = existing
|
||||||
|
} else if ilstErr == nil {
|
||||||
|
if coverData, coverErr := extractCoverFromM4AIlst(f, fileSize, ilst); coverErr == nil {
|
||||||
|
result.CoverPath = cacheScannedCover(filePath, coverCacheDir, cacheKey, coverData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata == nil {
|
||||||
|
return scanFromFilename(filePath, displayNameHint, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func libraryFormatForM4ACodec(codec string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(codec)) {
|
||||||
|
case "flac":
|
||||||
|
return "flac"
|
||||||
|
case "alac":
|
||||||
|
return "alac"
|
||||||
|
case "eac3", "ec-3":
|
||||||
|
return "eac3"
|
||||||
|
case "ac3", "ac-3":
|
||||||
|
return "ac3"
|
||||||
|
case "ac4", "ac-4":
|
||||||
|
return "ac4"
|
||||||
|
case "aac", "mp4a":
|
||||||
|
return "m4a"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLosslessLibraryFormat(format string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||||
|
case "flac", "alac", "wav", "aiff", "aif", "aifc":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||||
|
metadata, err := ReadID3Tags(filePath)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
|
||||||
|
return scanFromFilename(filePath, displayNameHint, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyAudioMetadataToScan(metadata, result)
|
||||||
|
|
||||||
|
quality, err := GetMP3Quality(filePath)
|
||||||
|
if err == nil {
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
result.BitDepth = quality.BitDepth // 0 for lossy
|
||||||
|
result.Duration = quality.Duration
|
||||||
|
if quality.Bitrate > 0 {
|
||||||
|
result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||||
|
metadata, err := ReadOggVorbisComments(filePath)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[LibraryScan] Ogg/Opus read error for %s: %v\n", filePath, err)
|
||||||
|
return scanFromFilename(filePath, displayNameHint, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.TrackName = metadata.Title
|
||||||
|
result.ArtistName = metadata.Artist
|
||||||
|
result.AlbumName = metadata.Album
|
||||||
|
result.AlbumArtist = metadata.AlbumArtist
|
||||||
|
result.ISRC = metadata.ISRC
|
||||||
|
result.TrackNumber = metadata.TrackNumber
|
||||||
|
result.TotalTracks = metadata.TotalTracks
|
||||||
|
result.DiscNumber = metadata.DiscNumber
|
||||||
|
result.TotalDiscs = metadata.TotalDiscs
|
||||||
|
result.Genre = metadata.Genre
|
||||||
|
result.ReleaseDate = metadata.Date
|
||||||
|
result.Composer = metadata.Composer
|
||||||
|
result.Label = metadata.Label
|
||||||
|
result.Copyright = metadata.Copyright
|
||||||
|
|
||||||
|
quality, err := GetOggQuality(filePath)
|
||||||
|
if err == nil {
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
result.BitDepth = quality.BitDepth // 0 for lossy
|
||||||
|
result.Duration = quality.Duration
|
||||||
|
if quality.Bitrate > 0 {
|
||||||
|
result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanAPEFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||||
|
tag, err := ReadAPETags(filePath)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[LibraryScan] APE tag read error for %s: %v\n", filePath, err)
|
||||||
|
return scanFromFilename(filePath, displayNameHint, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := APETagToAudioMetadata(tag)
|
||||||
|
if metadata == nil {
|
||||||
|
return scanFromFilename(filePath, displayNameHint, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyAudioMetadataToScan(metadata, result)
|
||||||
|
|
||||||
|
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||||
|
result.MetadataFromFilename = true
|
||||||
|
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
|
||||||
|
filename := strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
|
||||||
|
|
||||||
|
parts := strings.SplitN(filename, " - ", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
if len(parts[0]) <= 3 && isNumeric(parts[0]) {
|
||||||
|
result.TrackName = parts[1]
|
||||||
|
result.ArtistName = "Unknown Artist"
|
||||||
|
} else {
|
||||||
|
result.ArtistName = parts[0]
|
||||||
|
result.TrackName = parts[1]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(filename) > 3 && isNumeric(filename[:2]) {
|
||||||
|
title := strings.TrimLeft(filename[2:], " .-")
|
||||||
|
result.TrackName = title
|
||||||
|
} else {
|
||||||
|
result.TrackName = filename
|
||||||
|
}
|
||||||
|
result.ArtistName = "Unknown Artist"
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Dir(filePath)
|
||||||
|
result.AlbumName = filepath.Base(dir)
|
||||||
|
if result.AlbumName == "." || result.AlbumName == "" || result.AlbumName == "fd" || result.AlbumName == "self" {
|
||||||
|
result.AlbumName = "Unknown Album"
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNumeric(s string) bool {
|
||||||
|
for _, c := range s {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(s) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateLibraryID(filePath string) string {
|
||||||
|
return fmt.Sprintf("lib_%x", hashString(filePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashString(s string) uint32 {
|
||||||
|
var hash uint32 = 5381
|
||||||
|
for _, c := range s {
|
||||||
|
hash = ((hash << 5) + hash) + uint32(c)
|
||||||
|
}
|
||||||
|
return hash
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) {
|
||||||
|
existingFiles := make(map[string]int64)
|
||||||
|
if snapshotPath == "" {
|
||||||
|
return existingFiles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(snapshotPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(line, "\t", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
modTime, err := strconv.ParseInt(parts[0], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
existingFiles[parts[1]] = modTime
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return existingFiles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFiles map[string]int64) (string, error) {
|
||||||
|
if folderPath == "" {
|
||||||
|
return "{}", fmt.Errorf("folder path is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(folderPath)
|
||||||
|
if err != nil {
|
||||||
|
return "{}", fmt.Errorf("folder not found: %w", err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return "{}", fmt.Errorf("path is not a folder: %s", folderPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles))
|
||||||
|
|
||||||
|
libraryScanProgressMu.Lock()
|
||||||
|
libraryScanProgress = LibraryScanProgress{}
|
||||||
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
|
libraryScanCancelMu.Lock()
|
||||||
|
if libraryScanCancel != nil {
|
||||||
|
close(libraryScanCancel)
|
||||||
|
}
|
||||||
|
libraryScanCancel = make(chan struct{})
|
||||||
|
cancelCh := libraryScanCancel
|
||||||
|
libraryScanCancelMu.Unlock()
|
||||||
|
|
||||||
|
currentFiles, err := collectLibraryAudioFiles(folderPath, cancelCh)
|
||||||
|
if err != nil {
|
||||||
|
return "{}", err
|
||||||
|
}
|
||||||
|
currentPathSet := make(map[string]bool, len(currentFiles))
|
||||||
|
for _, fileInfo := range currentFiles {
|
||||||
|
currentPathSet[fileInfo.path] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
totalFiles := len(currentFiles)
|
||||||
|
libraryScanProgressMu.Lock()
|
||||||
|
libraryScanProgress.TotalFiles = totalFiles
|
||||||
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
|
var filesToScan []libraryAudioFileInfo
|
||||||
|
skippedCount := 0
|
||||||
|
existingCueTrackModTimes := make(map[string]int64)
|
||||||
|
for existingPath, modTime := range existingFiles {
|
||||||
|
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
|
||||||
|
baseCuePath := existingPath[:idx]
|
||||||
|
if _, exists := existingCueTrackModTimes[baseCuePath]; !exists {
|
||||||
|
existingCueTrackModTimes[baseCuePath] = modTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range currentFiles {
|
||||||
|
existingModTime, exists := existingFiles[f.path]
|
||||||
|
if !exists {
|
||||||
|
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
|
||||||
|
if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks {
|
||||||
|
if f.modTime == cueTrackModTime {
|
||||||
|
skippedCount++
|
||||||
|
} else {
|
||||||
|
filesToScan = append(filesToScan, f)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filesToScan = append(filesToScan, f)
|
||||||
|
} else if f.modTime != existingModTime {
|
||||||
|
filesToScan = append(filesToScan, f)
|
||||||
|
} else {
|
||||||
|
skippedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var deletedPaths []string
|
||||||
|
for existingPath := range existingFiles {
|
||||||
|
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
|
||||||
|
baseCuePath := existingPath[:idx]
|
||||||
|
if currentPathSet[baseCuePath] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
deletedPaths = append(deletedPaths, existingPath)
|
||||||
|
} else if !currentPathSet[existingPath] {
|
||||||
|
deletedPaths = append(deletedPaths, existingPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[LibraryScan] Incremental: %d to scan, %d skipped, %d deleted\n",
|
||||||
|
len(filesToScan), skippedCount, len(deletedPaths))
|
||||||
|
|
||||||
|
if len(filesToScan) == 0 {
|
||||||
|
libraryScanProgressMu.Lock()
|
||||||
|
libraryScanProgress.ScannedFiles = totalFiles
|
||||||
|
libraryScanProgress.IsComplete = true
|
||||||
|
libraryScanProgress.ProgressPct = 100
|
||||||
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
|
result := IncrementalScanResult{
|
||||||
|
Scanned: []LibraryScanResult{},
|
||||||
|
DeletedPaths: deletedPaths,
|
||||||
|
SkippedCount: skippedCount,
|
||||||
|
TotalFiles: totalFiles,
|
||||||
|
}
|
||||||
|
jsonBytes, _ := json.Marshal(result)
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]LibraryScanResult, 0, len(filesToScan))
|
||||||
|
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
errorCount := 0
|
||||||
|
|
||||||
|
cueReferencedAudioFilesInc := make(map[string]bool)
|
||||||
|
parsedCueFiles := make(map[string]scannedCueFileInfo)
|
||||||
|
for _, f := range filesToScan {
|
||||||
|
ext := strings.ToLower(filepath.Ext(f.path))
|
||||||
|
if ext == ".cue" {
|
||||||
|
sheet, err := ParseCueFile(f.path)
|
||||||
|
if err == nil && sheet.FileName != "" {
|
||||||
|
audioPath := ResolveCueAudioPath(f.path, sheet.FileName)
|
||||||
|
if audioPath != "" {
|
||||||
|
parsedCueFiles[f.path] = scannedCueFileInfo{
|
||||||
|
sheet: sheet,
|
||||||
|
audioPath: audioPath,
|
||||||
|
}
|
||||||
|
cueReferencedAudioFilesInc[audioPath] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsByIndex := make(map[int][]LibraryScanResult, len(filesToScan))
|
||||||
|
audioTasks := make([]libraryScanTask, 0, len(filesToScan))
|
||||||
|
completedFiles := skippedCount
|
||||||
|
|
||||||
|
for i, f := range filesToScan {
|
||||||
|
select {
|
||||||
|
case <-cancelCh:
|
||||||
|
return "{}", fmt.Errorf("scan cancelled")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(f.path))
|
||||||
|
|
||||||
|
if ext == ".cue" {
|
||||||
|
var cueResults []LibraryScanResult
|
||||||
|
cueInfo, ok := parsedCueFiles[f.path]
|
||||||
|
if ok {
|
||||||
|
cueResults, err = scanCueSheetForLibrary(
|
||||||
|
f.path,
|
||||||
|
cueInfo.sheet,
|
||||||
|
cueInfo.audioPath,
|
||||||
|
"",
|
||||||
|
f.modTime,
|
||||||
|
"",
|
||||||
|
scanTime,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
cueResults, err = ScanCueFileForLibrary(f.path, scanTime)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
errorCount++
|
||||||
|
GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err)
|
||||||
|
completedFiles++
|
||||||
|
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resultsByIndex[i] = cueResults
|
||||||
|
completedFiles++
|
||||||
|
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if cueReferencedAudioFilesInc[f.path] {
|
||||||
|
completedFiles++
|
||||||
|
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
audioTasks = append(audioTasks, libraryScanTask{index: i, info: f})
|
||||||
|
}
|
||||||
|
|
||||||
|
audioResults, audioErrors, err := scanLibraryAudioTasksParallel(
|
||||||
|
audioTasks,
|
||||||
|
scanTime,
|
||||||
|
cancelCh,
|
||||||
|
totalFiles,
|
||||||
|
&completedFiles,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "{}", err
|
||||||
|
}
|
||||||
|
errorCount += audioErrors
|
||||||
|
for index, scanResults := range audioResults {
|
||||||
|
resultsByIndex[index] = scanResults
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range filesToScan {
|
||||||
|
results = append(results, resultsByIndex[i]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryScanProgressMu.Lock()
|
||||||
|
libraryScanProgress.ErrorCount = errorCount
|
||||||
|
libraryScanProgress.IsComplete = true
|
||||||
|
libraryScanProgress.ScannedFiles = totalFiles
|
||||||
|
libraryScanProgress.ProgressPct = 100
|
||||||
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
|
GoLog("[LibraryScan] Incremental scan complete: %d scanned, %d skipped, %d deleted, %d errors\n",
|
||||||
|
len(results), skippedCount, len(deletedPaths), errorCount)
|
||||||
|
|
||||||
|
scanResult := IncrementalScanResult{
|
||||||
|
Scanned: results,
|
||||||
|
DeletedPaths: deletedPaths,
|
||||||
|
SkippedCount: skippedCount,
|
||||||
|
TotalFiles: totalFiles,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(scanResult)
|
||||||
|
if err != nil {
|
||||||
|
return "{}", fmt.Errorf("failed to marshal results: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
|
||||||
|
existingFiles := make(map[string]int64)
|
||||||
|
if existingFilesJSON != "" && existingFilesJSON != "{}" {
|
||||||
|
if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil {
|
||||||
|
GoLog("[LibraryScan] Warning: failed to parse existing files JSON: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath string) (string, error) {
|
||||||
|
existingFiles, err := loadExistingFilesSnapshot(snapshotPath)
|
||||||
|
if err != nil {
|
||||||
|
return "{}", fmt.Errorf("failed to load incremental snapshot: %w", err)
|
||||||
|
}
|
||||||
|
return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user