mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +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 | ||
|
|
08d648cfa8 | ||
|
|
40dd9bb68f | ||
|
|
aa0eecb27e | ||
|
|
b956c57652 | ||
|
|
fbe0674b04 | ||
|
|
1705fb2a81 | ||
|
|
a5f2760023 | ||
|
|
20081b14bd | ||
|
|
1cfda41ef2 | ||
|
|
19787ed461 | ||
|
|
93512fc3f7 | ||
|
|
555f0e8117 | ||
|
|
cbd8fd0342 | ||
|
|
60a74a7e0e | ||
|
|
eabafecaeb | ||
|
|
01c08322c1 | ||
|
|
a41074d271 | ||
|
|
452a15b652 | ||
|
|
4713be02de | ||
|
|
89dcef8ffd | ||
|
|
0d8bc7b269 | ||
|
|
fa432266f3 | ||
|
|
0cbe51ad19 | ||
|
|
5856729e9a | ||
|
|
c35b11a155 | ||
|
|
3f487c03cc | ||
|
|
5481f8e8cf | ||
|
|
cdbb0d6bd7 | ||
|
|
c9114fef01 | ||
|
|
480a96bd0e | ||
|
|
685392f073 | ||
|
|
0c558fb33b | ||
|
|
ac7840c4e5 | ||
|
|
f0988070bf | ||
|
|
87376ee73b | ||
|
|
b2726db84a | ||
|
|
4d77a0b178 | ||
|
|
bd6e2b4472 | ||
|
|
b7e96facbe | ||
|
|
648f36244b | ||
|
|
b5d02da759 | ||
|
|
c13fdcc6e1 | ||
|
|
5846dc2416 | ||
|
|
ef89352d09 | ||
|
|
c75139450a | ||
|
|
07c9cf034f | ||
|
|
b69f67a786 | ||
|
|
0961c09a6d | ||
|
|
491998e5c5 | ||
|
|
d3d34aaec1 | ||
|
|
5763a087c9 | ||
|
|
efa3ff3d66 | ||
|
|
ca8a6a8849 | ||
|
|
5a970ac83d | ||
|
|
10d59bc2b6 | ||
|
|
79bb3214ae | ||
|
|
6f566a9f03 | ||
|
|
82ba586b72 | ||
|
|
6f121c7543 | ||
|
|
35128a6bfc | ||
|
|
17dbcc1338 | ||
|
|
7f1c3e60db | ||
|
|
bab954c6d9 | ||
|
|
ed9511b17f | ||
|
|
ca1c95346f | ||
|
|
cd6cf05227 | ||
|
|
87bf33321b | ||
|
|
11567fee7d | ||
|
|
fd423e3095 | ||
|
|
f0afb1382e | ||
|
|
e2a2e8a914 | ||
|
|
4ffd5cc701 | ||
|
|
f09c607aab | ||
|
|
3833a66bf6 | ||
|
|
9ac10dd6bb | ||
|
|
e37f13275f | ||
|
|
4605517d55 | ||
|
|
c6e012b23d | ||
|
|
cdc1ba0a68 | ||
|
|
bd9b98b942 | ||
|
|
11979b12e2 | ||
|
|
53784d64c3 | ||
|
|
4caf875da4 | ||
|
|
5a1e898795 | ||
|
|
a7f9aae0a9 | ||
|
|
b3cad6e526 | ||
|
|
1893f3faa8 | ||
|
|
7573d360bf | ||
|
|
8966de6d0a | ||
|
|
b53146c599 | ||
|
|
266b08a80d | ||
|
|
357c684a21 | ||
|
|
556399d354 | ||
|
|
c1737b92d0 | ||
|
|
28cdb6c837 | ||
|
|
937ff6c4a2 | ||
|
|
547e6493dd | ||
|
|
3cdeeb2f5f | ||
|
|
f5b5af6eea | ||
|
|
8f7ede4730 | ||
|
|
a10aeb76ee | ||
|
|
2d9df7e1bc | ||
|
|
16e2046d0f | ||
|
|
306c34a32a | ||
|
|
93b173cff8 | ||
|
|
a287d6b815 | ||
|
|
9a205987ee | ||
|
|
a11cff8b28 | ||
|
|
23488a9c05 | ||
|
|
8610f18ff7 | ||
|
|
7a0be15359 | ||
|
|
8a332c566a | ||
|
|
8c33a86df5 | ||
|
|
c580280890 | ||
|
|
702fc9825e | ||
|
|
3cf9418f47 | ||
|
|
8d914d967c | ||
|
|
e8c9df20b3 | ||
|
|
f69f7e7bf6 | ||
|
|
eeb0298d13 | ||
|
|
96eb412fe5 | ||
|
|
f983a303fd | ||
|
|
08007f493c | ||
|
|
e302fac390 | ||
|
|
4d0f1120d2 | ||
|
|
9b53d662f7 | ||
|
|
c4e266a5b9 | ||
|
|
2273d9ba48 | ||
|
|
034b505d15 | ||
|
|
122b16133a | ||
|
|
86d5ae3a2b | ||
|
|
fd4348b803 | ||
|
|
34c7b33368 | ||
|
|
4fc6a6b45d | ||
|
|
aae48cc47c | ||
|
|
4b7834e090 | ||
|
|
9b3b586006 | ||
|
|
0f0ebe5565 | ||
|
|
2fa4aa5b70 | ||
|
|
8579f68554 | ||
|
|
fb7d101019 | ||
|
|
2d52f3251e | ||
|
|
de32b8b5f6 | ||
|
|
e1af1c8dc9 | ||
|
|
85949eef8c | ||
|
|
62015a4e2d | ||
|
|
44de61a06a | ||
|
|
4b9853eef7 | ||
|
|
4e0cae9c20 | ||
|
|
aa2cdef1d2 | ||
|
|
cea6a58c18 | ||
|
|
59a5ca622d | ||
|
|
05415d96d5 | ||
|
|
fca3039208 | ||
|
|
b12502c8f6 | ||
|
|
764f978897 | ||
|
|
8c2d2d5289 | ||
|
|
9580fafe4f | ||
|
|
49ecfe261a | ||
|
|
89e5f4bde9 | ||
|
|
2eab955f5f | ||
|
|
2bd671d305 | ||
|
|
e5a1ee2ac3 | ||
|
|
8f997314b0 | ||
|
|
2192e68684 | ||
|
|
21aeaafced | ||
|
|
270496b3d5 | ||
|
|
db477f970c | ||
|
|
0d39002af0 | ||
|
|
87b46a9694 | ||
|
|
364b2aa138 | ||
|
|
df1ac30e07 | ||
|
|
196fd0f651 | ||
|
|
b28e8a83a8 | ||
|
|
a8cc42ed30 | ||
|
|
b4f66ff4eb | ||
|
|
b1f8224310 | ||
|
|
f808a6a369 | ||
|
|
7d73e77f9b | ||
|
|
79e37769e5 | ||
|
|
9b7f1631f0 | ||
|
|
d819878ec7 | ||
|
|
823b050744 | ||
|
|
d7df859f1e | ||
|
|
8313f3c827 | ||
|
|
22151b0988 | ||
|
|
fb3fad279c | ||
|
|
7f7e408623 | ||
|
|
8abb99ac91 | ||
|
|
e85151643f | ||
|
|
1998a9300e | ||
|
|
19f69a6090 | ||
|
|
c24a72c302 | ||
|
|
860649e3ac | ||
|
|
2e7e2b1964 | ||
|
|
70cba44164 | ||
|
|
28b877f0eb | ||
|
|
32892bd4bd | ||
|
|
bb66cc470a | ||
|
|
60bf871945 | ||
|
|
d988b2c9d3 | ||
|
|
2f53be7c0d | ||
|
|
a1764b1aa0 | ||
|
|
99eed524c2 | ||
|
|
d909067c49 | ||
|
|
927fc8abac | ||
|
|
53824b7ac2 | ||
|
|
fd7424dd81 | ||
|
|
46491c137a | ||
|
|
0535b266ff | ||
|
|
d20f569678 | ||
|
|
4c83aeafb5 | ||
|
|
8abb9d119b | ||
|
|
6b30eba947 | ||
|
|
edb241faf7 | ||
|
|
49fa6c14e6 | ||
|
|
89c9fa323b | ||
|
|
fc2818dd97 | ||
|
|
c281763718 | ||
|
|
e97ecf38c5 | ||
|
|
fe72286273 | ||
|
|
2b8ec744dd | ||
|
|
5f11f5b114 | ||
|
|
61f62363b3 | ||
|
|
3278e32711 | ||
|
|
0be6455d46 | ||
|
|
0bf5a39a92 | ||
|
|
5424648158 | ||
|
|
dcfd95f276 | ||
|
|
4d6f7d8b08 | ||
|
|
2c2cf8cdf8 | ||
|
|
08c738dc69 | ||
|
|
eb36b0bb7b | ||
|
|
3fd14e21eb | ||
|
|
408895b607 | ||
|
|
1a01147a95 | ||
|
|
8950907428 | ||
|
|
eb40a88437 | ||
|
|
7f82049beb | ||
|
|
c0c1d745f3 | ||
|
|
c2b38a7c5a | ||
|
|
ae8638a4b2 | ||
|
|
b864fafa82 | ||
|
|
ee5ab1a751 | ||
|
|
64b884e27a | ||
|
|
dc8bb2cbc2 | ||
|
|
d882fc292c | ||
|
|
5dc0980ced | ||
|
|
1cd668c869 | ||
|
|
a827ebf6f4 | ||
|
|
3917ae02e2 | ||
|
|
bd14c7dc63 | ||
|
|
e0e28aee38 | ||
|
|
1550eedc12 | ||
|
|
b2074dfd02 | ||
|
|
e9171d6f21 | ||
|
|
ef60bba2e1 | ||
|
|
12fb942f16 | ||
|
|
3a2481e8b2 | ||
|
|
bede5ae8d7 | ||
|
|
445b186e3b | ||
|
|
354fe61b85 | ||
|
|
95f5ae610e | ||
|
|
2e806a28b9 | ||
|
|
2ab0350733 | ||
|
|
ce813bc216 | ||
|
|
21fe047e00 | ||
|
|
8558450378 | ||
|
|
f9e68b628d | ||
|
|
50509d0a16 | ||
|
|
c1c0494912 | ||
|
|
58e615462c | ||
|
|
f0bf769f0d | ||
|
|
423d50cfb5 | ||
|
|
2f4a62e03c | ||
|
|
e64bea41e6 | ||
|
|
f0acda0f01 | ||
|
|
af4e4561ec | ||
|
|
1787059f42 | ||
|
|
b2705cb2ae | ||
|
|
f236d72a19 | ||
|
|
cf270a36ff | ||
|
|
6d932386b0 | ||
|
|
9c054b9e3a | ||
|
|
d9f0007a2d | ||
|
|
ee35f52baf | ||
|
|
21347420f3 | ||
|
|
26987459f3 | ||
|
|
897388853b | ||
|
|
ef52332b8b | ||
|
|
1489378ffd | ||
|
|
ccc93f881a | ||
|
|
ded8b68098 | ||
|
|
983be8b37a |
@@ -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
|
||||
|
||||
# Scripts executed on Unix runners must keep LF line endings.
|
||||
*.sh text eol=lf
|
||||
gradlew text eol=lf
|
||||
|
||||
# Windows scripts
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
|
||||
@@ -1,30 +1,29 @@
|
||||
name: Bug Report
|
||||
description: Report a bug or unexpected behavior
|
||||
description: Report a reproducible bug or unexpected behavior
|
||||
title: "[Bug]: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug! Please fill out the form below.
|
||||
Thanks for reporting a SpotiFLAC Mobile bug. Please provide enough detail for another person to reproduce it.
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I have searched existing issues and this bug hasn't been reported yet
|
||||
- label: I searched existing issues and did not find this bug.
|
||||
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
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Bug Description
|
||||
description: A clear and concise description of what the bug is
|
||||
placeholder: Describe the bug...
|
||||
label: Description
|
||||
description: What happened, and what did you expect instead?
|
||||
placeholder: Include the visible error and any relevant context.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -32,29 +31,26 @@ body:
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Steps to reproduce the behavior
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. See error
|
||||
1. Open ...
|
||||
2. Enable ...
|
||||
3. Tap ...
|
||||
4. Observe ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
- type: dropdown
|
||||
id: trigger
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What did you expect to happen?
|
||||
placeholder: Describe what you expected...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: What actually happened?
|
||||
placeholder: Describe what actually happened...
|
||||
label: Area
|
||||
options:
|
||||
- App startup
|
||||
- Search or navigation
|
||||
- Download or verification
|
||||
- Library or metadata
|
||||
- Playback or background service
|
||||
- Audio analysis
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -62,62 +58,73 @@ body:
|
||||
id: version
|
||||
attributes:
|
||||
label: App Version
|
||||
description: Which version of SpotiFLAC are you using? (Check in Settings > About)
|
||||
placeholder: "e.g., v2.2.0"
|
||||
description: Find this in Settings > About.
|
||||
placeholder: "e.g. 4.8.1 (139)"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: platform
|
||||
id: channel
|
||||
attributes:
|
||||
label: Platform
|
||||
description: Which platform are you using?
|
||||
label: Release Channel
|
||||
options:
|
||||
- Android
|
||||
- iOS
|
||||
- Stable
|
||||
- Beta
|
||||
- Local or development build
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: device
|
||||
attributes:
|
||||
label: Device & OS Version
|
||||
description: What device and OS version are you using?
|
||||
placeholder: "e.g., Samsung Galaxy S24, Android 14"
|
||||
label: Device and OS
|
||||
placeholder: "e.g. Samsung Galaxy S24, Android 16"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: download-service
|
||||
id: storage
|
||||
attributes:
|
||||
label: Download Service
|
||||
description: Which download service were you using when the bug occurred?
|
||||
label: Download Storage
|
||||
options:
|
||||
- Tidal
|
||||
- Qobuz
|
||||
- Amazon Music
|
||||
- Deezer (search only)
|
||||
- SAF folder
|
||||
- App-specific folder
|
||||
- Not applicable
|
||||
- Not sure
|
||||
validations:
|
||||
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
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs / Screenshots
|
||||
label: Logs and Media
|
||||
description: |
|
||||
If applicable, add logs or screenshots to help explain your problem.
|
||||
|
||||
**To get logs:**
|
||||
1. Go to Settings > Options > Detailed Logging (turn ON)
|
||||
2. Reproduce the bug
|
||||
3. Go to Settings > Logs
|
||||
4. Tap Share button to export logs
|
||||
placeholder: Paste logs or drag & drop screenshots here...
|
||||
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.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Any other context about the problem
|
||||
placeholder: Add any other context...
|
||||
placeholder: Settings, frequency, regression version, or anything else that may help.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- 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
|
||||
- name: Extension Development Guide
|
||||
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
|
||||
description: Report issues with downloading specific tracks or albums
|
||||
description: Report a wrong, missing, failed, or low-quality download
|
||||
title: "[Download]: "
|
||||
labels: ["download-issue"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
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
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I have tried downloading with a different service (Tidal/Qobuz/Amazon)
|
||||
- label: I searched existing issues and did not find this problem.
|
||||
required: true
|
||||
- label: I am using the latest version of SpotiFLAC (Stable Version)
|
||||
- label: I updated the affected extension and retried once.
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: issue-type
|
||||
attributes:
|
||||
label: Issue Type
|
||||
description: What kind of download issue are you experiencing?
|
||||
options:
|
||||
- Track not found on service
|
||||
- Track or album not found
|
||||
- Wrong track downloaded
|
||||
- Download fails/errors
|
||||
- Metadata incorrect
|
||||
- Audio quality issue
|
||||
- Download fails or stalls
|
||||
- Metadata or cover is incorrect
|
||||
- Audio format or quality is incorrect
|
||||
- Duplicate or replacement behavior
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: spotify-url
|
||||
id: source-url
|
||||
attributes:
|
||||
label: Spotify URL
|
||||
description: The Spotify URL of the track/album you're trying to download
|
||||
placeholder: "https://open.spotify.com/track/..."
|
||||
validations:
|
||||
required: true
|
||||
label: Source URL
|
||||
description: URL pasted or opened in SpotiFLAC Mobile, if available.
|
||||
placeholder: "https://..."
|
||||
|
||||
- type: input
|
||||
id: track-info
|
||||
attributes:
|
||||
label: Track Info
|
||||
description: Artist name and track title
|
||||
placeholder: "Artist - Track Title"
|
||||
label: Track or Album
|
||||
placeholder: "Artist - Track or album 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:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: download-service
|
||||
id: storage
|
||||
attributes:
|
||||
label: Download Service
|
||||
description: Which service did you try to download from?
|
||||
label: Download Storage
|
||||
options:
|
||||
- Tidal
|
||||
- Qobuz
|
||||
- Amazon Music
|
||||
- All services
|
||||
- SAF folder
|
||||
- App-specific folder
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: search-service
|
||||
id: worker
|
||||
attributes:
|
||||
label: Search Service
|
||||
description: Which search service are you using?
|
||||
label: Download Worker
|
||||
options:
|
||||
- Spotify
|
||||
- Deezer
|
||||
- Native worker
|
||||
- Dart worker
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: Describe the issue in detail
|
||||
placeholder: |
|
||||
What happened? What did you expect?
|
||||
If wrong track was downloaded, what track was downloaded instead?
|
||||
label: What Happened?
|
||||
description: For a wrong match, identify both the expected and downloaded recording.
|
||||
placeholder: Describe the result and the expected result.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
id: environment
|
||||
attributes:
|
||||
label: App Version
|
||||
description: Which version of SpotiFLAC are you using?
|
||||
placeholder: "e.g., v2.2.0"
|
||||
label: App Version, Device, and OS
|
||||
placeholder: "4.8.1 (139), device model, Android/iOS version"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
id: logs
|
||||
attributes:
|
||||
label: Screenshots / Logs
|
||||
description: |
|
||||
If applicable, add screenshots or logs.
|
||||
|
||||
**To get logs:**
|
||||
1. Go to Settings > Options > Detailed Logging (turn ON)
|
||||
2. Try downloading the track again
|
||||
3. Go to Settings > Logs
|
||||
4. Tap Share button to export logs
|
||||
placeholder: Drag & drop screenshots or paste logs here...
|
||||
label: Logs and Screenshots
|
||||
description: Enable detailed logging, retry once, and export Settings > Logs. Remove credentials and personal data.
|
||||
placeholder: Paste logs or drag files here.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -6,7 +6,7 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
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.
|
||||
|
||||
- type: checkboxes
|
||||
@@ -15,7 +15,7 @@ body:
|
||||
label: Checklist
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I have read the [Extension Development Guide](https://github.com/zarzet/SpotiFLAC-Mobile/blob/main/docs/EXTENSION_DEVELOPMENT.md)
|
||||
- label: I have read the [Extension Development Guide](https://github.com/spotiflacapp/SpotiFLAC-Mobile/blob/main/docs/EXTENSION_DEVELOPMENT.md)
|
||||
required: true
|
||||
- label: I have searched existing issues and this API feature hasn't been requested yet
|
||||
required: true
|
||||
|
||||
@@ -50,7 +50,7 @@ body:
|
||||
options:
|
||||
- UI/UX Improvement
|
||||
- Download Feature
|
||||
- New Service Integration
|
||||
- Extension System/API
|
||||
- Metadata/Tagging
|
||||
- Performance
|
||||
- Settings/Configuration
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
## Summary
|
||||
|
||||
<!-- What changed, why is it needed, and which user-visible behavior is affected? -->
|
||||
|
||||
## Related Issues
|
||||
|
||||
<!-- Use "Fixes #123" when this PR should close an issue. -->
|
||||
|
||||
## Type of Change
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] Feature
|
||||
- [ ] Performance improvement
|
||||
- [ ] Refactor with no intended behavior change
|
||||
- [ ] Tests, build, or repository maintenance
|
||||
- [ ] Documentation or localization
|
||||
|
||||
## Validation
|
||||
|
||||
<!-- Check only commands relevant to this change. Explain skipped checks below. -->
|
||||
|
||||
- [ ] `dart format --output=none --set-exit-if-changed lib test`
|
||||
- [ ] `flutter analyze`
|
||||
- [ ] `flutter test`
|
||||
- [ ] `gofmt`, `go vet ./...`, and `go test ./...` in `go_backend/`
|
||||
- [ ] Android or iOS native checks
|
||||
- [ ] Manual reproduction or device test
|
||||
|
||||
Validation notes:
|
||||
|
||||
<!-- Devices, commands, test cases, or the reason a check was not applicable. -->
|
||||
|
||||
## Contributor Checklist
|
||||
|
||||
- [ ] This PR is focused and contains no unrelated changes.
|
||||
- [ ] Extension-specific behavior, if any, uses a generic capability or manifest contract.
|
||||
- [ ] User-facing strings, documentation, and generated files are updated where needed.
|
||||
- [ ] No credentials, downloaded media, or build artifacts are included.
|
||||
|
||||
## Screenshots or Recordings
|
||||
|
||||
<!-- Add before/after media for UI changes. Delete this section otherwise. -->
|
||||
@@ -0,0 +1,184 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Detect changed paths
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
dart: ${{ steps.filter.outputs.dart }}
|
||||
go: ${{ steps.filter.outputs.go }}
|
||||
android: ${{ steps.filter.outputs.android }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Filter paths
|
||||
uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
dart:
|
||||
- 'lib/**'
|
||||
- 'test/**'
|
||||
- 'assets/**'
|
||||
- 'pubspec.yaml'
|
||||
- 'pubspec.lock'
|
||||
- 'analysis_options.yaml'
|
||||
- 'l10n.yaml'
|
||||
- '.fvmrc'
|
||||
- '.github/workflows/ci.yml'
|
||||
go:
|
||||
- 'go_backend/**'
|
||||
- '.github/workflows/ci.yml'
|
||||
android:
|
||||
- 'android/**'
|
||||
- 'go_backend/**'
|
||||
- 'lib/models/settings.dart'
|
||||
- 'lib/providers/download_queue_provider*.dart'
|
||||
- 'lib/services/download_request_payload.dart'
|
||||
- 'lib/services/history_database.dart'
|
||||
- 'lib/services/platform_bridge.dart'
|
||||
- 'pubspec.yaml'
|
||||
- 'pubspec.lock'
|
||||
- '.fvmrc'
|
||||
- '.github/workflows/ci.yml'
|
||||
|
||||
flutter:
|
||||
name: Flutter analyze & test
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.dart == 'true'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version-file: .fvmrc
|
||||
cache: true
|
||||
|
||||
- name: Cache pub dependencies
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.pub-cache
|
||||
key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }}
|
||||
restore-keys: pub-${{ runner.os }}-
|
||||
|
||||
- name: Get Flutter dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Analyze
|
||||
run: flutter analyze
|
||||
|
||||
- name: Run tests
|
||||
run: flutter test
|
||||
|
||||
go:
|
||||
name: Go vet & test
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: go_backend
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go_backend/go.mod
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
- name: Check formatting
|
||||
run: test -z "$(gofmt -l .)"
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Run tests
|
||||
run: go test ./...
|
||||
|
||||
android:
|
||||
name: Android compile & native tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.android == 'true'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "25"
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go_backend/go.mod
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version-file: .fvmrc
|
||||
cache: true
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
gradle-version: "9.6.1"
|
||||
|
||||
- name: Install Android SDK & NDK
|
||||
run: |
|
||||
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \
|
||||
"ndk;29.0.14206865" \
|
||||
"platforms;android-37.0" \
|
||||
"build-tools;37.0.0"
|
||||
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build Go backend for Android
|
||||
working-directory: go_backend
|
||||
run: |
|
||||
go install golang.org/x/mobile/cmd/gomobile
|
||||
gomobile init
|
||||
mkdir -p ../android/app/libs
|
||||
gomobile bind \
|
||||
-target=android/arm,android/arm64 \
|
||||
-androidapi 24 \
|
||||
-o ../android/app/libs/gobackend.aar \
|
||||
.
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
|
||||
- name: Get Flutter dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Configure Flutter SDK for Gradle
|
||||
run: echo "flutter.sdk=$FLUTTER_ROOT" > android/local.properties
|
||||
|
||||
- name: Compile Kotlin and run native unit tests
|
||||
run: gradle -p android :app:compileDebugKotlin :app:testDebugUnitTest
|
||||
@@ -47,15 +47,14 @@ jobs:
|
||||
steps:
|
||||
- name: Free disk space
|
||||
run: |
|
||||
# Remove large unused tools (~15GB total)
|
||||
# Remove large unused tools (~15GB total). Docker prune was dropped:
|
||||
# it took 1-2 minutes and the rm below already frees enough.
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo rm -rf /opt/ghc
|
||||
sudo rm -rf /opt/hostedtoolcache/CodeQL
|
||||
sudo rm -rf /usr/local/share/boost
|
||||
sudo rm -rf /usr/share/swift
|
||||
sudo rm -rf /usr/local/.ghcup
|
||||
# Clean docker images
|
||||
sudo docker image prune --all --force
|
||||
# Show available space
|
||||
df -h
|
||||
|
||||
@@ -66,12 +65,12 @@ jobs:
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
java-version: "25"
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.25.8"
|
||||
go-version-file: go_backend/go.mod
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
# Cache Gradle for faster builds
|
||||
@@ -84,6 +83,12 @@ jobs:
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-${{ runner.os }}-
|
||||
|
||||
- name: Cache Android NDK
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: /usr/local/lib/android/sdk/ndk/29.0.14206865
|
||||
key: ndk-29.0.14206865
|
||||
|
||||
- name: Install Android SDK & NDK
|
||||
run: |
|
||||
# Use pre-installed Android SDK on GitHub runners
|
||||
@@ -94,22 +99,29 @@ jobs:
|
||||
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
||||
|
||||
# Install NDK r29 (supports 16KB page size for Android 15+)
|
||||
# Platform android-36 and build-tools 36.0.0 for targetSdk 36 (Android 16)
|
||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;29.0.14206865" "platforms;android-36" "build-tools;36.0.0"
|
||||
# Keep the installed platform aligned with compileSdk/targetSdk.
|
||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;29.0.14206865" "platforms;android-37.0" "build-tools;37.0.0"
|
||||
|
||||
# Set NDK path
|
||||
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> $GITHUB_ENV
|
||||
|
||||
- name: Install gomobile
|
||||
working-directory: go_backend
|
||||
run: |
|
||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
# Installed from inside the module so the go.mod-pinned x/mobile
|
||||
# version is used (reproducible + covered by the setup-go cache).
|
||||
go install golang.org/x/mobile/cmd/gomobile
|
||||
gomobile init
|
||||
|
||||
- name: Build Go backend for Android
|
||||
working-directory: go_backend
|
||||
run: |
|
||||
mkdir -p ../android/app/libs
|
||||
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
|
||||
# arm/arm64 only: ndk.abiFilters in app/build.gradle.kts strips
|
||||
# every other ABI from all outputs (universal APK included), so an
|
||||
# amd64 slice would never reach a shipped APK — it only bloats the
|
||||
# aar and slows this step.
|
||||
gomobile bind -target=android/arm,android/arm64 -androidapi 24 -o ../android/app/libs/gobackend.aar .
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
|
||||
@@ -117,8 +129,16 @@ jobs:
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version-file: .fvmrc
|
||||
cache: true
|
||||
|
||||
- name: Cache pub dependencies
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.pub-cache
|
||||
key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }}
|
||||
restore-keys: pub-${{ runner.os }}-
|
||||
|
||||
- name: Get Flutter dependencies
|
||||
run: flutter pub get
|
||||
|
||||
@@ -127,13 +147,11 @@ jobs:
|
||||
|
||||
- name: Build APK (Release - unsigned)
|
||||
run: |
|
||||
flutter build apk --release --split-per-abi || true
|
||||
# Verify APKs were created
|
||||
flutter build apk --release --split-per-abi \
|
||||
--target-platform android-arm,android-arm64
|
||||
ls -la build/app/outputs/flutter-apk/
|
||||
if [ ! -f "build/app/outputs/flutter-apk/app-arm64-v8a-release.apk" ]; then
|
||||
echo "ERROR: APK not found!"
|
||||
exit 1
|
||||
fi
|
||||
test -f build/app/outputs/flutter-apk/app-arm64-v8a-release.apk
|
||||
test -f build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk
|
||||
|
||||
- name: Sign APKs
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
@@ -149,12 +167,29 @@ jobs:
|
||||
|
||||
- name: Rename APKs
|
||||
run: |
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
VERSION="${{ needs.get-version.outputs.version }}"
|
||||
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
|
||||
mv app-armeabi-v7a-release-signed.apk SpotiFLAC-${VERSION}-arm32.apk || mv app-armeabi-v7a-release.apk SpotiFLAC-${VERSION}-arm32.apk || true
|
||||
mv app-release-signed.apk SpotiFLAC-${VERSION}-universal.apk || mv app-release.apk SpotiFLAC-${VERSION}-universal.apk || true
|
||||
|
||||
rename_required_apk() {
|
||||
unsigned="$1"
|
||||
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
|
||||
|
||||
- name: Upload APK artifact
|
||||
@@ -179,7 +214,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.25.8"
|
||||
go-version-file: go_backend/go.mod
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
# Cache CocoaPods
|
||||
@@ -191,8 +226,10 @@ jobs:
|
||||
restore-keys: pods-${{ runner.os }}-
|
||||
|
||||
- name: Install gomobile
|
||||
working-directory: go_backend
|
||||
run: |
|
||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
# go.mod-pinned x/mobile version (reproducible + cached by setup-go).
|
||||
go install golang.org/x/mobile/cmd/gomobile
|
||||
gomobile init
|
||||
|
||||
- name: Build Go backend for iOS
|
||||
@@ -210,8 +247,9 @@ jobs:
|
||||
|
||||
- name: Add XCFramework to Xcode project
|
||||
run: |
|
||||
# Install xcodeproj gem for modifying Xcode project
|
||||
sudo gem install xcodeproj
|
||||
# xcodeproj usually ships with the preinstalled CocoaPods; only
|
||||
# install it when missing.
|
||||
gem list -i xcodeproj >/dev/null || sudo gem install xcodeproj
|
||||
|
||||
# Create Ruby script to add framework
|
||||
cat > add_framework.rb << 'EOF'
|
||||
@@ -252,8 +290,16 @@ jobs:
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version-file: .fvmrc
|
||||
cache: true
|
||||
|
||||
- name: Cache pub dependencies
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.pub-cache
|
||||
key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }}
|
||||
restore-keys: pub-${{ runner.os }}-
|
||||
|
||||
- name: Get Flutter dependencies
|
||||
run: flutter pub get
|
||||
|
||||
@@ -388,8 +434,6 @@ jobs:
|
||||
### Installation
|
||||
**Android**: Enable "Install from unknown sources" and install the APK
|
||||
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
||||
|
||||
  
|
||||
FOOTER
|
||||
|
||||
echo "Release body:"
|
||||
@@ -399,7 +443,7 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.get-version.outputs.version }}
|
||||
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
||||
name: SpotiFLAC-Mobile ${{ needs.get-version.outputs.version }}
|
||||
body_path: /tmp/release_body.txt
|
||||
files: ./release/*
|
||||
draft: false
|
||||
@@ -447,7 +491,7 @@ jobs:
|
||||
|
||||
jq --arg ver "$VERSION_NUM" \
|
||||
--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" \
|
||||
'.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
|
||||
@@ -565,7 +609,7 @@ jobs:
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||
-F document=@"${ARM64_APK}" \
|
||||
-F caption="SpotiFLAC ${VERSION} - arm64 (recommended)"
|
||||
-F caption="SpotiFLAC Mobile ${VERSION} - arm64 (recommended)"
|
||||
fi
|
||||
|
||||
# Upload arm32 APK to channel
|
||||
@@ -574,7 +618,7 @@ jobs:
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||
-F document=@"${ARM32_APK}" \
|
||||
-F caption="SpotiFLAC ${VERSION} - arm32"
|
||||
-F caption="SpotiFLAC Mobile ${VERSION} - arm32"
|
||||
fi
|
||||
|
||||
# Upload iOS IPA to channel
|
||||
@@ -584,7 +628,7 @@ jobs:
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||
-F document=@"${IOS_IPA}" \
|
||||
-F caption="SpotiFLAC ${VERSION} - iOS (unsigned, sideload required)"
|
||||
-F caption="SpotiFLAC Mobile ${VERSION} - iOS (unsigned, sideload required)"
|
||||
fi
|
||||
|
||||
echo "Telegram notification sent!"
|
||||
|
||||
+70
-59
@@ -1,87 +1,98 @@
|
||||
# OS files
|
||||
# Operating-system files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# IDE
|
||||
# Editors and local assistants
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
.cursorignore
|
||||
.cursorrules
|
||||
|
||||
# Kiro specs (development only)
|
||||
.kiro/
|
||||
.playwright-mcp/
|
||||
.rtk/
|
||||
.claude/settings.local.json
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
|
||||
# Design assets (banners, mockups)
|
||||
design/
|
||||
|
||||
# Reference folder (development only)
|
||||
referensi/
|
||||
|
||||
# Documentation (development only, published separately)
|
||||
docs/
|
||||
|
||||
# Old spotiflac_android folder (moved to root)
|
||||
spotiflac_android/
|
||||
|
||||
# Flutter/Dart
|
||||
# Flutter and Dart
|
||||
.dart_tool/
|
||||
.fvm/
|
||||
.packages
|
||||
build/
|
||||
*.lock
|
||||
!pubspec.lock
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.metadata
|
||||
/build/
|
||||
/coverage/
|
||||
*.apk
|
||||
*.aab
|
||||
*.ipa
|
||||
|
||||
# Go backend build artifacts
|
||||
go_backend/*.aar
|
||||
go_backend/*.jar
|
||||
go_backend/*.exe
|
||||
go_backend/*.xcframework/
|
||||
# Local JavaScript tooling (the app itself has no Node dependency)
|
||||
/node_modules/
|
||||
/bun.lock
|
||||
/package-lock.json
|
||||
|
||||
# Android
|
||||
android/.gradle/
|
||||
android/app/libs/gobackend.aar
|
||||
android/app/libs/gobackend-sources.jar
|
||||
android/local.properties
|
||||
android/*.iml
|
||||
android/key.properties
|
||||
android/*.jks
|
||||
android/*.keystore
|
||||
android/app/*.jks
|
||||
# Go backend build outputs
|
||||
/go_backend/*.aar
|
||||
/go_backend/*.jar
|
||||
/go_backend/*.exe
|
||||
/go_backend/*.xcframework/
|
||||
|
||||
# iOS
|
||||
ios/Frameworks/
|
||||
ios/Pods/
|
||||
ios/.symlinks/
|
||||
ios/Flutter/Flutter.framework/
|
||||
ios/Flutter/Flutter.podspec
|
||||
# Android build state and signing material
|
||||
/android/.gradle/
|
||||
/android/.kotlin/
|
||||
/android/app/build/
|
||||
/android/app/libs/*.aar
|
||||
/android/app/libs/*-sources.jar
|
||||
/android/local.properties
|
||||
/android/*.iml
|
||||
/android/key.properties
|
||||
/android/*.jks
|
||||
/android/*.keystore
|
||||
/android/app/*.jks
|
||||
|
||||
# Extension folder
|
||||
extension/
|
||||
# iOS generated state
|
||||
/ios/Frameworks/
|
||||
/ios/Pods/
|
||||
/ios/.symlinks/
|
||||
/ios/Flutter/ephemeral/
|
||||
/ios/Flutter/Flutter.framework/
|
||||
/ios/Flutter/Flutter.podspec
|
||||
*.xcarchive
|
||||
DerivedData/
|
||||
|
||||
# Agent instructions
|
||||
AGENTS.md
|
||||
# Credentials and machine-local configuration
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.mobileprovision
|
||||
*.p12
|
||||
*.pem
|
||||
|
||||
# Temp/misc
|
||||
.tmp/
|
||||
# Local research and design material
|
||||
/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
|
||||
network_requests.txt
|
||||
*.bak
|
||||
/network_requests.txt
|
||||
/AndroidManifest.xml
|
||||
|
||||
# Log files
|
||||
*.bak
|
||||
*.orig
|
||||
*.swp
|
||||
*.tmp
|
||||
*.log
|
||||
hs_err_*.log
|
||||
flutter_*.log
|
||||
|
||||
# Development tools
|
||||
tool/
|
||||
.claude/settings.local.json
|
||||
.playwright-mcp/
|
||||
|
||||
# FVM Version Cache
|
||||
.fvm/
|
||||
|
||||
+521
-13
@@ -1,5 +1,513 @@
|
||||
# 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
|
||||
|
||||
### Changed
|
||||
@@ -745,8 +1253,8 @@ Thank you for your understanding and continued support. This decision was made t
|
||||
|
||||
### 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)
|
||||
- **Duplicate Detection** ([#117](https://github.com/zarzet/SpotiFLAC-Mobile/issues/117)): "In Library" badge on tracks matching by ISRC or track name + artist
|
||||
- **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/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
|
||||
|
||||
### Added
|
||||
@@ -814,7 +1322,7 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
||||
|
||||
### 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)
|
||||
- **Lossy Bitrate Options**: MP3 (320/256/192/128kbps), Opus (128/96/64kbps)
|
||||
- **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
|
||||
|
||||
- **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
|
||||
- **Deezer Pagination**: Fixed >25 tracks only showing first 25 ([#112](https://github.com/zarzet/SpotiFLAC-Mobile/issues/112))
|
||||
- **Duplicate Embed Lyrics Setting**: Removed from Options page ([#110](https://github.com/zarzet/SpotiFLAC-Mobile/issues/110))
|
||||
- **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/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))
|
||||
- Portuguese: Pedro Marcondes ([@justapedro](https://crowdin.com/profile/justapedro))
|
||||
- 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
|
||||
- Shows default provider (Deezer based on metadata source setting) at the top
|
||||
- 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
|
||||
- Re-triggers search automatically if there's existing text in the search bar
|
||||
- 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
|
||||
- Triggers JavaScript function when tapped (e.g., start OAuth flow)
|
||||
- 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
|
||||
- Embeds GENRE, ORGANIZATION (label), and COPYRIGHT tags into FLAC files
|
||||
- Works automatically when Deezer track ID is available (via ISRC matching)
|
||||
- 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
|
||||
- When enabled, MP3 (320kbps) appears as a quality option alongside FLAC options
|
||||
- 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
|
||||
- Extracted from first track's artist metadata
|
||||
- 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.
|
||||
- Tracks sorted by disc number first, then by track number
|
||||
- Single-disc albums display normally without separators
|
||||
- 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
|
||||
- Groups tracks by album name and artist
|
||||
- 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)
|
||||
|
||||
+119
-234
@@ -1,282 +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)
|
||||
- [How Can I Contribute?](#how-can-i-contribute)
|
||||
- [Reporting Bugs](#reporting-bugs)
|
||||
- [Suggesting Features](#suggesting-features)
|
||||
- [Code Contributions](#code-contributions)
|
||||
- [Translations](#translations)
|
||||
- [Development Setup](#development-setup)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Coding Guidelines](#coding-guidelines)
|
||||
- [Commit Guidelines](#commit-guidelines)
|
||||
- [Pull Request Process](#pull-request-process)
|
||||
- Search the [existing issues](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues)
|
||||
before opening a new one.
|
||||
- Use the issue template that best matches the problem.
|
||||
- Keep pull requests focused. Separate unrelated fixes into separate PRs.
|
||||
- Never commit credentials, signing files, downloaded media, or generated build
|
||||
artifacts.
|
||||
|
||||
## 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.
|
||||
|
||||
When creating a bug report, please use the bug report template and include:
|
||||
|
||||
- **Clear and descriptive title**
|
||||
- **Steps to reproduce** the issue
|
||||
- **Expected behavior** vs **actual behavior**
|
||||
- **Screenshots or screen recordings** if applicable
|
||||
- **Device information** (model, OS version)
|
||||
- **App version**
|
||||
- **Logs** from Settings > About > View Logs
|
||||
|
||||
### Suggesting Features
|
||||
|
||||
Feature requests are welcome! Please use the feature request template and:
|
||||
|
||||
- **Check existing issues** to avoid duplicates
|
||||
- **Describe the feature** clearly
|
||||
- **Explain the use case** - why would this be useful?
|
||||
- **Consider the scope** - is this a small enhancement or a major feature?
|
||||
|
||||
### Code Contributions
|
||||
|
||||
1. **Fork the repository** and create your branch from `dev`
|
||||
2. **Make your changes** following our coding guidelines
|
||||
3. **Test your changes** thoroughly
|
||||
4. **Submit a pull request** to the `dev` branch
|
||||
|
||||
### Translations
|
||||
|
||||
We use [Crowdin](https://crowdin.com/project/spotiflac-mobile) for translations. To contribute:
|
||||
|
||||
1. Visit our [Crowdin project](https://crowdin.com/project/spotiflac-mobile)
|
||||
2. Select your language or request a new one
|
||||
3. Start translating!
|
||||
|
||||
Translation files are located in `lib/l10n/arb/`.
|
||||
[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
|
||||
`flutter` (and `fvm dart` with `dart`) in the commands below.
|
||||
|
||||
## 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
|
||||
git clone https://github.com/YOUR_USERNAME/SpotiFLAC-Mobile.git
|
||||
cd SpotiFLAC-Mobile
|
||||
git remote add upstream https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
|
||||
```
|
||||
|
||||
2. **Add upstream remote**
|
||||
2. Install the pinned Flutter SDK and Dart dependencies:
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
|
||||
fvm install
|
||||
fvm flutter pub get
|
||||
```
|
||||
|
||||
3. **Use FVM (Flutter Version: 3.41.5)**
|
||||
```bash
|
||||
fvm use
|
||||
```
|
||||
3. Build the Go backend for Android. `ANDROID_NDK_HOME` must point to the NDK
|
||||
version used by CI and `CGO_ENABLED` must be enabled.
|
||||
|
||||
4. **Install dependencies**
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
5. **Generate code** (for Riverpod, JSON serialization, etc.)
|
||||
```bash
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
6. **Set up Go environment (Go Version: 1.25.7)**
|
||||
```bash
|
||||
cd go_backend
|
||||
mkdir -p ../android/app/libs
|
||||
cd go_backend
|
||||
go mod download
|
||||
go install golang.org/x/mobile/cmd/gomobile
|
||||
gomobile init
|
||||
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
|
||||
mkdir -p ../android/app/libs
|
||||
gomobile bind \
|
||||
-target=android/arm,android/arm64 \
|
||||
-androidapi 24 \
|
||||
-o ../android/app/libs/gobackend.aar \
|
||||
.
|
||||
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
|
||||
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
|
||||
# Debug build
|
||||
flutter build apk --debug
|
||||
|
||||
# Release build
|
||||
flutter build apk --release
|
||||
fvm dart format --output=none --set-exit-if-changed lib test
|
||||
fvm flutter analyze
|
||||
fvm flutter test
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
lib/
|
||||
├── l10n/ # Localization files
|
||||
│ └── arb/ # ARB translation files
|
||||
├── models/ # Data models
|
||||
├── providers/ # Riverpod providers
|
||||
├── screens/ # UI screens
|
||||
│ └── settings/ # Settings sub-screens
|
||||
├── services/ # Business logic services
|
||||
├── theme/ # App theming
|
||||
├── utils/ # Utility functions
|
||||
├── widgets/ # Reusable widgets
|
||||
├── app.dart # App configuration
|
||||
└── main.dart # Entry point
|
||||
```
|
||||
|
||||
## Coding Guidelines
|
||||
|
||||
### General
|
||||
|
||||
- Follow [Effective Dart](https://dart.dev/effective-dart) guidelines
|
||||
- Use meaningful variable and function names
|
||||
- Keep functions small and focused
|
||||
- Add comments for complex logic
|
||||
|
||||
### Formatting
|
||||
|
||||
- Use `dart format` before committing
|
||||
- Maximum line length: 80 characters
|
||||
- Use trailing commas for better formatting
|
||||
Go backend:
|
||||
|
||||
```bash
|
||||
dart format .
|
||||
cd go_backend
|
||||
gofmt -w .
|
||||
go vet ./...
|
||||
go test ./...
|
||||
```
|
||||
|
||||
### Linting
|
||||
|
||||
Ensure your code passes all lints:
|
||||
Android native code, after building `gobackend.aar`:
|
||||
|
||||
```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. Follow these patterns:
|
||||
## Code and Commit Style
|
||||
|
||||
```dart
|
||||
// Use code generation with riverpod_annotation
|
||||
@riverpod
|
||||
class MyNotifier extends _$MyNotifier {
|
||||
@override
|
||||
MyState build() => MyState();
|
||||
|
||||
// Methods to update state
|
||||
}
|
||||
```
|
||||
- Follow `analysis_options.yaml`, `.editorconfig`, and existing module patterns.
|
||||
- Keep user-facing strings in the localization files.
|
||||
- Prefer small functions and explicit error handling at platform boundaries.
|
||||
- Use [Conventional Commits](https://www.conventionalcommits.org/), for example:
|
||||
|
||||
### Localization
|
||||
```text
|
||||
feat(download): add batch selection
|
||||
fix(storage): handle revoked folder access
|
||||
docs(contributing): refresh Android setup
|
||||
```
|
||||
|
||||
All user-facing strings should be localized:
|
||||
## Pull Requests
|
||||
|
||||
```dart
|
||||
// Good
|
||||
Text(AppLocalizations.of(context)!.downloadComplete)
|
||||
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.
|
||||
|
||||
// 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/dev
|
||||
```
|
||||
|
||||
2. **Create a feature branch**
|
||||
```bash
|
||||
git checkout -b feat/my-new-feature
|
||||
```
|
||||
|
||||
3. **Make your changes** and commit following our guidelines
|
||||
|
||||
4. **Push to your fork**
|
||||
```bash
|
||||
git push origin feat/my-new-feature
|
||||
```
|
||||
|
||||
5. **Create a Pull Request**
|
||||
- Target the `dev` branch
|
||||
- Fill in the PR template
|
||||
- Link related issues
|
||||
|
||||
6. **Address review feedback**
|
||||
- Make requested changes
|
||||
- Push additional commits
|
||||
- Request re-review when ready
|
||||
|
||||
### PR Requirements
|
||||
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] All tests pass
|
||||
- [ ] No new linting errors
|
||||
- [ ] Documentation updated (if needed)
|
||||
- [ ] Commit messages follow guidelines
|
||||
- [ ] PR description is clear and complete
|
||||
|
||||
## Questions?
|
||||
|
||||
If you have questions, feel free to:
|
||||
|
||||
- Open a [Discussion](https://github.com/zarzet/SpotiFLAC-Mobile/discussions)
|
||||
- Check existing [Issues](https://github.com/zarzet/SpotiFLAC-Mobile/issues)
|
||||
|
||||
Thank you for contributing! 💚
|
||||
When reporting a crash, include the SpotiFLAC Mobile version, release channel,
|
||||
device/OS, exact reproduction steps, storage mode, and exported app logs. For a
|
||||
cold-start Android crash, `adb logcat -b crash -d` is especially useful.
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/31d1bf3c3b2015c13e83c4f909a7c6093a9423e3e702f0c582a3e0035c849424)
|
||||
[](https://github.com/spotiflacapp/SpotiFLAC-Mobile/releases)
|
||||
[](https://crowdin.com/project/spotiflac-mobile)
|
||||
|
||||
[](https://t.me/spotiflac)
|
||||
@@ -52,7 +51,22 @@ Extensions let the community add new music sources and features without waiting
|
||||
### Developing Extensions
|
||||
|
||||
> [!NOTE]
|
||||
> Want to build your own extension? The [Extension Development Guide](https://zarzet.github.io/SpotiFLAC-Mobile/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>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
|
||||
@@ -125,13 +145,13 @@ Some countries have restricted access to certain streaming service APIs. If down
|
||||
</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>
|
||||
|
||||
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.
|
||||
@@ -139,7 +159,7 @@ In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link
|
||||
</details>
|
||||
|
||||
> [!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)
|
||||
|
||||
@@ -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!
|
||||
|
||||
<a href="https://github.com/zarzet/SpotiFLAC-Mobile/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=zarzet/SpotiFLAC-Mobile" />
|
||||
<a href="https://github.com/spotiflacapp/SpotiFLAC-Mobile/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=spotiflacapp/SpotiFLAC-Mobile" />
|
||||
</a>
|
||||
|
||||
We also appreciate everyone who helped with [translations on Crowdin](https://crowdin.com/project/spotiflac-mobile), reported bugs, suggested features, and spread the word.
|
||||
|
||||
@@ -43,6 +43,9 @@ linter:
|
||||
type_annotate_public_apis: true
|
||||
cancel_subscriptions: 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
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
|
||||
@@ -17,7 +17,7 @@ if (keystorePropertiesFile.exists()) {
|
||||
|
||||
android {
|
||||
namespace = "com.zarz.spotiflac"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
compileSdk = 37
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
buildFeatures {
|
||||
@@ -26,13 +26,13 @@ android {
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
sourceCompatibility = JavaVersion.VERSION_25
|
||||
targetCompatibility = JavaVersion.VERSION_25
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ android {
|
||||
defaultConfig {
|
||||
applicationId = "com.zarz.spotiflac"
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = 36
|
||||
targetSdk = 37
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
multiDexEnabled = true
|
||||
@@ -62,6 +62,8 @@ android {
|
||||
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-debug"
|
||||
ndk {
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
@@ -121,8 +123,13 @@ dependencies {
|
||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0-beta02")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0")
|
||||
implementation("androidx.documentfile:documentfile:1.1.0")
|
||||
implementation("androidx.activity:activity-ktx:1.13.0")
|
||||
implementation("com.antonkarpenko:ffmpeg-kit-full:2.1.0")
|
||||
|
||||
// NativeDownloadFinalizer imports FFmpegKit APIs directly. The Flutter
|
||||
// plugin owns the runtime AAR; compileOnly avoids packaging it twice here.
|
||||
compileOnly("com.antonkarpenko:ffmpeg-kit-full:2.2.1")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
<!-- Permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="29" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
@@ -21,6 +22,9 @@
|
||||
android:label="SpotiFLAC Mobile"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:allowBackup="false"
|
||||
android:fullBackupContent="@xml/backup_rules_legacy"
|
||||
android:dataExtractionRules="@xml/backup_rules"
|
||||
android:usesCleartextTraffic="false"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
@@ -100,6 +104,12 @@
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="spotiflac" android:host="spotify-callback" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="spotiflac" android:host="session-grant" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Download Service -->
|
||||
@@ -108,6 +118,34 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<receiver
|
||||
android:name=".DownloadQueueWidgetProvider"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_download_queue_info" />
|
||||
</receiver>
|
||||
|
||||
<service
|
||||
android:name="com.ryanheise.audioservice.AudioService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="true"
|
||||
android:enabled="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<receiver
|
||||
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- flutter_local_notifications receivers -->
|
||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
|
||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
|
||||
@@ -124,6 +162,10 @@
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc" />
|
||||
|
||||
<!-- FileProvider for APK installation -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
internal object NativeWorkerPolicy {
|
||||
const val MAX_RATE_LIMIT_RETRIES = 1
|
||||
private const val DEFAULT_RATE_LIMIT_DELAY_SECONDS = 30
|
||||
private const val MIN_RATE_LIMIT_DELAY_SECONDS = 5
|
||||
private const val MAX_RATE_LIMIT_DELAY_SECONDS = 300
|
||||
|
||||
private val retryAfterPattern = Regex(
|
||||
"""retry[- ]?after(?: seconds)?[:= ]+(\d+)""",
|
||||
RegexOption.IGNORE_CASE,
|
||||
)
|
||||
|
||||
fun shouldRetryRateLimit(
|
||||
errorType: String?,
|
||||
errorMessage: String?,
|
||||
attempts: Int,
|
||||
): Boolean {
|
||||
if (attempts >= MAX_RATE_LIMIT_RETRIES) return false
|
||||
if (errorType.equals("rate_limit", ignoreCase = true)) return true
|
||||
|
||||
val message = errorMessage.orEmpty()
|
||||
return message.contains("429") ||
|
||||
message.contains("rate limit", ignoreCase = true) ||
|
||||
message.contains("too many requests", ignoreCase = true)
|
||||
}
|
||||
|
||||
fun rateLimitDelaySeconds(
|
||||
retryAfterSeconds: Int?,
|
||||
errorMessage: String?,
|
||||
): Int {
|
||||
val parsedFromMessage = retryAfterPattern
|
||||
.find(errorMessage.orEmpty())
|
||||
?.groupValues
|
||||
?.getOrNull(1)
|
||||
?.toIntOrNull()
|
||||
return (retryAfterSeconds?.takeIf { it > 0 }
|
||||
?: parsedFromMessage
|
||||
?: DEFAULT_RATE_LIMIT_DELAY_SECONDS)
|
||||
.coerceIn(
|
||||
MIN_RATE_LIMIT_DELAY_SECONDS,
|
||||
MAX_RATE_LIMIT_DELAY_SECONDS,
|
||||
)
|
||||
}
|
||||
|
||||
fun isVerificationRequired(
|
||||
errorType: String?,
|
||||
errorMessage: String?,
|
||||
): Boolean {
|
||||
if (errorType.equals("verification_required", ignoreCase = true)) {
|
||||
return true
|
||||
}
|
||||
val message = errorMessage.orEmpty()
|
||||
return message.contains("verification required", ignoreCase = true) ||
|
||||
message.contains("challenge required", ignoreCase = true)
|
||||
}
|
||||
|
||||
fun requiresWifi(downloadNetworkMode: String?): Boolean =
|
||||
downloadNetworkMode.equals("wifi_only", ignoreCase = true)
|
||||
|
||||
fun shouldPauseForNetwork(
|
||||
downloadNetworkMode: String?,
|
||||
hasWifi: Boolean,
|
||||
): Boolean = requiresWifi(downloadNetworkMode) && !hasWifi
|
||||
|
||||
fun shouldNotifyQueueComplete(
|
||||
cancelRequested: Boolean,
|
||||
completed: Int,
|
||||
failed: Int,
|
||||
): Boolean = !cancelRequested && completed + failed > 0
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
@@ -17,6 +19,44 @@ object SafDownloadHandler {
|
||||
private const val MAX_SAF_DISPLAY_NAME_UTF8_BYTES = 180
|
||||
private const val STAGED_SAF_MIME_TYPE = "application/octet-stream"
|
||||
|
||||
// Serializes the exists-check/create/write/publish sequence per target
|
||||
// file so concurrent downloads that sanitize to the same display name
|
||||
// cannot interleave writes into one document. Different names keep
|
||||
// downloading in parallel; the second same-name caller blocks, then hits
|
||||
// the exists check and reports already_exists.
|
||||
private val safNameLocks = java.util.concurrent.ConcurrentHashMap<String, Any>()
|
||||
|
||||
data class UniqueWriteResult(val uri: String, val fileName: String)
|
||||
data class ExistingAwareWriteResult(
|
||||
val uri: String,
|
||||
val fileName: String,
|
||||
val alreadyExists: Boolean,
|
||||
)
|
||||
|
||||
private fun <T> withSafNameLock(
|
||||
treeUriStr: String,
|
||||
relativeDir: String,
|
||||
fileName: String,
|
||||
block: () -> T
|
||||
): T {
|
||||
val key = "$treeUriStr|$relativeDir|${fileName.lowercase(Locale.ROOT)}"
|
||||
val lock = safNameLocks.computeIfAbsent(key) { Any() }
|
||||
return synchronized(lock) { block() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes and fsyncs a SAF output stream so the copied bytes are durable
|
||||
* before the staged document is renamed to its final name; without this a
|
||||
* power loss right after the rename can leave a truncated "complete" file.
|
||||
*/
|
||||
fun syncOutputStream(output: OutputStream) {
|
||||
try {
|
||||
output.flush()
|
||||
(output as? FileOutputStream)?.fd?.sync()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
fun handle(context: Context, requestJson: String, downloader: (String) -> String): String {
|
||||
val req = JSONObject(requestJson)
|
||||
val storageMode = req.optString("storage_mode", "")
|
||||
@@ -25,13 +65,34 @@ object SafDownloadHandler {
|
||||
return downloader(requestJson)
|
||||
}
|
||||
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val relativeDir = sanitizeRelativeDir(req.optString("saf_relative_dir", ""))
|
||||
val outputExt = normalizeExt(req.optString("saf_output_ext", ""))
|
||||
val mimeType = mimeTypeForExt(outputExt)
|
||||
val fileName = buildSafFileName(req, outputExt)
|
||||
return withSafNameLock(treeUriStr, relativeDir, fileName) {
|
||||
handleSafLocked(context, req, downloader, treeUriStr, relativeDir, outputExt, fileName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSafLocked(
|
||||
context: Context,
|
||||
req: JSONObject,
|
||||
downloader: (String) -> String,
|
||||
treeUriStr: String,
|
||||
relativeDir: String,
|
||||
outputExt: String,
|
||||
fileName: String
|
||||
): String {
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val mimeType = mimeTypeForExt(outputExt)
|
||||
val deferSafPublish = req.optBoolean("defer_saf_publish", false)
|
||||
val useStagedOutput = req.optBoolean("stage_saf_output", false) && !deferSafPublish
|
||||
// Downloads are always written under a staged ".partial" name so a
|
||||
// killed process can never leave a half-written file under the final
|
||||
// name (which the exists check would then accept as complete forever).
|
||||
// Callers that set stage_saf_output own the promotion to the final
|
||||
// name (the native finalizer); for everyone else — the foreground
|
||||
// Dart queue — this handler promotes right after a successful write.
|
||||
val finalizerPromotesStaged = req.optBoolean("stage_saf_output", false) && !deferSafPublish
|
||||
val useStagedOutput = !deferSafPublish
|
||||
val stagedFileName = if (useStagedOutput) buildStagedSafFileName(fileName) else fileName
|
||||
val stagedMimeType = if (useStagedOutput) STAGED_SAF_MIME_TYPE else mimeType
|
||||
|
||||
@@ -39,9 +100,7 @@ object SafDownloadHandler {
|
||||
if (existingDir != null) {
|
||||
val existing = existingDir.findFile(fileName)
|
||||
if (existing != null && existing.isFile && existing.length() > 0) {
|
||||
if (useStagedOutput || deferSafPublish) {
|
||||
deleteStaleStagedFiles(existingDir, fileName, outputExt)
|
||||
}
|
||||
deleteStaleStagedFiles(existingDir, fileName, outputExt)
|
||||
val obj = JSONObject()
|
||||
obj.put("success", true)
|
||||
obj.put("message", "File already exists")
|
||||
@@ -90,6 +149,11 @@ object SafDownloadHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove any stale partial from a previous killed attempt before
|
||||
// creating the staged document: reusing it would let a shorter new
|
||||
// write leave the old tail bytes in place (fd truncation is
|
||||
// best-effort on some providers).
|
||||
deleteStaleStagedFiles(targetDir, fileName, outputExt)
|
||||
var document = createOrReuseDocumentFile(targetDir, stagedMimeType, stagedFileName)
|
||||
?: return errorJson("Failed to create SAF file")
|
||||
|
||||
@@ -105,6 +169,7 @@ object SafDownloadHandler {
|
||||
val response = downloader(req.toString())
|
||||
val respObj = JSONObject(response)
|
||||
if (respObj.optBoolean("success", false)) {
|
||||
var finalFileName = fileName
|
||||
val goFilePath = respObj.optString("file_path", "")
|
||||
if (goFilePath.isNotEmpty() &&
|
||||
!goFilePath.startsWith("content://") &&
|
||||
@@ -138,11 +203,13 @@ object SafDownloadHandler {
|
||||
document.delete()
|
||||
document = replacement
|
||||
}
|
||||
finalFileName = actualFileName
|
||||
}
|
||||
context.contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
|
||||
srcFile.inputStream().use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
syncOutputStream(output)
|
||||
} ?: throw IllegalStateException("failed to open SAF output stream")
|
||||
srcFile.delete()
|
||||
} catch (e: Exception) {
|
||||
@@ -156,9 +223,19 @@ object SafDownloadHandler {
|
||||
}
|
||||
respObj.put("file_path", document.uri.toString())
|
||||
respObj.put("file_name", document.name ?: fileName)
|
||||
if (useStagedOutput) {
|
||||
if (finalizerPromotesStaged) {
|
||||
respObj.put("saf_staged_output", true)
|
||||
respObj.put("saf_staged_file_name", document.name ?: stagedFileName)
|
||||
} else if (useStagedOutput) {
|
||||
// Legacy caller (foreground Dart queue): publish here by
|
||||
// renaming the staged file to its final name.
|
||||
val published = replaceFinalDocument(targetDir, document, finalFileName)
|
||||
if (published == null) {
|
||||
document.delete()
|
||||
return errorJson("Failed to publish SAF download")
|
||||
}
|
||||
respObj.put("file_path", published.uri.toString())
|
||||
respObj.put("file_name", published.name ?: finalFileName)
|
||||
}
|
||||
} else {
|
||||
document.delete()
|
||||
@@ -177,6 +254,43 @@ object SafDownloadHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps [document] into place under [finalName] without a window where the
|
||||
* previous file is gone but the new one is not yet in place. Any existing
|
||||
* file is renamed aside first and only deleted once the staged file holds
|
||||
* the final name; a failed swap restores it. Returns the published
|
||||
* document, or null when the swap failed (the caller owns [document]).
|
||||
*/
|
||||
private fun replaceFinalDocument(
|
||||
targetDir: DocumentFile,
|
||||
document: DocumentFile,
|
||||
finalName: String
|
||||
): DocumentFile? {
|
||||
val existingFinal = targetDir.findFile(finalName)
|
||||
var aside: DocumentFile? = null
|
||||
if (existingFinal != null && existingFinal.uri != document.uri) {
|
||||
val asideName = buildReplacedSafFileName(finalName)
|
||||
try {
|
||||
targetDir.findFile(asideName)?.delete()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
if (!existingFinal.renameTo(asideName)) {
|
||||
return null
|
||||
}
|
||||
aside = targetDir.findFile(asideName) ?: existingFinal
|
||||
}
|
||||
if (!document.renameTo(finalName)) {
|
||||
aside?.renameTo(finalName)
|
||||
return null
|
||||
}
|
||||
aside?.delete()
|
||||
return targetDir.findFile(finalName) ?: document
|
||||
}
|
||||
|
||||
private fun buildReplacedSafFileName(fileName: String): String {
|
||||
return "${sanitizeFilename(fileName)}.replaced"
|
||||
}
|
||||
|
||||
fun copyContentUriToTemp(context: Context, uriStr: String): String? {
|
||||
return try {
|
||||
val uri = Uri.parse(uriStr)
|
||||
@@ -206,12 +320,197 @@ object SafDownloadHandler {
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
srcPath: String
|
||||
): String? {
|
||||
val finalName = sanitizeFilename(fileName)
|
||||
return withSafNameLock(treeUriStr, sanitizeRelativeDir(relativeDir), finalName) {
|
||||
writeFileToSafLocked(context, treeUriStr, relativeDir, finalName, srcPath)
|
||||
}
|
||||
}
|
||||
|
||||
fun writeFileToSafUnique(
|
||||
context: Context,
|
||||
treeUriStr: String,
|
||||
relativeDir: String,
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
srcPath: String,
|
||||
preservedSuffix: String = "",
|
||||
): UniqueWriteResult? {
|
||||
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||
val preferredName = sanitizeFilenamePreservingSuffix(fileName, preservedSuffix)
|
||||
return withSafNameLock(treeUriStr, safeRelativeDir, preferredName) {
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val targetDir = ensureDocumentDir(context, treeUri, safeRelativeDir) ?: return@withSafNameLock null
|
||||
val availableName = findAvailableFileName(
|
||||
targetDir,
|
||||
preferredName,
|
||||
preservedSuffix,
|
||||
)
|
||||
val uri = writeFileToSafLocked(
|
||||
context,
|
||||
treeUriStr,
|
||||
safeRelativeDir,
|
||||
availableName,
|
||||
srcPath,
|
||||
) ?: return@withSafNameLock null
|
||||
UniqueWriteResult(uri = uri, fileName = availableName)
|
||||
}
|
||||
}
|
||||
|
||||
fun writeFileToSafCollisionAware(
|
||||
context: Context,
|
||||
treeUriStr: String,
|
||||
relativeDir: String,
|
||||
cleanFileName: String,
|
||||
variantFileName: String,
|
||||
mimeType: String,
|
||||
srcPath: String,
|
||||
preservedSuffix: String = "",
|
||||
): UniqueWriteResult? {
|
||||
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||
val cleanName = sanitizeFilename(cleanFileName)
|
||||
val preferredVariant = sanitizeFilenamePreservingSuffix(
|
||||
variantFileName,
|
||||
preservedSuffix,
|
||||
)
|
||||
return withSafNameLock(treeUriStr, safeRelativeDir, cleanName) {
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val targetDir = ensureDocumentDir(context, treeUri, safeRelativeDir)
|
||||
?: return@withSafNameLock null
|
||||
val selectedName = if (targetDir.findFile(cleanName) == null) {
|
||||
cleanName
|
||||
} else {
|
||||
findAvailableFileName(targetDir, preferredVariant, preservedSuffix)
|
||||
}
|
||||
val uri = writeFileToSafLocked(
|
||||
context,
|
||||
treeUriStr,
|
||||
safeRelativeDir,
|
||||
selectedName,
|
||||
srcPath,
|
||||
) ?: return@withSafNameLock null
|
||||
UniqueWriteResult(uri = uri, fileName = selectedName)
|
||||
}
|
||||
}
|
||||
|
||||
fun writeFileToSafIfAbsent(
|
||||
context: Context,
|
||||
treeUriStr: String,
|
||||
relativeDir: String,
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
srcPath: String,
|
||||
): ExistingAwareWriteResult? {
|
||||
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||
val finalName = sanitizeFilename(fileName)
|
||||
return withSafNameLock(treeUriStr, safeRelativeDir, finalName) {
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val targetDir = ensureDocumentDir(context, treeUri, safeRelativeDir)
|
||||
?: return@withSafNameLock null
|
||||
val existing = targetDir.findFile(finalName)
|
||||
if (existing != null && existing.isFile && existing.length() > 0L) {
|
||||
return@withSafNameLock ExistingAwareWriteResult(
|
||||
uri = existing.uri.toString(),
|
||||
fileName = existing.name ?: finalName,
|
||||
alreadyExists = true,
|
||||
)
|
||||
}
|
||||
val uri = writeFileToSafLocked(
|
||||
context,
|
||||
treeUriStr,
|
||||
safeRelativeDir,
|
||||
finalName,
|
||||
srcPath,
|
||||
) ?: return@withSafNameLock null
|
||||
ExistingAwareWriteResult(
|
||||
uri = uri,
|
||||
fileName = finalName,
|
||||
alreadyExists = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sanitizeFilenamePreservingSuffix(fileName: String, suffix: String): String {
|
||||
val sanitized = sanitizeFilename(fileName)
|
||||
val trimmedSuffix = suffix.trim()
|
||||
if (trimmedSuffix.isEmpty() || sanitized.contains(trimmedSuffix)) return sanitized
|
||||
|
||||
val dotIndex = fileName.lastIndexOf('.')
|
||||
val hasExtension = dotIndex > 0 && dotIndex < fileName.length - 1
|
||||
val extension = if (hasExtension) fileName.substring(dotIndex) else ""
|
||||
val rawStem = if (hasExtension) fileName.substring(0, dotIndex) else fileName
|
||||
val rawPrefix = rawStem.replace(trimmedSuffix, "").trim(' ', '_', '-')
|
||||
val safeSuffix = sanitizeFilename(trimmedSuffix)
|
||||
val reserved = " - $safeSuffix$extension"
|
||||
val prefixBytes = (MAX_SAF_DISPLAY_NAME_UTF8_BYTES - reserved.toByteArray(Charsets.UTF_8).size)
|
||||
.coerceAtLeast(1)
|
||||
val safePrefix = truncateUtf8Bytes(sanitizeFilename(rawPrefix), prefixBytes)
|
||||
.trim()
|
||||
.trim('.', ' ', '_', '-')
|
||||
.ifBlank { "track" }
|
||||
return "$safePrefix$reserved"
|
||||
}
|
||||
|
||||
private fun findAvailableFileName(
|
||||
parent: DocumentFile,
|
||||
preferredName: String,
|
||||
preservedSuffix: String,
|
||||
): String {
|
||||
if (parent.findFile(preferredName) == null) return preferredName
|
||||
for (counter in 2..9999) {
|
||||
val candidate = appendFilenameCounter(
|
||||
preferredName,
|
||||
counter.toLong(),
|
||||
preservedSuffix,
|
||||
)
|
||||
if (parent.findFile(candidate) == null) return candidate
|
||||
}
|
||||
return appendFilenameCounter(
|
||||
preferredName,
|
||||
System.currentTimeMillis(),
|
||||
preservedSuffix,
|
||||
)
|
||||
}
|
||||
|
||||
private fun appendFilenameCounter(
|
||||
fileName: String,
|
||||
counter: Long,
|
||||
preservedSuffix: String,
|
||||
): String {
|
||||
val dotIndex = fileName.lastIndexOf('.')
|
||||
val hasExtension = dotIndex > 0 && dotIndex < fileName.length - 1
|
||||
val extension = if (hasExtension) fileName.substring(dotIndex) else ""
|
||||
val originalStem = if (hasExtension) fileName.substring(0, dotIndex) else fileName
|
||||
val safePreservedSuffix = preservedSuffix.trim()
|
||||
val hasPreservedSuffix = safePreservedSuffix.isNotEmpty() && originalStem.contains(safePreservedSuffix)
|
||||
val stem = if (hasPreservedSuffix) {
|
||||
originalStem.replace(safePreservedSuffix, "").trim(' ', '_', '-')
|
||||
} else {
|
||||
originalStem
|
||||
}
|
||||
val suffix = if (hasPreservedSuffix) {
|
||||
" - $safePreservedSuffix ($counter)"
|
||||
} else {
|
||||
" ($counter)"
|
||||
}
|
||||
val reservedBytes = extension.toByteArray(Charsets.UTF_8).size +
|
||||
suffix.toByteArray(Charsets.UTF_8).size
|
||||
val maxStemBytes = (MAX_SAF_DISPLAY_NAME_UTF8_BYTES - reservedBytes).coerceAtLeast(1)
|
||||
val safeStem = truncateUtf8Bytes(stem, maxStemBytes).trim().trim('.', ' ').ifBlank { "track" }
|
||||
return "$safeStem$suffix$extension"
|
||||
}
|
||||
|
||||
private fun writeFileToSafLocked(
|
||||
context: Context,
|
||||
treeUriStr: String,
|
||||
relativeDir: String,
|
||||
finalName: String,
|
||||
srcPath: String
|
||||
): String? {
|
||||
var stagedDocument: DocumentFile? = null
|
||||
return try {
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val targetDir = ensureDocumentDir(context, treeUri, relativeDir) ?: return null
|
||||
val finalName = sanitizeFilename(fileName)
|
||||
val ext = normalizeExt(finalName.substringAfterLast('.', ""))
|
||||
val stagedName = buildStagedSafFileName(finalName)
|
||||
deleteStaleStagedFiles(targetDir, finalName, ext)
|
||||
@@ -228,18 +527,16 @@ object SafDownloadHandler {
|
||||
File(srcPath).inputStream().use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
syncOutputStream(output)
|
||||
}
|
||||
|
||||
val existingFinal = targetDir.findFile(finalName)
|
||||
if (existingFinal != null && existingFinal.uri != document.uri) {
|
||||
existingFinal.delete()
|
||||
}
|
||||
if (!document.renameTo(finalName)) {
|
||||
val published = replaceFinalDocument(targetDir, document, finalName)
|
||||
if (published == null) {
|
||||
document.delete()
|
||||
return null
|
||||
}
|
||||
stagedDocument = null
|
||||
targetDir.findFile(finalName)?.uri?.toString() ?: document.uri.toString()
|
||||
published.uri.toString()
|
||||
} catch (e: Exception) {
|
||||
stagedDocument?.delete()
|
||||
android.util.Log.w("SpotiFLAC", "Failed to write file to SAF: ${e.message}")
|
||||
@@ -255,21 +552,20 @@ object SafDownloadHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeExt(ext: String?): String {
|
||||
if (ext.isNullOrBlank()) return ""
|
||||
return if (ext.startsWith(".")) {
|
||||
ext.lowercase(Locale.ROOT)
|
||||
} else {
|
||||
".${ext.lowercase(Locale.ROOT)}"
|
||||
}
|
||||
internal fun normalizeExt(ext: String?): String {
|
||||
val trimmed = ext?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return ""
|
||||
return if (trimmed.startsWith(".")) trimmed.lowercase(Locale.ROOT) else ".${trimmed.lowercase(Locale.ROOT)}"
|
||||
}
|
||||
|
||||
private fun mimeTypeForExt(ext: String?): String {
|
||||
internal fun mimeTypeForExt(ext: String?): String {
|
||||
return when (normalizeExt(ext)) {
|
||||
".m4a", ".mp4" -> "audio/mp4"
|
||||
".mp3" -> "audio/mpeg"
|
||||
".opus" -> "audio/ogg"
|
||||
".opus", ".ogg" -> "audio/ogg"
|
||||
".flac" -> "audio/flac"
|
||||
".wav" -> "audio/wav"
|
||||
".aiff", ".aif", ".aifc" -> "audio/aiff"
|
||||
".lrc" -> "application/octet-stream"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
@@ -313,7 +609,8 @@ object SafDownloadHandler {
|
||||
private fun deleteStaleStagedFiles(parent: DocumentFile, fileName: String, outputExt: String) {
|
||||
val stagedNames = linkedSetOf(
|
||||
buildStagedSafFileName(fileName),
|
||||
buildLegacyStagedSafFileName(fileName, outputExt)
|
||||
buildLegacyStagedSafFileName(fileName, outputExt),
|
||||
buildReplacedSafFileName(fileName)
|
||||
)
|
||||
for (stagedName in stagedNames) {
|
||||
try {
|
||||
@@ -323,7 +620,7 @@ object SafDownloadHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private fun sanitizeFilename(name: String): String {
|
||||
internal fun sanitizeFilename(name: String): String {
|
||||
var sanitized = name
|
||||
.replace("/", " ")
|
||||
.replace(Regex("[\\\\:*?\"<>|]"), " ")
|
||||
@@ -382,7 +679,7 @@ object SafDownloadHandler {
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
private fun sanitizeRelativeDir(relativeDir: String): String {
|
||||
internal fun sanitizeRelativeDir(relativeDir: String): String {
|
||||
if (relativeDir.isBlank()) return ""
|
||||
return relativeDir
|
||||
.split("/")
|
||||
@@ -391,7 +688,7 @@ object SafDownloadHandler {
|
||||
.joinToString("/")
|
||||
}
|
||||
|
||||
private fun ensureDocumentDir(
|
||||
internal fun ensureDocumentDir(
|
||||
context: Context,
|
||||
treeUri: Uri,
|
||||
relativeDir: String
|
||||
@@ -423,7 +720,7 @@ object SafDownloadHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private fun findDocumentDir(
|
||||
internal fun findDocumentDir(
|
||||
context: Context,
|
||||
treeUri: Uri,
|
||||
relativeDir: String
|
||||
@@ -441,7 +738,7 @@ object SafDownloadHandler {
|
||||
return current
|
||||
}
|
||||
|
||||
private fun createOrReuseDocumentFile(
|
||||
internal fun createOrReuseDocumentFile(
|
||||
parent: DocumentFile,
|
||||
mimeType: String,
|
||||
fileName: String
|
||||
|
||||
@@ -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"?>
|
||||
<resources>
|
||||
<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>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<automotiveApp>
|
||||
<uses name="media" />
|
||||
</automotiveApp>
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<data-extraction-rules>
|
||||
<!--
|
||||
SpotiFLAC Mobile provides an explicit, schema-aware Backup & Restore
|
||||
feature. Platform backup must not copy device-bound SAF grants, paths,
|
||||
queues, extension credentials, or signed sessions to another install.
|
||||
-->
|
||||
<cloud-backup>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
<exclude domain="device_root" path="." />
|
||||
<exclude domain="device_file" path="." />
|
||||
<exclude domain="device_database" path="." />
|
||||
<exclude domain="device_sharedpref" path="." />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
<exclude domain="device_root" path="." />
|
||||
<exclude domain="device_file" path="." />
|
||||
<exclude domain="device_database" path="." />
|
||||
<exclude domain="device_sharedpref" path="." />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<full-backup-content>
|
||||
<!-- Android 11 and lower use this legacy rule format. -->
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
<exclude domain="device_root" path="." />
|
||||
<exclude domain="device_file" path="." />
|
||||
<exclude domain="device_database" path="." />
|
||||
<exclude domain="device_sharedpref" path="." />
|
||||
</full-backup-content>
|
||||
@@ -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,152 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class NativeWorkerPolicyTest {
|
||||
@Test
|
||||
fun rateLimitRetriesExactlyOnce() {
|
||||
assertTrue(
|
||||
NativeWorkerPolicy.shouldRetryRateLimit(
|
||||
errorType = "rate_limit",
|
||||
errorMessage = "Too many requests",
|
||||
attempts = 0,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeWorkerPolicy.shouldRetryRateLimit(
|
||||
errorType = "rate_limit",
|
||||
errorMessage = "Too many requests",
|
||||
attempts = 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rateLimitDetectionFallsBackToMessage() {
|
||||
assertTrue(
|
||||
NativeWorkerPolicy.shouldRetryRateLimit(
|
||||
errorType = "network",
|
||||
errorMessage = "HTTP 429: retry later",
|
||||
attempts = 0,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeWorkerPolicy.shouldRetryRateLimit(
|
||||
errorType = "network",
|
||||
errorMessage = "Connection reset",
|
||||
attempts = 0,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retryDelayUsesServerHintAndSafeBounds() {
|
||||
assertEquals(
|
||||
45,
|
||||
NativeWorkerPolicy.rateLimitDelaySeconds(
|
||||
retryAfterSeconds = 45,
|
||||
errorMessage = null,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
12,
|
||||
NativeWorkerPolicy.rateLimitDelaySeconds(
|
||||
retryAfterSeconds = null,
|
||||
errorMessage = "Retry-After seconds: 12",
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
5,
|
||||
NativeWorkerPolicy.rateLimitDelaySeconds(
|
||||
retryAfterSeconds = 1,
|
||||
errorMessage = null,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
300,
|
||||
NativeWorkerPolicy.rateLimitDelaySeconds(
|
||||
retryAfterSeconds = 900,
|
||||
errorMessage = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wifiOnlyPausesWithoutWifi() {
|
||||
assertTrue(
|
||||
NativeWorkerPolicy.shouldPauseForNetwork(
|
||||
downloadNetworkMode = "wifi_only",
|
||||
hasWifi = false,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeWorkerPolicy.shouldPauseForNetwork(
|
||||
downloadNetworkMode = "wifi_only",
|
||||
hasWifi = true,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeWorkerPolicy.shouldPauseForNetwork(
|
||||
downloadNetworkMode = "any",
|
||||
hasWifi = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verificationDetectionUsesTypeAndMessageFallback() {
|
||||
assertTrue(
|
||||
NativeWorkerPolicy.isVerificationRequired(
|
||||
errorType = "verification_required",
|
||||
errorMessage = null,
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
NativeWorkerPolicy.isVerificationRequired(
|
||||
errorType = "unknown",
|
||||
errorMessage = "Challenge required before download",
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeWorkerPolicy.isVerificationRequired(
|
||||
errorType = "network",
|
||||
errorMessage = "Connection reset",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completionAlertSkipsCancelledOrEmptyRuns() {
|
||||
assertTrue(
|
||||
NativeWorkerPolicy.shouldNotifyQueueComplete(
|
||||
cancelRequested = false,
|
||||
completed = 1,
|
||||
failed = 0,
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
NativeWorkerPolicy.shouldNotifyQueueComplete(
|
||||
cancelRequested = false,
|
||||
completed = 0,
|
||||
failed = 1,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeWorkerPolicy.shouldNotifyQueueComplete(
|
||||
cancelRequested = true,
|
||||
completed = 1,
|
||||
failed = 0,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeWorkerPolicy.shouldNotifyQueueComplete(
|
||||
cancelRequested = false,
|
||||
completed = 0,
|
||||
failed = 0,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
@@ -11,8 +11,8 @@ subprojects {
|
||||
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
sourceCompatibility = JavaVersion.VERSION_25
|
||||
targetCompatibility = JavaVersion.VERSION_25
|
||||
}
|
||||
|
||||
// Enable multidex for all subprojects
|
||||
@@ -27,7 +27,7 @@ subprojects {
|
||||
|
||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-all.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
|
||||
@@ -19,8 +19,8 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "9.2.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.3.21" apply false
|
||||
id("com.android.application") version "9.3.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.4.10" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
"name": "SpotiFLAC Mobile",
|
||||
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||
"developerName": "zarzet",
|
||||
"version": "4.6.0",
|
||||
"versionDate": "2026-06-13",
|
||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.6.0/SpotiFLAC-v4.6.0-ios-unsigned.ipa",
|
||||
"localizedDescription": "SpotiFLAC Mobile is written in Flutter. Download tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
|
||||
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
||||
"size": 34347687
|
||||
"version": "4.8.0",
|
||||
"versionDate": "2026-07-25",
|
||||
"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 and uses installable extensions for metadata and audio downloads.",
|
||||
"iconURL": "https://raw.githubusercontent.com/spotiflacapp/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
||||
"size": 37939793
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Binary file not shown.
+2
-2
@@ -5,7 +5,7 @@
|
||||
# Template for the changelog body
|
||||
body = """
|
||||
{%- macro remote_url() -%}
|
||||
https://github.com/zarzet/SpotiFLAC-Mobile
|
||||
https://github.com/spotiflacapp/SpotiFLAC-Mobile
|
||||
{%- endmacro -%}
|
||||
|
||||
{% if version %}\
|
||||
@@ -99,5 +99,5 @@ tag_pattern = "v[0-9].*"
|
||||
sort_commits = "newest"
|
||||
|
||||
[remote.github]
|
||||
owner = "zarzet"
|
||||
owner = "spotiflacapp"
|
||||
repo = "SpotiFLAC-Mobile"
|
||||
|
||||
+3
-5
@@ -6,18 +6,16 @@ files:
|
||||
# Keys MUST be the project's Crowdin language ids; values are the
|
||||
# %locale% suffix used in app_%locale%.arb (underscores so Flutter
|
||||
# gen-l10n parses them — hyphenated filenames break gen-l10n).
|
||||
ar: ar
|
||||
# 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
|
||||
es-ES: es_ES
|
||||
fr: fr
|
||||
hi: hi
|
||||
id: id
|
||||
ja: ja
|
||||
ko: ko
|
||||
nl: nl
|
||||
pt-PT: pt_PT
|
||||
ru: ru
|
||||
tr: tr
|
||||
uk: uk
|
||||
zh-CN: zh_CN
|
||||
zh-TW: zh_TW
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,408 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// mp4Box is a minimal ISO-BMFF / QuickTime box view over an in-memory buffer.
|
||||
type mp4Box struct {
|
||||
offset int64
|
||||
size int64
|
||||
hdr int64
|
||||
typ string
|
||||
}
|
||||
|
||||
func (b mp4Box) body() int64 { return b.offset + b.hdr }
|
||||
func (b mp4Box) end() int64 { return b.offset + b.size }
|
||||
|
||||
func readMP4Box(data []byte, pos int64) (mp4Box, bool) {
|
||||
n := int64(len(data))
|
||||
if pos < 0 || pos+8 > n {
|
||||
return mp4Box{}, false
|
||||
}
|
||||
size := int64(binary.BigEndian.Uint32(data[pos : pos+4]))
|
||||
typ := string(data[pos+4 : pos+8])
|
||||
hdr := int64(8)
|
||||
switch size {
|
||||
case 1:
|
||||
if pos+16 > n {
|
||||
return mp4Box{}, false
|
||||
}
|
||||
size = int64(binary.BigEndian.Uint64(data[pos+8 : pos+16]))
|
||||
hdr = 16
|
||||
case 0:
|
||||
size = n - pos
|
||||
}
|
||||
if size < hdr || pos+size > n {
|
||||
return mp4Box{}, false
|
||||
}
|
||||
return mp4Box{offset: pos, size: size, hdr: hdr, typ: typ}, true
|
||||
}
|
||||
|
||||
func findChildMP4(data []byte, start, end int64, typ string) (mp4Box, bool) {
|
||||
pos := start
|
||||
for pos+8 <= end {
|
||||
b, ok := readMP4Box(data, pos)
|
||||
if !ok {
|
||||
return mp4Box{}, false
|
||||
}
|
||||
if b.typ == typ {
|
||||
return b, true
|
||||
}
|
||||
pos = b.end()
|
||||
}
|
||||
return mp4Box{}, false
|
||||
}
|
||||
|
||||
func eachChildMP4(data []byte, start, end int64, typ string, fn func(mp4Box) bool) {
|
||||
pos := start
|
||||
for pos+8 <= end {
|
||||
b, ok := readMP4Box(data, pos)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if b.typ == typ && !fn(b) {
|
||||
return
|
||||
}
|
||||
pos = b.end()
|
||||
}
|
||||
}
|
||||
|
||||
// findBoxBySignature scans [start,end) for a box of the given type, matching the
|
||||
// 4-byte type tag and validating the preceding size field. Used to locate dac4
|
||||
// which may be nested inside an encrypted (enca) sample entry.
|
||||
func findBoxBySignature(data []byte, start, end int64, typ string) (mp4Box, bool) {
|
||||
if len(typ) != 4 {
|
||||
return mp4Box{}, false
|
||||
}
|
||||
for i := start; i+8 <= end; i++ {
|
||||
if data[i+4] == typ[0] && data[i+5] == typ[1] && data[i+6] == typ[2] && data[i+7] == typ[3] {
|
||||
if b, ok := readMP4Box(data, i); ok && b.typ == typ {
|
||||
return b, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return mp4Box{}, false
|
||||
}
|
||||
|
||||
// audioSampleEntryHeaderLen returns the byte length of the fixed audio sample
|
||||
// entry header (from the box body start) before child boxes begin. ok is false
|
||||
// for malformed/truncated entries whose declared header is not fully present.
|
||||
func audioSampleEntryHeaderLen(data []byte, entry mp4Box) (hdrLen int64, ok bool) {
|
||||
// 6 bytes reserved + 2 bytes data_reference_index, then the audio fields.
|
||||
base := entry.body()
|
||||
if base+10 > entry.end() {
|
||||
return 0, false
|
||||
}
|
||||
version := binary.BigEndian.Uint16(data[base+8 : base+10])
|
||||
hdrLen = 8 + 20
|
||||
switch version {
|
||||
case 1:
|
||||
hdrLen += 16
|
||||
case 2:
|
||||
hdrLen += 36
|
||||
}
|
||||
if base+hdrLen > entry.end() {
|
||||
return 0, false
|
||||
}
|
||||
return hdrLen, true
|
||||
}
|
||||
|
||||
type ac4Location struct {
|
||||
chain []mp4Box // moov, trak, mdia, minf, stbl, stsd (ancestors to grow)
|
||||
entry mp4Box // the ac-4 sample entry
|
||||
}
|
||||
|
||||
func locateAC4Entry(data []byte) (ac4Location, bool) {
|
||||
moov, ok := findChildMP4(data, 0, int64(len(data)), "moov")
|
||||
if !ok {
|
||||
return ac4Location{}, false
|
||||
}
|
||||
var found ac4Location
|
||||
var ok2 bool
|
||||
eachChildMP4(data, moov.body(), moov.end(), "trak", func(trak mp4Box) bool {
|
||||
mdia, ok := findChildMP4(data, trak.body(), trak.end(), "mdia")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
minf, ok := findChildMP4(data, mdia.body(), mdia.end(), "minf")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
stbl, ok := findChildMP4(data, minf.body(), minf.end(), "stbl")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
stsd, ok := findChildMP4(data, stbl.body(), stbl.end(), "stsd")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
entry, ok := findChildMP4(data, stsd.body()+8, stsd.end(), "ac-4")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
found = ac4Location{chain: []mp4Box{moov, trak, mdia, minf, stbl, stsd}, entry: entry}
|
||||
ok2 = true
|
||||
return false
|
||||
})
|
||||
return found, ok2
|
||||
}
|
||||
|
||||
func growBoxSize(data []byte, b mp4Box, delta int64) {
|
||||
if b.hdr == 16 {
|
||||
binary.BigEndian.PutUint64(data[b.offset+8:b.offset+16], uint64(b.size+delta))
|
||||
} else {
|
||||
binary.BigEndian.PutUint32(data[b.offset:b.offset+4], uint32(b.size+delta))
|
||||
}
|
||||
}
|
||||
|
||||
// shiftChunkOffsets adds delta to every stco/co64 entry that references a file
|
||||
// offset at or beyond insertPos, keeping sample pointers valid after bytes are
|
||||
// inserted into moov.
|
||||
func shiftChunkOffsets(data []byte, moov mp4Box, insertPos, delta int64) {
|
||||
eachChildMP4(data, moov.body(), moov.end(), "trak", func(trak mp4Box) bool {
|
||||
mdia, ok := findChildMP4(data, trak.body(), trak.end(), "mdia")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
minf, ok := findChildMP4(data, mdia.body(), mdia.end(), "minf")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
stbl, ok := findChildMP4(data, minf.body(), minf.end(), "stbl")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if stco, ok := findChildMP4(data, stbl.body(), stbl.end(), "stco"); ok {
|
||||
base := stco.body() + 4
|
||||
if base+4 <= stco.end() {
|
||||
count := int64(binary.BigEndian.Uint32(data[base : base+4]))
|
||||
p := base + 4
|
||||
for i := int64(0); i < count && p+4 <= stco.end(); i++ {
|
||||
v := int64(binary.BigEndian.Uint32(data[p : p+4]))
|
||||
if v >= insertPos {
|
||||
binary.BigEndian.PutUint32(data[p:p+4], uint32(v+delta))
|
||||
}
|
||||
p += 4
|
||||
}
|
||||
}
|
||||
}
|
||||
if co64, ok := findChildMP4(data, stbl.body(), stbl.end(), "co64"); ok {
|
||||
base := co64.body() + 4
|
||||
if base+4 <= co64.end() {
|
||||
count := int64(binary.BigEndian.Uint32(data[base : base+4]))
|
||||
p := base + 4
|
||||
for i := int64(0); i < count && p+8 <= co64.end(); i++ {
|
||||
v := int64(binary.BigEndian.Uint64(data[p : p+8]))
|
||||
if v >= insertPos {
|
||||
binary.BigEndian.PutUint64(data[p:p+8], uint64(v+delta))
|
||||
}
|
||||
p += 8
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// normalizeQuickTimeBrandsInBuf rewrites a "qt " ftyp brand to isom/mp42 in
|
||||
// place. Works on any buffer whose top level contains the ftyp box (whole file
|
||||
// or the ftyp box alone). Returns whether anything changed.
|
||||
func normalizeQuickTimeBrandsInBuf(data []byte) bool {
|
||||
ftyp, ok := findChildMP4(data, 0, int64(len(data)), "ftyp")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
if ftyp.body()+4 <= int64(len(data)) && string(data[ftyp.body():ftyp.body()+4]) != "mp42" {
|
||||
copy(data[ftyp.body():ftyp.body()+4], []byte("mp42"))
|
||||
changed = true
|
||||
}
|
||||
for p := ftyp.body() + 8; p+4 <= ftyp.end(); p += 4 {
|
||||
if string(data[p:p+4]) == "qt " {
|
||||
copy(data[p:p+4], []byte("isom"))
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// normalizeQuickTimeAudioEntry rewrites a version-1 QuickTime sound sample
|
||||
// entry into a plain version-0 AudioSampleEntry, dropping the 16-byte v1
|
||||
// extension. base is the buffer's absolute file offset (0 for a whole-file
|
||||
// buffer, the moov offset for a moov-only buffer).
|
||||
func normalizeQuickTimeAudioEntry(data []byte, base int64) []byte {
|
||||
loc, ok := locateAC4Entry(data)
|
||||
if !ok {
|
||||
return data
|
||||
}
|
||||
entry := loc.entry
|
||||
verPos := entry.body() + 8
|
||||
if verPos+2 > entry.end() {
|
||||
return data
|
||||
}
|
||||
if binary.BigEndian.Uint16(data[verPos:verPos+2]) != 1 {
|
||||
return data // already v0 (or v2, left untouched)
|
||||
}
|
||||
|
||||
// The v1 QuickTime sound extension is the 16 bytes following the 20-byte v0
|
||||
// audio fields (samplesPerPacket, bytesPerPacket, bytesPerFrame, bytesPerSample).
|
||||
extStart := entry.body() + 8 + 20
|
||||
extEnd := extStart + 16
|
||||
if extEnd > entry.end() {
|
||||
return data
|
||||
}
|
||||
delta := int64(-16)
|
||||
|
||||
binary.BigEndian.PutUint16(data[verPos:verPos+2], 0)
|
||||
shiftChunkOffsets(data, loc.chain[0], base+extStart, delta)
|
||||
for _, b := range loc.chain {
|
||||
growBoxSize(data, b, delta)
|
||||
}
|
||||
growBoxSize(data, entry, delta)
|
||||
|
||||
out := make([]byte, 0, len(data)-16)
|
||||
out = append(out, data[:extStart]...)
|
||||
out = append(out, data[extEnd:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeQuickTimeAudioToMP4 rewrites a QuickTime-flavored file (FFmpeg mov
|
||||
// muxer output: ftyp brand "qt " and a version-1 sound sample entry) into a
|
||||
// standard ISO MP4: an isom/mp42 brand and a plain version-0 AudioSampleEntry.
|
||||
// Windows Media Foundation (and other strict parsers) reject the QuickTime
|
||||
// flavor for AC-4 even when dac4 is present.
|
||||
func normalizeQuickTimeAudioToMP4(data []byte) []byte {
|
||||
normalizeQuickTimeBrandsInBuf(data)
|
||||
return normalizeQuickTimeAudioEntry(data, 0)
|
||||
}
|
||||
|
||||
// EnsureAC4ConfigBox makes a decrypted AC-4 MP4 standards-compliant and
|
||||
// playable: it normalizes FFmpeg's QuickTime-flavored mov output to an ISO MP4
|
||||
// and injects the AC-4 configuration box (dac4) into the ac-4 sample entry. The
|
||||
// dac4 box is copied verbatim from sourcePath (the original MP4, whose plaintext
|
||||
// moov still carries it). No-op when the file has no AC-4 track. Only the ftyp
|
||||
// and moov boxes are held in memory; the audio bulk is streamed.
|
||||
func EnsureAC4ConfigBox(decryptedPath, sourcePath string) error {
|
||||
f, err := os.Open(decryptedPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
// A non-MP4 decrypt output (e.g. a raw FLAC stream) is not an AC-4 file;
|
||||
// bail out before the box parser reports it as a corrupt MP4. A real
|
||||
// ISO-BMFF box type is four printable ASCII bytes.
|
||||
var head [8]byte
|
||||
if _, err := f.ReadAt(head[:], 0); err != nil {
|
||||
f.Close()
|
||||
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
for _, c := range head[4:8] {
|
||||
if c < 0x20 || c > 0x7e {
|
||||
f.Close()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
moovBuf, moovOffset, moovFound, err := loadTopLevelMP4Box(f, info.Size(), "moov")
|
||||
if err != nil || !moovFound {
|
||||
f.Close()
|
||||
return err // parse/read failure, or no moov: nothing to do
|
||||
}
|
||||
ftypBuf, ftypOffset, ftypFound, err := loadTopLevelMP4Box(f, info.Size(), "ftyp")
|
||||
f.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
moovLen := int64(len(moovBuf))
|
||||
|
||||
if _, ok := locateAC4Entry(moovBuf); !ok {
|
||||
return nil // not an AC-4 file; nothing to do
|
||||
}
|
||||
|
||||
ftypChanged := ftypFound && normalizeQuickTimeBrandsInBuf(ftypBuf)
|
||||
dst := normalizeQuickTimeAudioEntry(moovBuf, moovOffset)
|
||||
|
||||
loc, ok := locateAC4Entry(dst)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
hdrLen, ok := audioSampleEntryHeaderLen(dst, loc.entry)
|
||||
if !ok {
|
||||
return fmt.Errorf("malformed ac-4 sample entry")
|
||||
}
|
||||
childStart := loc.entry.body() + hdrLen
|
||||
if _, has := findChildMP4(dst, childStart, loc.entry.end(), "dac4"); has {
|
||||
// Already has dac4; still persist any normalization changes.
|
||||
return writeAC4Sections(decryptedPath, ftypChanged, ftypBuf, ftypOffset, dst, moovOffset, moovLen)
|
||||
}
|
||||
|
||||
srcF, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srcInfo, err := srcF.Stat()
|
||||
if err != nil {
|
||||
srcF.Close()
|
||||
return err
|
||||
}
|
||||
srcMoovBuf, _, srcFound, err := loadTopLevelMP4Box(srcF, srcInfo.Size(), "moov")
|
||||
srcF.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !srcFound {
|
||||
return fmt.Errorf("source has no moov")
|
||||
}
|
||||
srcMoov, ok := readMP4Box(srcMoovBuf, 0)
|
||||
if !ok {
|
||||
return fmt.Errorf("source has no moov")
|
||||
}
|
||||
dac4Box, ok := findBoxBySignature(srcMoovBuf, srcMoov.body(), srcMoov.end(), "dac4")
|
||||
if !ok {
|
||||
return fmt.Errorf("dac4 not found in source")
|
||||
}
|
||||
dac4 := append([]byte{}, srcMoovBuf[dac4Box.offset:dac4Box.end()]...)
|
||||
|
||||
insertPos := childStart
|
||||
delta := int64(len(dac4))
|
||||
|
||||
shiftChunkOffsets(dst, loc.chain[0], moovOffset+insertPos, delta)
|
||||
for _, b := range loc.chain {
|
||||
growBoxSize(dst, b, delta)
|
||||
}
|
||||
growBoxSize(dst, loc.entry, delta)
|
||||
|
||||
out := make([]byte, 0, len(dst)+len(dac4))
|
||||
out = append(out, dst[:insertPos]...)
|
||||
out = append(out, dac4...)
|
||||
out = append(out, dst[insertPos:]...)
|
||||
|
||||
return writeAC4Sections(decryptedPath, ftypChanged, ftypBuf, ftypOffset, out, moovOffset, moovLen)
|
||||
}
|
||||
|
||||
// writeAC4Sections streams the edited moov (and, when changed, ftyp) back into
|
||||
// the file.
|
||||
func writeAC4Sections(path string, ftypChanged bool, ftypBuf []byte, ftypOffset int64, moovBuf []byte, moovOffset, origMoovLen int64) error {
|
||||
sections := []fileSection{
|
||||
{start: moovOffset, end: moovOffset + origMoovLen, data: moovBuf},
|
||||
}
|
||||
if ftypChanged {
|
||||
sections = append(sections, fileSection{
|
||||
start: ftypOffset,
|
||||
end: ftypOffset + int64(len(ftypBuf)),
|
||||
data: ftypBuf,
|
||||
})
|
||||
}
|
||||
return replaceFileSectionsStreaming(path, sections)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func mp4TestBox(typ string, body []byte) []byte {
|
||||
out := make([]byte, 8+len(body))
|
||||
binary.BigEndian.PutUint32(out[:4], uint32(len(out)))
|
||||
copy(out[4:8], typ)
|
||||
copy(out[8:], body)
|
||||
return out
|
||||
}
|
||||
|
||||
func mp4TestAC4Tree(entryBody []byte) []byte {
|
||||
entry := mp4TestBox("ac-4", entryBody)
|
||||
stsdBody := append([]byte{
|
||||
0, 0, 0, 0, // version/flags
|
||||
0, 0, 0, 1, // entry_count
|
||||
}, entry...)
|
||||
stsd := mp4TestBox("stsd", stsdBody)
|
||||
stbl := mp4TestBox("stbl", stsd)
|
||||
minf := mp4TestBox("minf", stbl)
|
||||
mdia := mp4TestBox("mdia", minf)
|
||||
trak := mp4TestBox("trak", mdia)
|
||||
moov := mp4TestBox("moov", trak)
|
||||
return moov
|
||||
}
|
||||
|
||||
func shortAC4SampleEntryBody(version uint16) []byte {
|
||||
body := make([]byte, 10)
|
||||
binary.BigEndian.PutUint16(body[8:10], version)
|
||||
return body
|
||||
}
|
||||
|
||||
func TestNormalizeQuickTimeAudioToMP4IgnoresTruncatedAC4Entry(t *testing.T) {
|
||||
input := mp4TestAC4Tree(shortAC4SampleEntryBody(1))
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("normalizeQuickTimeAudioToMP4 panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
got := normalizeQuickTimeAudioToMP4(append([]byte{}, input...))
|
||||
if !bytes.Equal(got, input) {
|
||||
t.Fatal("truncated QuickTime AC-4 entry should be left unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureAC4ConfigBoxRejectsTruncatedAC4Entry(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
decryptedPath := filepath.Join(dir, "decrypted.mp4")
|
||||
sourcePath := filepath.Join(dir, "source.mp4")
|
||||
|
||||
if err := os.WriteFile(decryptedPath, mp4TestAC4Tree(shortAC4SampleEntryBody(2)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(sourcePath, mp4TestBox("moov", mp4TestBox("dac4", []byte{1, 2, 3, 4})), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("EnsureAC4ConfigBox panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := EnsureAC4ConfigBox(decryptedPath, sourcePath); err == nil {
|
||||
t.Fatal("expected malformed AC-4 sample entry error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ac4Metadata mirrors the tag fields the app embeds for other formats. Numeric
|
||||
// fields are strings because they arrive as a JSON-encoded map of strings.
|
||||
type ac4Metadata struct {
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
Album string `json:"album"`
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
Date string `json:"date"`
|
||||
Genre string `json:"genre"`
|
||||
Composer string `json:"composer"`
|
||||
TrackNumber string `json:"trackNumber"`
|
||||
TotalTracks string `json:"totalTracks"`
|
||||
DiscNumber string `json:"discNumber"`
|
||||
TotalDiscs string `json:"totalDiscs"`
|
||||
ISRC string `json:"isrc"`
|
||||
Label string `json:"label"`
|
||||
Copyright string `json:"copyright"`
|
||||
Lyrics string `json:"lyrics"`
|
||||
}
|
||||
|
||||
func atoiSafe(s string) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func itunesTextTag(atomType, value string) []byte {
|
||||
data := make([]byte, 8+len(value))
|
||||
binary.BigEndian.PutUint32(data[0:4], 1) // well-known type 1 = UTF-8
|
||||
copy(data[8:], []byte(value))
|
||||
return buildM4AAtom(atomType, buildM4AAtom("data", data))
|
||||
}
|
||||
|
||||
func itunesNumberPairTag(atomType string, number, total int) []byte {
|
||||
payload := make([]byte, 8)
|
||||
binary.BigEndian.PutUint16(payload[2:4], uint16(number))
|
||||
binary.BigEndian.PutUint16(payload[4:6], uint16(total))
|
||||
data := make([]byte, 8+len(payload))
|
||||
binary.BigEndian.PutUint32(data[0:4], 0) // type 0 = implicit/binary
|
||||
copy(data[8:], payload)
|
||||
return buildM4AAtom(atomType, buildM4AAtom("data", data))
|
||||
}
|
||||
|
||||
func itunesCoverTag(image []byte) []byte {
|
||||
typeCode := uint32(13) // JPEG
|
||||
if len(image) >= 8 &&
|
||||
image[0] == 0x89 && image[1] == 0x50 && image[2] == 0x4E && image[3] == 0x47 {
|
||||
typeCode = 14 // PNG
|
||||
}
|
||||
data := make([]byte, 8+len(image))
|
||||
binary.BigEndian.PutUint32(data[0:4], typeCode)
|
||||
copy(data[8:], image)
|
||||
return buildM4AAtom("covr", buildM4AAtom("data", data))
|
||||
}
|
||||
|
||||
func itunesMetadataHandler() []byte {
|
||||
payload := make([]byte, 0, 25)
|
||||
payload = append(payload, 0, 0, 0, 0) // version + flags
|
||||
payload = append(payload, 0, 0, 0, 0) // pre_defined
|
||||
payload = append(payload, []byte("mdir")...) // handler type
|
||||
payload = append(payload, []byte("appl")...) // reserved[0]
|
||||
payload = append(payload, 0, 0, 0, 0, 0, 0, 0, 0) // reserved[1..2]
|
||||
payload = append(payload, 0) // empty name
|
||||
return buildM4AAtom("hdlr", payload)
|
||||
}
|
||||
|
||||
// buildITunesUdta assembles a fresh udta>meta>(hdlr+ilst) box from metadata.
|
||||
func buildITunesUdta(md ac4Metadata, cover []byte) []byte {
|
||||
ilst := make([]byte, 0, 256)
|
||||
add := func(atomType, value string) {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
ilst = append(ilst, itunesTextTag(atomType, value)...)
|
||||
}
|
||||
}
|
||||
add("\xa9nam", md.Title)
|
||||
add("\xa9ART", md.Artist)
|
||||
add("\xa9alb", md.Album)
|
||||
add("aART", md.AlbumArtist)
|
||||
add("\xa9day", md.Date)
|
||||
add("\xa9gen", md.Genre)
|
||||
add("\xa9wrt", md.Composer)
|
||||
if tn := atoiSafe(md.TrackNumber); tn > 0 {
|
||||
ilst = append(ilst, itunesNumberPairTag("trkn", tn, atoiSafe(md.TotalTracks))...)
|
||||
}
|
||||
if dn := atoiSafe(md.DiscNumber); dn > 0 {
|
||||
ilst = append(ilst, itunesNumberPairTag("disk", dn, atoiSafe(md.TotalDiscs))...)
|
||||
}
|
||||
if strings.TrimSpace(md.ISRC) != "" {
|
||||
ilst = append(ilst, buildM4AFreeformAtom("ISRC", strings.TrimSpace(md.ISRC))...)
|
||||
}
|
||||
if strings.TrimSpace(md.Label) != "" {
|
||||
ilst = append(ilst, buildM4AFreeformAtom("LABEL", strings.TrimSpace(md.Label))...)
|
||||
}
|
||||
if strings.TrimSpace(md.Copyright) != "" {
|
||||
add("cprt", md.Copyright)
|
||||
}
|
||||
if strings.TrimSpace(md.Lyrics) != "" {
|
||||
add("\xa9lyr", md.Lyrics)
|
||||
}
|
||||
if len(cover) > 0 {
|
||||
ilst = append(ilst, itunesCoverTag(cover)...)
|
||||
}
|
||||
|
||||
ilstBox := buildM4AAtom("ilst", ilst)
|
||||
metaPayload := append([]byte{0, 0, 0, 0}, itunesMetadataHandler()...)
|
||||
metaPayload = append(metaPayload, ilstBox...)
|
||||
meta := buildM4AAtom("meta", metaPayload)
|
||||
return buildM4AAtom("udta", meta)
|
||||
}
|
||||
|
||||
// writeMP4iTunesMetadata replaces (or inserts) a udta>meta>ilst metadata box in
|
||||
// the moov of an MP4 buffer and returns the rewritten bytes. base is the
|
||||
// buffer's absolute file offset (0 for a whole-file buffer) so stco/co64
|
||||
// shifts compare against the absolute positions the entries hold.
|
||||
func writeMP4iTunesMetadata(data []byte, base int64, md ac4Metadata, cover []byte) []byte {
|
||||
moov, ok := findChildMP4(data, 0, int64(len(data)), "moov")
|
||||
if !ok {
|
||||
return data
|
||||
}
|
||||
newUdta := buildITunesUdta(md, cover)
|
||||
|
||||
if udta, ok := findChildMP4(data, moov.body(), moov.end(), "udta"); ok {
|
||||
delta := int64(len(newUdta)) - udta.size
|
||||
shiftChunkOffsets(data, moov, base+udta.offset, delta)
|
||||
growBoxSize(data, moov, delta)
|
||||
out := make([]byte, 0, len(data)+len(newUdta))
|
||||
out = append(out, data[:udta.offset]...)
|
||||
out = append(out, newUdta...)
|
||||
out = append(out, data[udta.end():]...)
|
||||
return out
|
||||
}
|
||||
|
||||
delta := int64(len(newUdta))
|
||||
insertPos := moov.end()
|
||||
shiftChunkOffsets(data, moov, base+insertPos, delta)
|
||||
growBoxSize(data, moov, delta)
|
||||
out := make([]byte, 0, len(data)+len(newUdta))
|
||||
out = append(out, data[:insertPos]...)
|
||||
out = append(out, newUdta...)
|
||||
out = append(out, data[insertPos:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// WriteAC4MetadataIfApplicable writes iTunes metadata into an AC-4 MP4. Returns
|
||||
// true when the file was an AC-4 track and metadata was written; false when the
|
||||
// file is not AC-4 (the caller should fall back to its normal metadata path).
|
||||
// Only the moov box is held in memory; the audio bulk is streamed.
|
||||
func WriteAC4MetadataIfApplicable(decryptedPath, metadataJSON, coverPath string) (bool, error) {
|
||||
f, err := os.Open(decryptedPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return false, err
|
||||
}
|
||||
moovBuf, moovOffset, found, err := loadTopLevelMP4Box(f, info.Size(), "moov")
|
||||
f.Close()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !found {
|
||||
return false, nil
|
||||
}
|
||||
moovLen := int64(len(moovBuf))
|
||||
if _, ok := locateAC4Entry(moovBuf); !ok {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var md ac4Metadata
|
||||
if strings.TrimSpace(metadataJSON) != "" {
|
||||
_ = json.Unmarshal([]byte(metadataJSON), &md)
|
||||
}
|
||||
var cover []byte
|
||||
if strings.TrimSpace(coverPath) != "" {
|
||||
if b, err := os.ReadFile(coverPath); err == nil {
|
||||
cover = b
|
||||
}
|
||||
}
|
||||
|
||||
out := writeMP4iTunesMetadata(moovBuf, moovOffset, md, cover)
|
||||
if err := replaceFileSectionsStreaming(decryptedPath, []fileSection{
|
||||
{start: moovOffset, end: moovOffset + moovLen, data: out},
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -16,9 +16,8 @@ const (
|
||||
apeTagFlagHeader = 1 << 29 // bit 29: this is the header, not the footer
|
||||
apeTagFlagReadOnly = 1 << 0
|
||||
// Item flags: bits 1-2 encode content type
|
||||
apeItemFlagUTF8 = 0 << 1 // 00: UTF-8 text
|
||||
apeItemFlagBinary = 1 << 1 // 01: binary data
|
||||
apeItemFlagLink = 2 << 1 // 10: external link
|
||||
// (00: UTF-8 text, 01: binary data, 10: external link)
|
||||
apeItemFlagBinary = 1 << 1
|
||||
)
|
||||
|
||||
// APETagItem represents a single key-value item in an APEv2 tag.
|
||||
@@ -314,7 +313,6 @@ func marshalAPETag(tag *APETag) ([]byte, error) {
|
||||
footerFlags := uint32(1 << 31)
|
||||
footer := buildAPEHeaderFooter(version, tagSize, itemCount, footerFlags)
|
||||
|
||||
// Final layout: header + items + footer
|
||||
result := make([]byte, 0, len(header)+len(itemsData)+len(footer))
|
||||
result = append(result, header...)
|
||||
result = append(result, itemsData...)
|
||||
@@ -564,7 +562,7 @@ func ReadAPETagsFromReader(r io.ReaderAt, fileSize int64) (*APETag, error) {
|
||||
return nil, fmt.Errorf("no APEv2 tag found")
|
||||
}
|
||||
|
||||
func parseAPETagFromFooter(r io.ReaderAt, fileSize, footerOffset int64, footer []byte) (*APETag, error) {
|
||||
func parseAPETagFromFooter(r io.ReaderAt, _, footerOffset int64, footer []byte) (*APETag, error) {
|
||||
version := binary.LittleEndian.Uint32(footer[8:12])
|
||||
tagSize := binary.LittleEndian.Uint32(footer[12:16])
|
||||
itemCount := binary.LittleEndian.Uint32(footer[16:20])
|
||||
|
||||
@@ -5,9 +5,7 @@ import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -185,7 +183,7 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
|
||||
case "TCR":
|
||||
metadata.Copyright = value
|
||||
case "ULT":
|
||||
if v := extractLyricsFrame(frameData); v != "" && metadata.Lyrics == "" {
|
||||
if v := extractLangTextFrame(frameData); v != "" && metadata.Lyrics == "" {
|
||||
metadata.Lyrics = v
|
||||
}
|
||||
case "TXX":
|
||||
@@ -306,11 +304,11 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
|
||||
case "TCOP":
|
||||
metadata.Copyright = value
|
||||
case "COMM":
|
||||
if v := extractCommentFrame(frameData); v != "" {
|
||||
if v := extractLangTextFrame(frameData); v != "" {
|
||||
metadata.Comment = v
|
||||
}
|
||||
case "USLT":
|
||||
if v := extractLyricsFrame(frameData); v != "" && metadata.Lyrics == "" {
|
||||
if v := extractLangTextFrame(frameData); v != "" && metadata.Lyrics == "" {
|
||||
metadata.Lyrics = v
|
||||
}
|
||||
case "TXXX":
|
||||
@@ -390,7 +388,9 @@ func extractTextFrame(data []byte) string {
|
||||
}
|
||||
}
|
||||
|
||||
func extractCommentFrame(data []byte) string {
|
||||
// extractLangTextFrame decodes ID3 frames with an encoding byte, 3-byte
|
||||
// language code, and null-terminated descriptor before the text (COMM, USLT).
|
||||
func extractLangTextFrame(data []byte) string {
|
||||
if len(data) < 5 {
|
||||
return ""
|
||||
}
|
||||
@@ -425,42 +425,6 @@ func extractCommentFrame(data []byte) string {
|
||||
return extractTextFrame(framed)
|
||||
}
|
||||
|
||||
func extractLyricsFrame(data []byte) string {
|
||||
if len(data) < 5 {
|
||||
return ""
|
||||
}
|
||||
|
||||
encoding := data[0]
|
||||
rest := data[4:]
|
||||
|
||||
var text []byte
|
||||
switch encoding {
|
||||
case 1, 2:
|
||||
for i := 0; i+1 < len(rest); i += 2 {
|
||||
if rest[i] == 0 && rest[i+1] == 0 {
|
||||
text = rest[i+2:]
|
||||
break
|
||||
}
|
||||
}
|
||||
default:
|
||||
idx := bytes.IndexByte(rest, 0)
|
||||
if idx >= 0 && idx+1 < len(rest) {
|
||||
text = rest[idx+1:]
|
||||
} else {
|
||||
text = rest
|
||||
}
|
||||
}
|
||||
|
||||
if len(text) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
framed := make([]byte, 1+len(text))
|
||||
framed[0] = encoding
|
||||
copy(framed[1:], text)
|
||||
return extractTextFrame(framed)
|
||||
}
|
||||
|
||||
func extractUserTextFrame(data []byte) (string, string) {
|
||||
if len(data) < 2 {
|
||||
return "", ""
|
||||
@@ -581,11 +545,6 @@ func cleanGenre(genre string) string {
|
||||
return genre
|
||||
}
|
||||
|
||||
func parseTrackNumber(s string) int {
|
||||
num, _ := parseIndexPair(s)
|
||||
return num
|
||||
}
|
||||
|
||||
func parseIndexPair(s string) (int, int) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
@@ -813,414 +772,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
||||
return quality, nil
|
||||
}
|
||||
|
||||
func ReadOggVorbisComments(filePath string) (*AudioMetadata, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
metadata := &AudioMetadata{}
|
||||
|
||||
packets, err := collectOggPackets(file, 30, 80)
|
||||
if err != nil && len(packets) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
streamType := detectOggStreamType(packets)
|
||||
for _, pkt := range packets {
|
||||
if streamType == oggStreamOpus {
|
||||
if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" {
|
||||
parseVorbisComments(pkt[8:], metadata)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
if streamType == oggStreamVorbis || streamType == oggStreamUnknown {
|
||||
if len(pkt) > 7 && pkt[0] == 0x03 && string(pkt[1:7]) == "vorbis" {
|
||||
parseVorbisComments(pkt[7:], metadata)
|
||||
break
|
||||
}
|
||||
}
|
||||
if streamType == oggStreamUnknown {
|
||||
if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" {
|
||||
parseVorbisComments(pkt[8:], metadata)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if metadata.Title == "" && metadata.Artist == "" {
|
||||
return nil, fmt.Errorf("no Vorbis comments found")
|
||||
}
|
||||
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
type oggPage struct {
|
||||
headerType byte
|
||||
segmentTable []byte
|
||||
data []byte
|
||||
}
|
||||
|
||||
func readOggPageWithHeader(file *os.File) (*oggPage, error) {
|
||||
header := make([]byte, 27)
|
||||
if _, err := io.ReadFull(file, header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if string(header[0:4]) != "OggS" {
|
||||
return nil, fmt.Errorf("not an Ogg page")
|
||||
}
|
||||
|
||||
headerType := header[5]
|
||||
numSegments := int(header[26])
|
||||
|
||||
segmentTable := make([]byte, numSegments)
|
||||
if _, err := io.ReadFull(file, segmentTable); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var pageSize int
|
||||
for _, seg := range segmentTable {
|
||||
pageSize += int(seg)
|
||||
}
|
||||
|
||||
pageData := make([]byte, pageSize)
|
||||
if _, err := io.ReadFull(file, pageData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &oggPage{
|
||||
headerType: headerType,
|
||||
segmentTable: segmentTable,
|
||||
data: pageData,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func collectOggPackets(file *os.File, maxPackets, maxPages int) ([][]byte, error) {
|
||||
const maxPacketSize = 10 * 1024 * 1024
|
||||
var packets [][]byte
|
||||
var cur []byte
|
||||
skipPacket := false
|
||||
|
||||
for pageNum := 0; pageNum < maxPages && len(packets) < maxPackets; pageNum++ {
|
||||
page, err := readOggPageWithHeader(file)
|
||||
if err != nil {
|
||||
if len(packets) > 0 {
|
||||
return packets, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if page.headerType&0x01 == 0 && len(cur) > 0 {
|
||||
cur = nil
|
||||
skipPacket = false
|
||||
}
|
||||
|
||||
offset := 0
|
||||
for _, seg := range page.segmentTable {
|
||||
segLen := int(seg)
|
||||
if offset+segLen > len(page.data) {
|
||||
return packets, fmt.Errorf("invalid ogg segment size")
|
||||
}
|
||||
|
||||
if skipPacket {
|
||||
offset += segLen
|
||||
if segLen < 255 {
|
||||
skipPacket = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if len(cur)+segLen > maxPacketSize {
|
||||
cur = nil
|
||||
skipPacket = true
|
||||
offset += segLen
|
||||
if segLen < 255 {
|
||||
skipPacket = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
cur = append(cur, page.data[offset:offset+segLen]...)
|
||||
offset += segLen
|
||||
|
||||
if segLen < 255 {
|
||||
if len(cur) > 0 {
|
||||
packets = append(packets, cur)
|
||||
}
|
||||
cur = nil
|
||||
if len(packets) >= maxPackets {
|
||||
return packets, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return packets, nil
|
||||
}
|
||||
|
||||
type oggStreamType int
|
||||
|
||||
const (
|
||||
oggStreamUnknown oggStreamType = iota
|
||||
oggStreamOpus
|
||||
oggStreamVorbis
|
||||
)
|
||||
|
||||
func detectOggStreamType(packets [][]byte) oggStreamType {
|
||||
for _, p := range packets {
|
||||
if len(p) >= 8 && string(p[0:8]) == "OpusHead" {
|
||||
return oggStreamOpus
|
||||
}
|
||||
if len(p) > 7 && p[0] == 0x01 && string(p[1:7]) == "vorbis" {
|
||||
return oggStreamVorbis
|
||||
}
|
||||
}
|
||||
return oggStreamUnknown
|
||||
}
|
||||
|
||||
func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
||||
if len(data) < 4 {
|
||||
return
|
||||
}
|
||||
|
||||
reader := bytes.NewReader(data)
|
||||
artistValues := make([]string, 0, 1)
|
||||
albumArtistValues := make([]string, 0, 1)
|
||||
|
||||
var vendorLen uint32
|
||||
if err := binary.Read(reader, binary.LittleEndian, &vendorLen); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if vendorLen > uint32(len(data)-4) {
|
||||
return
|
||||
}
|
||||
vendor := make([]byte, vendorLen)
|
||||
if _, err := reader.Read(vendor); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var commentCount uint32
|
||||
if err := binary.Read(reader, binary.LittleEndian, &commentCount); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for i := uint32(0); i < commentCount && i < 100; i++ {
|
||||
var commentLen uint32
|
||||
if err := binary.Read(reader, binary.LittleEndian, &commentLen); err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
remaining := uint32(reader.Len())
|
||||
if commentLen > remaining {
|
||||
break
|
||||
}
|
||||
if commentLen > 512*1024 {
|
||||
reader.Seek(int64(commentLen), io.SeekCurrent)
|
||||
continue
|
||||
}
|
||||
|
||||
comment := make([]byte, commentLen)
|
||||
if _, err := reader.Read(comment); err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
parts := strings.SplitN(string(comment), "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.ToUpper(parts[0])
|
||||
value := parts[1]
|
||||
|
||||
switch key {
|
||||
case "TITLE":
|
||||
metadata.Title = value
|
||||
case "ARTIST":
|
||||
artistValues = append(artistValues, value)
|
||||
case "ALBUMARTIST", "ALBUM_ARTIST", "ALBUM ARTIST":
|
||||
albumArtistValues = append(albumArtistValues, value)
|
||||
case "ALBUM":
|
||||
metadata.Album = value
|
||||
case "DATE", "YEAR":
|
||||
metadata.Date = value
|
||||
if len(value) >= 4 {
|
||||
metadata.Year = value[:4]
|
||||
}
|
||||
case "GENRE":
|
||||
metadata.Genre = value
|
||||
case "TRACKNUMBER", "TRACK":
|
||||
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||
case "DISCNUMBER", "DISC":
|
||||
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||
case "ISRC":
|
||||
metadata.ISRC = value
|
||||
case "COMPOSER":
|
||||
metadata.Composer = value
|
||||
case "COMMENT", "DESCRIPTION":
|
||||
metadata.Comment = value
|
||||
case "LYRICS", "UNSYNCEDLYRICS":
|
||||
if metadata.Lyrics == "" {
|
||||
metadata.Lyrics = value
|
||||
}
|
||||
case "ORGANIZATION", "LABEL", "PUBLISHER":
|
||||
metadata.Label = value
|
||||
case "COPYRIGHT":
|
||||
metadata.Copyright = value
|
||||
case "REPLAYGAIN_TRACK_GAIN":
|
||||
metadata.ReplayGainTrackGain = value
|
||||
case "REPLAYGAIN_TRACK_PEAK":
|
||||
metadata.ReplayGainTrackPeak = value
|
||||
case "REPLAYGAIN_ALBUM_GAIN":
|
||||
metadata.ReplayGainAlbumGain = value
|
||||
case "REPLAYGAIN_ALBUM_PEAK":
|
||||
metadata.ReplayGainAlbumPeak = value
|
||||
}
|
||||
}
|
||||
|
||||
if len(artistValues) > 0 {
|
||||
metadata.Artist = joinVorbisCommentValues(artistValues)
|
||||
}
|
||||
if len(albumArtistValues) > 0 {
|
||||
metadata.AlbumArtist = joinVorbisCommentValues(albumArtistValues)
|
||||
}
|
||||
}
|
||||
|
||||
func GetOggQuality(filePath string) (*OggQuality, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
quality := &OggQuality{}
|
||||
|
||||
packets, err := collectOggPackets(file, 5, 10)
|
||||
if err != nil && len(packets) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
streamType := detectOggStreamType(packets)
|
||||
if streamType == oggStreamUnknown {
|
||||
if strings.HasSuffix(strings.ToLower(filePath), ".opus") {
|
||||
streamType = oggStreamOpus
|
||||
} else {
|
||||
streamType = oggStreamVorbis
|
||||
}
|
||||
}
|
||||
|
||||
isOpus := streamType == oggStreamOpus
|
||||
var preSkip int
|
||||
|
||||
if isOpus {
|
||||
for _, pkt := range packets {
|
||||
if len(pkt) >= 19 && string(pkt[0:8]) == "OpusHead" {
|
||||
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
|
||||
if quality.SampleRate == 0 {
|
||||
quality.SampleRate = 48000
|
||||
}
|
||||
preSkip = int(binary.LittleEndian.Uint16(pkt[10:12]))
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, pkt := range packets {
|
||||
if len(pkt) > 29 && pkt[0] == 0x01 && string(pkt[1:7]) == "vorbis" {
|
||||
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
return quality, nil
|
||||
}
|
||||
fileSize := stat.Size()
|
||||
|
||||
granule := readLastOggGranulePosition(file, fileSize)
|
||||
if granule > 0 {
|
||||
if isOpus {
|
||||
totalSamples := granule - int64(preSkip)
|
||||
if totalSamples > 0 {
|
||||
durationSec := float64(totalSamples) / 48000.0
|
||||
if durationSec > 0 {
|
||||
quality.Duration = int(math.Round(durationSec))
|
||||
quality.Bitrate = int(float64(fileSize*8) / durationSec)
|
||||
}
|
||||
}
|
||||
} else if quality.SampleRate > 0 {
|
||||
durationSec := float64(granule) / float64(quality.SampleRate)
|
||||
if durationSec > 0 {
|
||||
quality.Duration = int(math.Round(durationSec))
|
||||
quality.Bitrate = int(float64(fileSize*8) / durationSec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if quality.Bitrate <= 0 && quality.Duration > 0 {
|
||||
quality.Bitrate = int(fileSize * 8 / int64(quality.Duration))
|
||||
}
|
||||
if quality.Duration > 24*60*60 {
|
||||
quality.Duration = 0
|
||||
quality.Bitrate = 0
|
||||
}
|
||||
if quality.Bitrate > 0 && quality.Bitrate < 8000 {
|
||||
quality.Bitrate = 0
|
||||
}
|
||||
|
||||
return quality, nil
|
||||
}
|
||||
|
||||
func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
|
||||
searchSize := int64(65536)
|
||||
if searchSize > fileSize {
|
||||
searchSize = fileSize
|
||||
}
|
||||
|
||||
buf := make([]byte, searchSize)
|
||||
offset := fileSize - searchSize
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
n, err := file.ReadAt(buf, offset)
|
||||
if err != nil && n == 0 {
|
||||
return 0
|
||||
}
|
||||
buf = buf[:n]
|
||||
|
||||
for i := n - 4; i >= 0; i-- {
|
||||
if buf[i] != 'O' || buf[i+1] != 'g' || buf[i+2] != 'g' || buf[i+3] != 'S' {
|
||||
continue
|
||||
}
|
||||
if i+27 > n {
|
||||
continue
|
||||
}
|
||||
version := buf[i+4]
|
||||
headerType := buf[i+5]
|
||||
if version != 0 || headerType > 0x07 {
|
||||
continue
|
||||
}
|
||||
segmentCount := int(buf[i+26])
|
||||
headerLen := 27 + segmentCount
|
||||
if i+headerLen > n {
|
||||
continue
|
||||
}
|
||||
payloadLen := 0
|
||||
for s := 0; s < segmentCount; s++ {
|
||||
payloadLen += int(buf[i+27+s])
|
||||
}
|
||||
if i+headerLen+payloadLen > n {
|
||||
continue
|
||||
}
|
||||
return int64(binary.LittleEndian.Uint64(buf[i+6 : i+14]))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
var id3v1Genres = []string{
|
||||
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge",
|
||||
"Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B",
|
||||
@@ -1250,442 +801,3 @@ var id3v1Genres = []string{
|
||||
"Contemporary Christian", "Christian Rock", "Merengue", "Salsa",
|
||||
"Thrash Metal", "Anime", "J-Pop", "Synthpop",
|
||||
}
|
||||
|
||||
func extractMP3CoverArt(filePath string) ([]byte, string, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
header := make([]byte, 10)
|
||||
if _, err := io.ReadFull(file, header); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
if string(header[0:3]) != "ID3" {
|
||||
return nil, "", fmt.Errorf("no ID3v2 header")
|
||||
}
|
||||
|
||||
majorVersion := header[3]
|
||||
size := int(header[6])<<21 | int(header[7])<<14 | int(header[8])<<7 | int(header[9])
|
||||
|
||||
tagData := make([]byte, size)
|
||||
if _, err := io.ReadFull(file, tagData); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
pos := 0
|
||||
var frameIDLen, headerLen int
|
||||
if majorVersion == 2 {
|
||||
frameIDLen = 3
|
||||
headerLen = 6
|
||||
} else {
|
||||
frameIDLen = 4
|
||||
headerLen = 10
|
||||
}
|
||||
|
||||
for pos+headerLen < len(tagData) {
|
||||
frameID := string(tagData[pos : pos+frameIDLen])
|
||||
if frameID[0] == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
var frameSize int
|
||||
switch majorVersion {
|
||||
case 2:
|
||||
frameSize = int(tagData[pos+3])<<16 | int(tagData[pos+4])<<8 | int(tagData[pos+5])
|
||||
case 4:
|
||||
frameSize = int(tagData[pos+4])<<21 | int(tagData[pos+5])<<14 | int(tagData[pos+6])<<7 | int(tagData[pos+7])
|
||||
default:
|
||||
frameSize = int(tagData[pos+4])<<24 | int(tagData[pos+5])<<16 | int(tagData[pos+6])<<8 | int(tagData[pos+7])
|
||||
}
|
||||
|
||||
if frameSize <= 0 || pos+headerLen+frameSize > len(tagData) {
|
||||
break
|
||||
}
|
||||
|
||||
if (frameIDLen == 4 && frameID == "APIC") || (frameIDLen == 3 && frameID == "PIC") {
|
||||
frameData := tagData[pos+headerLen : pos+headerLen+frameSize]
|
||||
imageData, mimeType := parseAPICFrame(frameData, majorVersion)
|
||||
if len(imageData) > 0 {
|
||||
return imageData, mimeType, nil
|
||||
}
|
||||
}
|
||||
|
||||
pos += headerLen + frameSize
|
||||
}
|
||||
|
||||
return nil, "", fmt.Errorf("no cover art found")
|
||||
}
|
||||
|
||||
func parseAPICFrame(data []byte, version byte) ([]byte, string) {
|
||||
if len(data) < 4 {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
pos := 0
|
||||
encoding := data[pos]
|
||||
pos++
|
||||
|
||||
var mimeType string
|
||||
if version == 2 {
|
||||
if pos+3 > len(data) {
|
||||
return nil, ""
|
||||
}
|
||||
format := string(data[pos : pos+3])
|
||||
pos += 3
|
||||
switch format {
|
||||
case "JPG":
|
||||
mimeType = "image/jpeg"
|
||||
case "PNG":
|
||||
mimeType = "image/png"
|
||||
default:
|
||||
mimeType = "image/jpeg"
|
||||
}
|
||||
} else {
|
||||
end := pos
|
||||
for end < len(data) && data[end] != 0 {
|
||||
end++
|
||||
}
|
||||
mimeType = string(data[pos:end])
|
||||
pos = end + 1
|
||||
}
|
||||
|
||||
if pos >= len(data) {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
pos++
|
||||
|
||||
if encoding == 0 || encoding == 3 {
|
||||
for pos < len(data) && data[pos] != 0 {
|
||||
pos++
|
||||
}
|
||||
pos++
|
||||
} else {
|
||||
for pos+1 < len(data) {
|
||||
if data[pos] == 0 && data[pos+1] == 0 {
|
||||
pos += 2
|
||||
break
|
||||
}
|
||||
pos++
|
||||
}
|
||||
}
|
||||
|
||||
if pos >= len(data) {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
return data[pos:], mimeType
|
||||
}
|
||||
|
||||
func extractOggCoverArt(filePath string) ([]byte, string, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
packets, err := collectOggPackets(file, 30, 80)
|
||||
if err != nil && len(packets) == 0 {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
streamType := detectOggStreamType(packets)
|
||||
for _, pkt := range packets {
|
||||
var comments []byte
|
||||
if streamType == oggStreamOpus {
|
||||
if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" {
|
||||
comments = pkt[8:]
|
||||
}
|
||||
} else {
|
||||
if len(pkt) > 7 && pkt[0] == 0x03 && string(pkt[1:7]) == "vorbis" {
|
||||
comments = pkt[7:]
|
||||
}
|
||||
}
|
||||
if len(comments) == 0 && streamType == oggStreamUnknown {
|
||||
if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" {
|
||||
comments = pkt[8:]
|
||||
} else if len(pkt) > 7 && pkt[0] == 0x03 && string(pkt[1:7]) == "vorbis" {
|
||||
comments = pkt[7:]
|
||||
}
|
||||
}
|
||||
|
||||
if len(comments) > 0 {
|
||||
imageData, mimeType := extractPictureFromVorbisComments(comments)
|
||||
if len(imageData) > 0 {
|
||||
return imageData, mimeType, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, "", fmt.Errorf("no cover art found")
|
||||
}
|
||||
|
||||
func extractPictureFromVorbisComments(data []byte) ([]byte, string) {
|
||||
if len(data) < 8 {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
reader := bytes.NewReader(data)
|
||||
|
||||
var vendorLen uint32
|
||||
if err := binary.Read(reader, binary.LittleEndian, &vendorLen); err != nil {
|
||||
return nil, ""
|
||||
}
|
||||
if vendorLen > uint32(len(data)-4) {
|
||||
return nil, ""
|
||||
}
|
||||
reader.Seek(int64(vendorLen), io.SeekCurrent)
|
||||
|
||||
var commentCount uint32
|
||||
if err := binary.Read(reader, binary.LittleEndian, &commentCount); err != nil {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
for i := uint32(0); i < commentCount && i < 100; i++ {
|
||||
var commentLen uint32
|
||||
if err := binary.Read(reader, binary.LittleEndian, &commentLen); err != nil {
|
||||
break
|
||||
}
|
||||
if commentLen > 10000000 {
|
||||
break
|
||||
}
|
||||
|
||||
comment := make([]byte, commentLen)
|
||||
if _, err := reader.Read(comment); err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
key := "METADATA_BLOCK_PICTURE="
|
||||
if len(comment) > len(key) && strings.ToUpper(string(comment[:len(key)])) == key {
|
||||
b64Data := comment[len(key):]
|
||||
decoded := make([]byte, base64StdDecodeLen(len(b64Data)))
|
||||
n, err := base64StdDecode(decoded, b64Data)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
decoded = decoded[:n]
|
||||
|
||||
imageData, mimeType := parseFLACPictureBlock(decoded)
|
||||
if len(imageData) > 0 {
|
||||
return imageData, mimeType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
func parseFLACPictureBlock(data []byte) ([]byte, string) {
|
||||
if len(data) < 32 {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
reader := bytes.NewReader(data)
|
||||
|
||||
var pictureType uint32
|
||||
binary.Read(reader, binary.BigEndian, &pictureType)
|
||||
|
||||
var mimeLen uint32
|
||||
binary.Read(reader, binary.BigEndian, &mimeLen)
|
||||
if mimeLen > 256 {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
mimeBytes := make([]byte, mimeLen)
|
||||
reader.Read(mimeBytes)
|
||||
mimeType := string(mimeBytes)
|
||||
|
||||
var descLen uint32
|
||||
binary.Read(reader, binary.BigEndian, &descLen)
|
||||
if descLen > 10000 {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
reader.Seek(int64(descLen), io.SeekCurrent)
|
||||
|
||||
reader.Seek(16, io.SeekCurrent)
|
||||
|
||||
var dataLen uint32
|
||||
binary.Read(reader, binary.BigEndian, &dataLen)
|
||||
if dataLen > 10000000 {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
imageData := make([]byte, dataLen)
|
||||
reader.Read(imageData)
|
||||
|
||||
return imageData, mimeType
|
||||
}
|
||||
|
||||
func base64StdDecodeLen(n int) int {
|
||||
return n * 6 / 8
|
||||
}
|
||||
|
||||
func base64StdDecode(dst, src []byte) (int, error) {
|
||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
|
||||
decodeMap := make([]byte, 256)
|
||||
for i := range decodeMap {
|
||||
decodeMap[i] = 0xFF
|
||||
}
|
||||
for i := 0; i < len(alphabet); i++ {
|
||||
decodeMap[alphabet[i]] = byte(i)
|
||||
}
|
||||
|
||||
si, di := 0, 0
|
||||
for si < len(src) {
|
||||
for si < len(src) && (src[si] == '\n' || src[si] == '\r' || src[si] == ' ' || src[si] == '\t') {
|
||||
si++
|
||||
}
|
||||
if si >= len(src) {
|
||||
break
|
||||
}
|
||||
|
||||
var vals [4]byte
|
||||
var valCount int
|
||||
for valCount < 4 && si < len(src) {
|
||||
c := src[si]
|
||||
si++
|
||||
if c == '=' {
|
||||
vals[valCount] = 0
|
||||
valCount++
|
||||
} else if c == '\n' || c == '\r' || c == ' ' || c == '\t' {
|
||||
continue
|
||||
} else if decodeMap[c] != 0xFF {
|
||||
vals[valCount] = decodeMap[c]
|
||||
valCount++
|
||||
}
|
||||
}
|
||||
|
||||
if valCount < 2 {
|
||||
break
|
||||
}
|
||||
|
||||
if di < len(dst) {
|
||||
dst[di] = vals[0]<<2 | vals[1]>>4
|
||||
di++
|
||||
}
|
||||
if valCount >= 3 && di < len(dst) {
|
||||
dst[di] = vals[1]<<4 | vals[2]>>2
|
||||
di++
|
||||
}
|
||||
if valCount >= 4 && di < len(dst) {
|
||||
dst[di] = vals[2]<<6 | vals[3]
|
||||
di++
|
||||
}
|
||||
}
|
||||
|
||||
return di, nil
|
||||
}
|
||||
|
||||
func extractAnyCoverArt(filePath string) ([]byte, string, error) {
|
||||
return extractAnyCoverArtWithHint(filePath, "")
|
||||
}
|
||||
|
||||
func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, string, error) {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
if ext == "" {
|
||||
ext = strings.ToLower(filepath.Ext(displayNameHint))
|
||||
}
|
||||
|
||||
switch ext {
|
||||
case ".flac":
|
||||
data, err := ExtractCoverArt(filePath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
mimeType := "image/jpeg"
|
||||
if len(data) > 8 && string(data[1:4]) == "PNG" {
|
||||
mimeType = "image/png"
|
||||
}
|
||||
return data, mimeType, nil
|
||||
|
||||
case ".mp3":
|
||||
return extractMP3CoverArt(filePath)
|
||||
|
||||
case ".opus", ".ogg":
|
||||
return extractOggCoverArt(filePath)
|
||||
|
||||
case ".m4a":
|
||||
data, err := extractCoverFromM4A(filePath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
mimeType := "image/jpeg"
|
||||
if len(data) >= 8 &&
|
||||
data[0] == 0x89 &&
|
||||
data[1] == 0x50 &&
|
||||
data[2] == 0x4E &&
|
||||
data[3] == 0x47 {
|
||||
mimeType = "image/png"
|
||||
}
|
||||
return data, mimeType, nil
|
||||
|
||||
case ".wav", ".aiff", ".aif", ".aifc":
|
||||
return extractWAVAIFFCover(filePath)
|
||||
|
||||
default:
|
||||
return nil, "", fmt.Errorf("unsupported format: %s", ext)
|
||||
}
|
||||
}
|
||||
|
||||
func SaveCoverToCache(filePath, cacheDir string) (string, error) {
|
||||
return SaveCoverToCacheWithHintAndKey(filePath, "", cacheDir, "")
|
||||
}
|
||||
|
||||
func SaveCoverToCacheWithHint(filePath, displayNameHint, cacheDir string) (string, error) {
|
||||
return SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, "")
|
||||
}
|
||||
|
||||
func resolveLibraryCoverCacheKey(filePath, explicitKey string) string {
|
||||
explicitKey = strings.TrimSpace(explicitKey)
|
||||
if explicitKey != "" {
|
||||
return explicitKey
|
||||
}
|
||||
|
||||
cacheKey := filePath
|
||||
if stat, err := os.Stat(filePath); err == nil {
|
||||
cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano())
|
||||
}
|
||||
return cacheKey
|
||||
}
|
||||
|
||||
func SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, coverCacheKey string) (string, error) {
|
||||
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
|
||||
hash := hashString(cacheKey)
|
||||
|
||||
jpgPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.jpg", hash))
|
||||
pngPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.png", hash))
|
||||
|
||||
if _, err := os.Stat(jpgPath); err == nil {
|
||||
return jpgPath, nil
|
||||
}
|
||||
if _, err := os.Stat(pngPath); err == nil {
|
||||
return pngPath, nil
|
||||
}
|
||||
|
||||
imageData, mimeType, err := extractAnyCoverArtWithHint(filePath, displayNameHint)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(cacheDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create cache dir: %w", err)
|
||||
}
|
||||
|
||||
var cachePath string
|
||||
if strings.Contains(mimeType, "png") {
|
||||
cachePath = pngPath
|
||||
} else {
|
||||
cachePath = jpgPath
|
||||
}
|
||||
|
||||
if err := os.WriteFile(cachePath, imageData, 0644); err != nil {
|
||||
return "", fmt.Errorf("failed to write cover: %w", err)
|
||||
}
|
||||
|
||||
return cachePath, nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -85,9 +85,6 @@ func TestAudioMetadataID3ParsingBranches(t *testing.T) {
|
||||
if n, total := parseIndexPair(" 8 / 10 "); n != 8 || total != 10 {
|
||||
t.Fatalf("parseIndexPair = %d/%d", n, total)
|
||||
}
|
||||
if got := parseTrackNumber("9/11"); got != 9 {
|
||||
t.Fatalf("parseTrackNumber = %d", got)
|
||||
}
|
||||
if got := removeUnsync([]byte{0xff, 0x00, 0xe0}); !bytes.Equal(got, []byte{0xff, 0xe0}) {
|
||||
t.Fatalf("removeUnsync = %#v", got)
|
||||
}
|
||||
@@ -164,12 +161,6 @@ func TestAudioMetadataCoverAndQualityHelpers(t *testing.T) {
|
||||
if commentMIME != "image/png" || !bytes.Equal(commentImage, png) {
|
||||
t.Fatalf("vorbis picture = %s/%v", commentMIME, commentImage)
|
||||
}
|
||||
decoded := make([]byte, base64StdDecodeLen(len("SGV sbG8="))+4)
|
||||
n, err := base64StdDecode(decoded, []byte("SGV sbG8="))
|
||||
if err != nil || strings.TrimRight(string(decoded[:n]), "\x00") != "Hello" {
|
||||
t.Fatalf("base64 decode = %q/%v", decoded[:n], err)
|
||||
}
|
||||
|
||||
if detectOggStreamType([][]byte{[]byte("OpusHeadxxxx")}) != oggStreamOpus {
|
||||
t.Fatal("expected opus stream")
|
||||
}
|
||||
@@ -430,9 +421,6 @@ func TestOggMetadataQualityAndCoverHelpers(t *testing.T) {
|
||||
if image, mime, err := extractAnyCoverArtWithHint(coverPath, "cover.opus"); err != nil || mime != "image/png" || len(image) == 0 {
|
||||
t.Fatalf("extractAnyCoverArtWithHint = %s/%#v/%v", mime, image, err)
|
||||
}
|
||||
if image, mime, err := extractAnyCoverArt(coverPath); err != nil || mime != "image/png" || len(image) == 0 {
|
||||
t.Fatalf("extractAnyCoverArt = %s/%#v/%v", mime, image, err)
|
||||
}
|
||||
extractedCoverPath := filepath.Join(dir, "extracted.png")
|
||||
if err := ExtractCoverToFile(coverPath, extractedCoverPath); err != nil {
|
||||
t.Fatalf("ExtractCoverToFile = %v", err)
|
||||
@@ -444,17 +432,6 @@ func TestOggMetadataQualityAndCoverHelpers(t *testing.T) {
|
||||
if err != nil || cachePath == "" {
|
||||
t.Fatalf("SaveCoverToCacheWithHintAndKey = %q/%v", cachePath, err)
|
||||
}
|
||||
cacheDir := filepath.Join(dir, "cache")
|
||||
if path, err := SaveCoverToCache(coverPath, cacheDir); err != nil || !strings.HasSuffix(path, ".png") {
|
||||
t.Fatalf("SaveCoverToCache = %q/%v", path, err)
|
||||
}
|
||||
if path, err := SaveCoverToCacheWithHint(coverPath, "cover.opus", cacheDir); err != nil || path == "" {
|
||||
t.Fatalf("SaveCoverToCacheWithHint = %q/%v", path, err)
|
||||
}
|
||||
hitPath, err := SaveCoverToCache(coverPath, cacheDir)
|
||||
if err != nil || hitPath == "" {
|
||||
t.Fatalf("SaveCoverToCache cache hit = %q/%v", hitPath, err)
|
||||
}
|
||||
if _, err := SaveCoverToCacheWithHintAndKey(filepath.Join(dir, "missing.opus"), "missing.opus", dir, "missing"); err == nil {
|
||||
t.Fatal("expected missing cover cache error")
|
||||
}
|
||||
@@ -515,3 +492,16 @@ func buildTestFLACPictureBlock(image []byte, mime string) []byte {
|
||||
picture.Write(image)
|
||||
return picture.Bytes()
|
||||
}
|
||||
|
||||
func TestR128ToReplayGainDb(t *testing.T) {
|
||||
// -1280/256 = -5 dB on the R128 (-23 LUFS) scale -> 0 dB ReplayGain (-18).
|
||||
if db, ok := r128ToReplayGainDb("-1280"); !ok || db != "0.00 dB" {
|
||||
t.Fatalf("got %q ok=%v", db, ok)
|
||||
}
|
||||
if db, ok := r128ToReplayGainDb(" -2944 "); !ok || db != "-6.50 dB" {
|
||||
t.Fatalf("got %q ok=%v", db, ok)
|
||||
}
|
||||
if _, ok := r128ToReplayGainDb("abc"); ok {
|
||||
t.Fatal("expected failure for non-numeric input")
|
||||
}
|
||||
}
|
||||
|
||||
+106
-106
@@ -20,23 +20,25 @@ type cancelEntry struct {
|
||||
refs int
|
||||
}
|
||||
|
||||
var (
|
||||
cancelMu sync.Mutex
|
||||
cancelMap = make(map[string]*cancelEntry)
|
||||
type cancelRegistry struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]*cancelEntry
|
||||
}
|
||||
|
||||
extensionRequestCancelMu sync.Mutex
|
||||
extensionRequestCancelMap = make(map[string]*cancelEntry)
|
||||
var (
|
||||
downloadCancels = &cancelRegistry{entries: make(map[string]*cancelEntry)}
|
||||
extensionRequestCancels = &cancelRegistry{entries: make(map[string]*cancelEntry)}
|
||||
)
|
||||
|
||||
func initDownloadCancel(itemID string) context.Context {
|
||||
if itemID == "" {
|
||||
func (r *cancelRegistry) init(id string) context.Context {
|
||||
if id == "" {
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
cancelMu.Lock()
|
||||
defer cancelMu.Unlock()
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if entry, ok := cancelMap[itemID]; ok {
|
||||
if entry, ok := r.entries[id]; ok {
|
||||
if entry.ctx == nil {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
entry.ctx = ctx
|
||||
@@ -50,7 +52,7 @@ func initDownloadCancel(itemID string) context.Context {
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancelMap[itemID] = &cancelEntry{
|
||||
r.entries[id] = &cancelEntry{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
canceled: false,
|
||||
@@ -59,124 +61,122 @@ func initDownloadCancel(itemID string) context.Context {
|
||||
return ctx
|
||||
}
|
||||
|
||||
func (r *cancelRegistry) context(id string) context.Context {
|
||||
if id == "" {
|
||||
return context.Background()
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if entry, ok := r.entries[id]; ok && entry.ctx != nil {
|
||||
return entry.ctx
|
||||
}
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
func (r *cancelRegistry) requestCancel(id string) {
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
if entry, ok := r.entries[id]; ok {
|
||||
entry.canceled = true
|
||||
if entry.cancel != nil {
|
||||
entry.cancel()
|
||||
}
|
||||
} else {
|
||||
r.entries[id] = &cancelEntry{canceled: true}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *cancelRegistry) isCancelled(id string) bool {
|
||||
if id == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
entry, ok := r.entries[id]
|
||||
canceled := ok && entry.canceled
|
||||
r.mu.Unlock()
|
||||
return canceled
|
||||
}
|
||||
|
||||
// resetIfIdle removes a cancellation entry that has no active work attached
|
||||
// (refs <= 0). Such entries exist to catch an item that is just about to
|
||||
// start, but if the item never starts the flag lingers and the next explicit
|
||||
// retry would consume it and abort immediately.
|
||||
func (r *cancelRegistry) resetIfIdle(id string) {
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
if entry, ok := r.entries[id]; ok && entry.refs <= 0 {
|
||||
delete(r.entries, id)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *cancelRegistry) release(id string) {
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
if entry, ok := r.entries[id]; ok {
|
||||
entry.refs--
|
||||
if entry.refs <= 0 {
|
||||
delete(r.entries, id)
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func initDownloadCancel(itemID string) context.Context {
|
||||
return downloadCancels.init(itemID)
|
||||
}
|
||||
|
||||
func downloadCancelContext(itemID string) context.Context {
|
||||
return downloadCancels.context(itemID)
|
||||
}
|
||||
|
||||
func cancelDownload(itemID string) {
|
||||
if itemID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
cancelMu.Lock()
|
||||
entry, ok := cancelMap[itemID]
|
||||
if ok {
|
||||
entry.canceled = true
|
||||
if entry.cancel != nil {
|
||||
entry.cancel()
|
||||
}
|
||||
} else {
|
||||
cancelMap[itemID] = &cancelEntry{canceled: true}
|
||||
}
|
||||
cancelMu.Unlock()
|
||||
|
||||
downloadCancels.requestCancel(itemID)
|
||||
RemoveItemProgress(itemID)
|
||||
}
|
||||
|
||||
func isDownloadCancelled(itemID string) bool {
|
||||
if itemID == "" {
|
||||
return false
|
||||
}
|
||||
return downloadCancels.isCancelled(itemID)
|
||||
}
|
||||
|
||||
cancelMu.Lock()
|
||||
entry, ok := cancelMap[itemID]
|
||||
canceled := ok && entry.canceled
|
||||
cancelMu.Unlock()
|
||||
return canceled
|
||||
func resetDownloadCancel(itemID string) {
|
||||
downloadCancels.resetIfIdle(itemID)
|
||||
}
|
||||
|
||||
func clearDownloadCancel(itemID string) {
|
||||
if itemID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
cancelMu.Lock()
|
||||
if entry, ok := cancelMap[itemID]; ok {
|
||||
entry.refs--
|
||||
if entry.refs <= 0 {
|
||||
delete(cancelMap, itemID)
|
||||
}
|
||||
}
|
||||
cancelMu.Unlock()
|
||||
downloadCancels.release(itemID)
|
||||
}
|
||||
|
||||
func initExtensionRequestCancel(requestID string) context.Context {
|
||||
if requestID == "" {
|
||||
return context.Background()
|
||||
}
|
||||
return extensionRequestCancels.init(requestID)
|
||||
}
|
||||
|
||||
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 {
|
||||
return extensionRequestCancels.context(requestID)
|
||||
}
|
||||
|
||||
func cancelExtensionRequest(requestID string) {
|
||||
if requestID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
extensionRequestCancelMu.Lock()
|
||||
if entry, ok := extensionRequestCancelMap[requestID]; ok {
|
||||
entry.canceled = true
|
||||
if entry.cancel != nil {
|
||||
entry.cancel()
|
||||
}
|
||||
} else {
|
||||
extensionRequestCancelMap[requestID] = &cancelEntry{canceled: true}
|
||||
}
|
||||
extensionRequestCancelMu.Unlock()
|
||||
extensionRequestCancels.requestCancel(requestID)
|
||||
}
|
||||
|
||||
func isExtensionRequestCancelled(requestID string) bool {
|
||||
if requestID == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
extensionRequestCancelMu.Lock()
|
||||
entry, ok := extensionRequestCancelMap[requestID]
|
||||
canceled := ok && entry.canceled
|
||||
extensionRequestCancelMu.Unlock()
|
||||
return canceled
|
||||
return extensionRequestCancels.isCancelled(requestID)
|
||||
}
|
||||
|
||||
func clearExtensionRequestCancel(requestID string) {
|
||||
if requestID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
extensionRequestCancelMu.Lock()
|
||||
if entry, ok := extensionRequestCancelMap[requestID]; ok {
|
||||
entry.refs--
|
||||
if entry.refs <= 0 {
|
||||
delete(extensionRequestCancelMap, requestID)
|
||||
}
|
||||
}
|
||||
extensionRequestCancelMu.Unlock()
|
||||
extensionRequestCancels.release(requestID)
|
||||
}
|
||||
|
||||
+246
-40
@@ -1,11 +1,17 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -14,8 +20,11 @@ const (
|
||||
spotifySizeMax = "ab67616d000082c1"
|
||||
)
|
||||
|
||||
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
|
||||
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
||||
// Square CDN covers using this path shape may return an image whose decoded
|
||||
// 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$`)
|
||||
|
||||
@@ -40,18 +49,237 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||
GoLog("[Cover] Upgraded 300x300 → 640x640")
|
||||
}
|
||||
|
||||
if maxQuality {
|
||||
maxURL := upgradeToMaxQuality(downloadURL)
|
||||
if maxURL != downloadURL {
|
||||
downloadURL = maxURL
|
||||
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
|
||||
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
|
||||
}
|
||||
if !maxQuality {
|
||||
GoLog("[Cover] Final URL: %s", downloadURL)
|
||||
data, err := fetchCoverCached(downloadURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append([]byte(nil), data...), nil
|
||||
}
|
||||
|
||||
GoLog("[Cover] Final URL: %s", downloadURL)
|
||||
candidates := maxQualityCoverCandidateURLs(downloadURL)
|
||||
data, selectedURL, width, height, err := fetchBestCoverCandidate(candidates)
|
||||
if err != nil {
|
||||
// A CDN can reject an upgraded size while the provider-supplied URL is
|
||||
// still valid. Preserve that URL as the final fallback.
|
||||
if len(candidates) == 1 && candidates[0] == downloadURL {
|
||||
return nil, err
|
||||
}
|
||||
data, err = fetchCoverCached(downloadURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
selectedURL = downloadURL
|
||||
width, height = coverDimensions(data)
|
||||
}
|
||||
GoLog("[Cover] Selected URL: %s (%dx%d, %d KB)", selectedURL, width, height, len(data)/1024)
|
||||
// Cached bytes are shared across goroutines and must never be mutated;
|
||||
// hand callers their own copy.
|
||||
return append([]byte(nil), data...), nil
|
||||
}
|
||||
|
||||
type fetchedCoverCandidate struct {
|
||||
url string
|
||||
data []byte
|
||||
width, height int
|
||||
err error
|
||||
}
|
||||
|
||||
func maxQualityCoverCandidateURLs(coverURL string) []string {
|
||||
upgraded := upgradeToMaxQuality(coverURL)
|
||||
candidates := []string{upgraded}
|
||||
// This is deliberately based on the URL capability rather than a provider
|
||||
// or extension ID. Any metadata source returning the same square-cover URL
|
||||
// shape receives the same verified candidate selection.
|
||||
if squareCoverSizeRegex.MatchString(coverURL) {
|
||||
candidate1500 := squareCoverSizeRegex.ReplaceAllString(
|
||||
coverURL,
|
||||
"/1500x1500-000000-80-0-0.jpg",
|
||||
)
|
||||
if candidate1500 != upgraded {
|
||||
candidates = append(candidates, candidate1500)
|
||||
}
|
||||
}
|
||||
return uniqueNonEmptyStrings(candidates)
|
||||
}
|
||||
|
||||
func uniqueNonEmptyStrings(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func fetchBestCoverCandidate(urls []string) ([]byte, string, int, int, error) {
|
||||
if len(urls) == 0 {
|
||||
return nil, "", 0, 0, fmt.Errorf("no cover candidates available")
|
||||
}
|
||||
results := make(chan fetchedCoverCandidate, len(urls))
|
||||
for _, candidateURL := range urls {
|
||||
go func(url string) {
|
||||
data, err := fetchCoverCached(url)
|
||||
width, height := coverDimensions(data)
|
||||
results <- fetchedCoverCandidate{
|
||||
url: url, data: data, width: width, height: height, err: err,
|
||||
}
|
||||
}(candidateURL)
|
||||
}
|
||||
|
||||
var best *fetchedCoverCandidate
|
||||
var firstErr error
|
||||
for range urls {
|
||||
candidate := <-results
|
||||
if candidate.err != nil || len(candidate.data) == 0 {
|
||||
if firstErr == nil {
|
||||
firstErr = candidate.err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if best == nil || coverCandidateBetter(candidate, *best) {
|
||||
copy := candidate
|
||||
best = ©
|
||||
}
|
||||
}
|
||||
if best == nil {
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("cover candidates returned no image data")
|
||||
}
|
||||
return nil, "", 0, 0, firstErr
|
||||
}
|
||||
return best.data, best.url, best.width, best.height, nil
|
||||
}
|
||||
|
||||
func coverDimensions(data []byte) (int, int) {
|
||||
if len(data) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
config, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil || config.Width <= 0 || config.Height <= 0 {
|
||||
return 0, 0
|
||||
}
|
||||
return config.Width, config.Height
|
||||
}
|
||||
|
||||
func coverCandidateBetter(candidate, current fetchedCoverCandidate) bool {
|
||||
candidatePixels := int64(candidate.width) * int64(candidate.height)
|
||||
currentPixels := int64(current.width) * int64(current.height)
|
||||
if candidatePixels != currentPixels {
|
||||
return candidatePixels > currentPixels
|
||||
}
|
||||
return len(candidate.data) > len(current.data)
|
||||
}
|
||||
|
||||
const (
|
||||
coverCacheMaxBytes = 24 * 1024 * 1024
|
||||
coverCacheTTL = 15 * time.Minute
|
||||
)
|
||||
|
||||
type coverCacheEntry struct {
|
||||
data []byte
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type coverInflightCall struct {
|
||||
wg sync.WaitGroup
|
||||
data []byte
|
||||
err error
|
||||
}
|
||||
|
||||
var (
|
||||
coverMu sync.Mutex
|
||||
coverCache = map[string]*coverCacheEntry{}
|
||||
coverCacheBytes int
|
||||
coverInflight = map[string]*coverInflightCall{}
|
||||
coverFetch = fetchCoverBytes
|
||||
)
|
||||
|
||||
func clearCoverMemoryCache() {
|
||||
coverMu.Lock()
|
||||
coverCache = map[string]*coverCacheEntry{}
|
||||
coverCacheBytes = 0
|
||||
coverMu.Unlock()
|
||||
}
|
||||
|
||||
// fetchCoverCached returns cover bytes for a final URL, collapsing concurrent
|
||||
// requests for the same URL into a single fetch (singleflight) and caching
|
||||
// results in memory for the duration of an album batch. The returned slice is
|
||||
// shared; callers must copy before mutating.
|
||||
func fetchCoverCached(downloadURL string) ([]byte, error) {
|
||||
coverMu.Lock()
|
||||
if e, ok := coverCache[downloadURL]; ok {
|
||||
if time.Now().Before(e.expiresAt) {
|
||||
data := e.data
|
||||
coverMu.Unlock()
|
||||
return data, nil
|
||||
}
|
||||
delete(coverCache, downloadURL)
|
||||
coverCacheBytes -= len(e.data)
|
||||
}
|
||||
if call, ok := coverInflight[downloadURL]; ok {
|
||||
coverMu.Unlock()
|
||||
call.wg.Wait()
|
||||
return call.data, call.err
|
||||
}
|
||||
call := &coverInflightCall{}
|
||||
// Default error so a panicking fetch never strands waiters with a
|
||||
// (nil, nil) "success"; overwritten on normal completion.
|
||||
call.err = fmt.Errorf("cover fetch aborted")
|
||||
call.wg.Add(1)
|
||||
coverInflight[downloadURL] = call
|
||||
coverMu.Unlock()
|
||||
|
||||
defer func() {
|
||||
call.wg.Done()
|
||||
coverMu.Lock()
|
||||
delete(coverInflight, downloadURL)
|
||||
coverMu.Unlock()
|
||||
}()
|
||||
|
||||
data, err := coverFetch(downloadURL)
|
||||
call.data, call.err = data, err
|
||||
if err == nil {
|
||||
coverCachePut(downloadURL, data)
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
func coverCachePut(downloadURL string, data []byte) {
|
||||
if len(data) == 0 || len(data) > coverCacheMaxBytes {
|
||||
return
|
||||
}
|
||||
coverMu.Lock()
|
||||
defer coverMu.Unlock()
|
||||
if e, ok := coverCache[downloadURL]; ok {
|
||||
coverCacheBytes -= len(e.data)
|
||||
}
|
||||
coverCache[downloadURL] = &coverCacheEntry{data: data, expiresAt: time.Now().Add(coverCacheTTL)}
|
||||
coverCacheBytes += len(data)
|
||||
for coverCacheBytes > coverCacheMaxBytes && len(coverCache) > 1 {
|
||||
var oldestKey string
|
||||
var oldest time.Time
|
||||
first := true
|
||||
for k, e := range coverCache {
|
||||
if first || e.expiresAt.Before(oldest) {
|
||||
oldest, oldestKey, first = e.expiresAt, k, false
|
||||
}
|
||||
}
|
||||
coverCacheBytes -= len(coverCache[oldestKey].data)
|
||||
delete(coverCache, oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchCoverBytes(downloadURL string) ([]byte, error) {
|
||||
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
||||
|
||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
||||
@@ -74,16 +302,8 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||
return nil, fmt.Errorf("failed to read cover data: %w", err)
|
||||
}
|
||||
|
||||
sizeKB := len(data) / 1024
|
||||
var resolution string
|
||||
if sizeKB > 200 {
|
||||
resolution = "~2000x2000 (hi-res)"
|
||||
} else if sizeKB > 50 {
|
||||
resolution = "~640x640"
|
||||
} else {
|
||||
resolution = "~300x300"
|
||||
}
|
||||
GoLog("[Cover] Downloaded %d KB (%s)", sizeKB, resolution)
|
||||
width, height := coverDimensions(data)
|
||||
GoLog("[Cover] Downloaded %d KB (%dx%d)", len(data)/1024, width, height)
|
||||
|
||||
return data, nil
|
||||
}
|
||||
@@ -93,8 +313,8 @@ func upgradeToMaxQuality(coverURL string) string {
|
||||
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||
}
|
||||
|
||||
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||
return upgradeDeezerCover(coverURL)
|
||||
if squareCoverSizeRegex.MatchString(coverURL) {
|
||||
return upgradeSquareCover(coverURL)
|
||||
}
|
||||
|
||||
if strings.Contains(coverURL, "resources.tidal.com") {
|
||||
@@ -108,14 +328,14 @@ func upgradeToMaxQuality(coverURL string) string {
|
||||
return coverURL
|
||||
}
|
||||
|
||||
func upgradeDeezerCover(coverURL string) string {
|
||||
if !strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||
func upgradeSquareCover(coverURL string) string {
|
||||
if !squareCoverSizeRegex.MatchString(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 {
|
||||
GoLog("[Cover] Deezer: upgraded to 1800x1800")
|
||||
GoLog("[Cover] Square CDN: probing 1900x1900 and 1500x1500")
|
||||
}
|
||||
return upgraded
|
||||
}
|
||||
@@ -143,17 +363,3 @@ func upgradeQobuzCover(coverURL string) string {
|
||||
}
|
||||
return upgraded
|
||||
}
|
||||
|
||||
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
||||
if imageURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
result := convertSmallToMedium(imageURL)
|
||||
|
||||
if maxQuality {
|
||||
result = upgradeToMaxQuality(result)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"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() {
|
||||
coverMu.Lock()
|
||||
coverCache = map[string]*coverCacheEntry{}
|
||||
coverInflight = map[string]*coverInflightCall{}
|
||||
coverCacheBytes = 0
|
||||
coverMu.Unlock()
|
||||
}
|
||||
|
||||
func TestFetchCoverCachedSingleflight(t *testing.T) {
|
||||
orig := coverFetch
|
||||
defer func() { coverFetch = orig }()
|
||||
resetCoverCache()
|
||||
|
||||
var calls int32
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
coverFetch = func(string) ([]byte, error) {
|
||||
if atomic.AddInt32(&calls, 1) == 1 {
|
||||
close(entered)
|
||||
}
|
||||
<-release
|
||||
return []byte("coverbytes"), nil
|
||||
}
|
||||
|
||||
const url = "https://cdn.example/cover_max.jpg"
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := fetchCoverCached(url); err != nil {
|
||||
t.Errorf("leader fetch error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
<-entered // leader has registered inflight and is blocked in coverFetch
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := fetchCoverCached(url); err != nil {
|
||||
t.Errorf("follower fetch error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
close(release)
|
||||
wg.Wait()
|
||||
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("expected 1 fetch for concurrent requests, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchCoverCachedTTLExpiry(t *testing.T) {
|
||||
orig := coverFetch
|
||||
defer func() { coverFetch = orig }()
|
||||
resetCoverCache()
|
||||
|
||||
var calls int32
|
||||
coverFetch = func(string) ([]byte, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
return []byte("data"), nil
|
||||
}
|
||||
|
||||
const url = "https://cdn.example/ttl.jpg"
|
||||
if _, err := fetchCoverCached(url); err != nil {
|
||||
t.Fatalf("first fetch error: %v", err)
|
||||
}
|
||||
// second call served from cache
|
||||
if _, err := fetchCoverCached(url); err != nil {
|
||||
t.Fatalf("second fetch error: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("expected cache hit, got %d fetches", got)
|
||||
}
|
||||
|
||||
// expire the entry and confirm a refetch
|
||||
coverMu.Lock()
|
||||
coverCache[url].expiresAt = time.Now().Add(-time.Minute)
|
||||
coverMu.Unlock()
|
||||
|
||||
if _, err := fetchCoverCached(url); err != nil {
|
||||
t.Fatalf("third fetch error: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 2 {
|
||||
t.Fatalf("expected refetch after TTL expiry, got %d fetches", got)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,8 @@ function track(id) {
|
||||
genre: "Pop",
|
||||
composer: "Composer",
|
||||
audioQuality: "FLAC 24-bit",
|
||||
audioModes: "DOLBY_ATMOS"
|
||||
audioModes: "DOLBY_ATMOS",
|
||||
explicit: true
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -241,7 +241,7 @@ func searchCollectionCandidates(provider *extensionProviderWrapper, itemType str
|
||||
}
|
||||
|
||||
if filter != "" {
|
||||
tracks, err := provider.CustomSearch(query, map[string]interface{}{
|
||||
tracks, err := provider.CustomSearch(query, map[string]any{
|
||||
"filter": filter,
|
||||
"limit": 10,
|
||||
})
|
||||
@@ -333,7 +333,7 @@ func resolveCollectionShareURL(ext *loadedExtension, itemType string, track *Ext
|
||||
if url := urlFromExternalLinks(track.ExternalLinks, "album"); 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 ""
|
||||
@@ -350,7 +350,7 @@ func resolveCollectionShareURL(ext *loadedExtension, itemType string, track *Ext
|
||||
if url := urlFromExternalLinks(track.ExternalLinks, "artist"); 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 ""
|
||||
@@ -409,7 +409,7 @@ func templateShareURL(ext *loadedExtension, itemType string, id string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
templates, ok := ext.Manifest.Capabilities["shareUrlTemplates"].(map[string]interface{})
|
||||
templates, ok := ext.Manifest.Capabilities["shareUrlTemplates"].(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
@@ -430,13 +430,3 @@ func stripProviderPrefix(id string) string {
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import "testing"
|
||||
func TestCrossExtensionShareUsesAlbumCollectionItems(t *testing.T) {
|
||||
ext := &loadedExtension{
|
||||
Manifest: &ExtensionManifest{
|
||||
Capabilities: map[string]interface{}{
|
||||
"shareUrlTemplates": map[string]interface{}{
|
||||
Capabilities: map[string]any{
|
||||
"shareUrlTemplates": map[string]any{
|
||||
"album": "https://music.apple.com/us/album/{id}",
|
||||
},
|
||||
},
|
||||
@@ -33,8 +33,8 @@ func TestCrossExtensionShareUsesAlbumCollectionItems(t *testing.T) {
|
||||
func TestCrossExtensionShareUsesArtistCollectionItems(t *testing.T) {
|
||||
ext := &loadedExtension{
|
||||
Manifest: &ExtensionManifest{
|
||||
Capabilities: map[string]interface{}{
|
||||
"shareUrlTemplates": map[string]interface{}{
|
||||
Capabilities: map[string]any{
|
||||
"shareUrlTemplates": map[string]any{
|
||||
"artist": "https://music.youtube.com/browse/{id}",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -117,6 +117,135 @@ func TestCueParserEndToEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestFlacWithISRC(t *testing.T, path, isrc string) {
|
||||
t.Helper()
|
||||
le32 := func(v int) []byte {
|
||||
return []byte{byte(v), byte(v >> 8), byte(v >> 16), byte(v >> 24)}
|
||||
}
|
||||
vendor := "test-vendor"
|
||||
comments := [][]byte{
|
||||
[]byte("TITLE=Song"),
|
||||
[]byte("ISRC=" + isrc),
|
||||
}
|
||||
var vorbis []byte
|
||||
vorbis = append(vorbis, le32(len(vendor))...)
|
||||
vorbis = append(vorbis, vendor...)
|
||||
vorbis = append(vorbis, le32(len(comments))...)
|
||||
for _, comment := range comments {
|
||||
vorbis = append(vorbis, le32(len(comment))...)
|
||||
vorbis = append(vorbis, comment...)
|
||||
}
|
||||
|
||||
blockHeader := func(last bool, blockType byte, length int) []byte {
|
||||
first := blockType
|
||||
if last {
|
||||
first |= 0x80
|
||||
}
|
||||
return []byte{first, byte(length >> 16), byte(length >> 8), byte(length)}
|
||||
}
|
||||
|
||||
var data []byte
|
||||
data = append(data, "fLaC"...)
|
||||
data = append(data, blockHeader(false, 0, 34)...) // STREAMINFO
|
||||
data = append(data, make([]byte, 34)...)
|
||||
picture := make([]byte, 4096) // stands in for embedded cover art
|
||||
data = append(data, blockHeader(false, 6, len(picture))...)
|
||||
data = append(data, picture...)
|
||||
data = append(data, blockHeader(true, 4, len(vorbis))...)
|
||||
data = append(data, vorbis...)
|
||||
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func 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) {
|
||||
dir := t.TempDir()
|
||||
trackA := filepath.Join(dir, "a.flac")
|
||||
trackB := filepath.Join(dir, "b.flac")
|
||||
writeTestFlacWithISRC(t, trackA, "USAA00000001")
|
||||
writeTestFlacWithISRC(t, trackB, "USBB00000002")
|
||||
defer InvalidateISRCCache(dir)
|
||||
|
||||
if got := readFlacISRC(trackA); got != "USAA00000001" {
|
||||
t.Fatalf("readFlacISRC = %q", got)
|
||||
}
|
||||
if got := readFlacISRC(filepath.Join(dir, "missing.flac")); got != "" {
|
||||
t.Fatalf("expected empty ISRC for missing file, got %q", got)
|
||||
}
|
||||
|
||||
idx := buildISRCIndex(dir)
|
||||
if path, ok := idx.lookup("usaa00000001"); !ok || path != trackA {
|
||||
t.Fatalf("lookup A = %q/%v", path, ok)
|
||||
}
|
||||
if path, ok := idx.lookup("USBB00000002"); !ok || path != trackB {
|
||||
t.Fatalf("lookup B = %q/%v", path, ok)
|
||||
}
|
||||
|
||||
// Change one file's tag (and mtime); an incremental rebuild must pick up
|
||||
// the change while adopting the untouched file from the cache.
|
||||
writeTestFlacWithISRC(t, trackB, "USBB00000099")
|
||||
future := time.Now().Add(2 * time.Second)
|
||||
if err := os.Chtimes(trackB, future, future); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rebuilt := buildISRCIndex(dir)
|
||||
if _, ok := rebuilt.lookup("USBB00000002"); ok {
|
||||
t.Fatal("expected stale ISRC to disappear after rebuild")
|
||||
}
|
||||
if path, ok := rebuilt.lookup("USBB00000099"); !ok || path != trackB {
|
||||
t.Fatalf("rebuilt lookup = %q/%v", path, ok)
|
||||
}
|
||||
if path, ok := rebuilt.lookup("USAA00000001"); !ok || path != trackA {
|
||||
t.Fatalf("cached entry lost on rebuild: %q/%v", path, ok)
|
||||
}
|
||||
|
||||
// Add() keeps the index fresh so the TTL never forces a rebuild while
|
||||
// downloads are actively maintaining it.
|
||||
rebuilt.buildTime.Store(time.Now().Add(-isrcIndexTTL - time.Minute).UnixNano())
|
||||
if rebuilt.isFresh() {
|
||||
t.Fatal("expected stale index")
|
||||
}
|
||||
rebuilt.Add("USCC00000003", trackA)
|
||||
if !rebuilt.isFresh() {
|
||||
t.Fatal("expected Add to refresh the index timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateIndexAndParallelExistence(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
filePath := filepath.Join(dir, "song.flac")
|
||||
@@ -124,7 +253,8 @@ func TestDuplicateIndexAndParallelExistence(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
idx := &ISRCIndex{index: map[string]string{}, outputDir: dir, buildTime: time.Now()}
|
||||
idx := &ISRCIndex{index: map[string]string{}, outputDir: dir}
|
||||
idx.buildTime.Store(time.Now().UnixNano())
|
||||
idx.Add("usrc17607839", filePath)
|
||||
if got, ok := idx.lookup("USRC17607839"); !ok || got != filePath {
|
||||
t.Fatalf("lookup = %q/%v", got, ok)
|
||||
|
||||
+40
-729
@@ -3,6 +3,7 @@ package gobackend
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -13,13 +14,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
deezerBaseURL = "https://api.deezer.com/2.0"
|
||||
deezerSearchURL = deezerBaseURL + "/search"
|
||||
deezerTrackURL = deezerBaseURL + "/track/%s"
|
||||
deezerAlbumURL = deezerBaseURL + "/album/%s"
|
||||
deezerArtistURL = deezerBaseURL + "/artist/%s"
|
||||
deezerArtistRelatedURL = deezerBaseURL + "/artist/%s/related"
|
||||
deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
|
||||
deezerBaseURL = "https://api.deezer.com/2.0"
|
||||
deezerSearchURL = deezerBaseURL + "/search"
|
||||
deezerTrackURL = deezerBaseURL + "/track/%s"
|
||||
deezerAlbumURL = deezerBaseURL + "/album/%s"
|
||||
deezerArtistURL = deezerBaseURL + "/artist/%s"
|
||||
deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
|
||||
|
||||
deezerCacheTTL = 10 * time.Minute
|
||||
|
||||
@@ -67,240 +67,6 @@ func GetDeezerClient() *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"`
|
||||
Artist deezerArtist `json:"artist"`
|
||||
Album deezerAlbumSimple `json:"album"`
|
||||
Contributors []deezerArtist `json:"contributors"`
|
||||
}
|
||||
|
||||
type deezerArtist struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Picture string `json:"picture"`
|
||||
PictureMedium string `json:"picture_medium"`
|
||||
PictureBig string `json:"picture_big"`
|
||||
PictureXL string `json:"picture_xl"`
|
||||
NbFan int `json:"nb_fan"`
|
||||
}
|
||||
|
||||
type deezerAlbumSimple struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Cover string `json:"cover"`
|
||||
CoverMedium string `json:"cover_medium"`
|
||||
CoverBig string `json:"cover_big"`
|
||||
CoverXL string `json:"cover_xl"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
|
||||
|
||||
@@ -552,442 +318,6 @@ func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResp
|
||||
}, 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,
|
||||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
// Find albums that need track counts
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
return &PlaylistResponsePayload{
|
||||
PlaylistInfo: info,
|
||||
TrackList: tracks,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMetadata, error) {
|
||||
directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc)
|
||||
|
||||
@@ -1120,45 +450,6 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
|
||||
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 {
|
||||
Genre string
|
||||
Label string
|
||||
@@ -1251,12 +542,16 @@ func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc strin
|
||||
return c.GetExtendedMetadataByTrackID(ctx, deezerID)
|
||||
}
|
||||
|
||||
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
||||
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst any) error {
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
delay := deezerRetryDelay * time.Duration(1<<(attempt-1))
|
||||
var apiErr *deezerAPIError
|
||||
if errors.As(lastErr, &apiErr) && apiErr.RetryAfter > 0 {
|
||||
delay = apiErr.RetryAfter
|
||||
}
|
||||
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
|
||||
time.Sleep(delay)
|
||||
}
|
||||
@@ -1267,16 +562,7 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
errStr := err.Error()
|
||||
|
||||
isRetryable := strings.Contains(errStr, "timeout") ||
|
||||
strings.Contains(errStr, "connection reset") ||
|
||||
strings.Contains(errStr, "connection refused") ||
|
||||
strings.Contains(errStr, "EOF") ||
|
||||
strings.Contains(errStr, "status 5") ||
|
||||
strings.Contains(errStr, "status 429")
|
||||
|
||||
if !isRetryable {
|
||||
if !isDeezerRetryableError(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1286,13 +572,38 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
||||
return fmt.Errorf("all %d attempts failed: %w", deezerMaxRetries+1, lastErr)
|
||||
}
|
||||
|
||||
func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
||||
type deezerAPIError struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
RetryAfter time.Duration
|
||||
}
|
||||
|
||||
func (e *deezerAPIError) Error() string {
|
||||
return fmt.Sprintf("deezer API returned status %d: %s", e.StatusCode, e.Body)
|
||||
}
|
||||
|
||||
func isDeezerRetryableError(err error) bool {
|
||||
if isConnectivityFailure(err) || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
return true
|
||||
}
|
||||
var apiErr *deezerAPIError
|
||||
if errors.As(err, &apiErr) {
|
||||
return apiErr.StatusCode == http.StatusTooManyRequests || apiErr.StatusCode >= http.StatusInternalServerError
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
// Without an explicit language Deezer localizes artist/genre names by
|
||||
// the caller's IP geolocation (issue #480: Arabic metadata on an
|
||||
// English device). Follow the app's display language instead.
|
||||
req.Header.Set("Accept-Language", metadataAcceptLanguage())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -1306,7 +617,7 @@ func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst inter
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("deezer API returned status %d: %s", resp.StatusCode, string(body))
|
||||
return &deezerAPIError{StatusCode: resp.StatusCode, Body: string(body), RetryAfter: getRetryAfterDuration(resp)}
|
||||
}
|
||||
|
||||
return json.Unmarshal(body, dst)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
related, err := client.GetRelatedArtists(ctx, "deezer:301", 3)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRelatedArtists: %v", err)
|
||||
}
|
||||
if len(related) != 1 || related[0].ID != "deezer:302" {
|
||||
t.Fatalf("related = %#v", related)
|
||||
}
|
||||
if _, err := client.GetRelatedArtists(ctx, "", 0); err == nil {
|
||||
t.Fatal("expected invalid related artist ID")
|
||||
}
|
||||
|
||||
playlist, err := client.GetPlaylist(ctx, "401")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPlaylist: %v", err)
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
)
|
||||
|
||||
// DNS-over-HTTPS fallback for DNS-level ISP blocking. The OS resolver stays
|
||||
// the primary path; only when it fails with a DNS error (NXDOMAIN, SERVFAIL,
|
||||
// refused, resolver timeout) is the host re-resolved over DoH to hardcoded
|
||||
// resolver IPs and dialed directly. TLS verification still runs against the
|
||||
// original hostname, so a bad answer cannot silently redirect traffic.
|
||||
|
||||
var dohUpstreams = []string{
|
||||
"https://1.1.1.1/dns-query",
|
||||
"https://8.8.8.8/dns-query",
|
||||
}
|
||||
|
||||
// Upstream URLs are literal IPs, so this client never needs DNS itself.
|
||||
var dohClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
|
||||
MaxIdleConnsPerHost: 1,
|
||||
IdleConnTimeout: 60 * time.Second,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
ForceAttemptHTTP2: true,
|
||||
TLSClientConfig: newTLSCompatibilityConfig(false),
|
||||
},
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
const (
|
||||
dohCacheMaxEntries = 256
|
||||
dohCacheMinTTL = time.Minute
|
||||
dohCacheMaxTTL = 30 * time.Minute
|
||||
dohCacheErrorTTL = 30 * time.Second
|
||||
)
|
||||
|
||||
type dohCacheEntry struct {
|
||||
ips []net.IP
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
dohMu sync.Mutex
|
||||
dohCache = map[string]dohCacheEntry{}
|
||||
)
|
||||
|
||||
// dialWithDoHFallback dials addr normally and, when the failure is a DNS
|
||||
// error, retries with DoH-resolved addresses. Non-DNS failures pass through
|
||||
// untouched.
|
||||
func dialWithDoHFallback(ctx context.Context, dialer *net.Dialer, network, addr string) (net.Conn, error) {
|
||||
conn, err := dialer.DialContext(ctx, network, addr)
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
var dnsErr *net.DNSError
|
||||
if !errors.As(err, &dnsErr) {
|
||||
return nil, err
|
||||
}
|
||||
host, port, splitErr := net.SplitHostPort(addr)
|
||||
if splitErr != nil || net.ParseIP(host) != nil {
|
||||
return nil, err
|
||||
}
|
||||
ips, dohErr := dohResolve(ctx, host)
|
||||
if dohErr != nil {
|
||||
// Surface the OS resolver's error, not the fallback's.
|
||||
return nil, err
|
||||
}
|
||||
GoLog("[DoH] OS resolver failed for %s (%v), dialing DoH answer\n", host, err)
|
||||
lastErr := err
|
||||
for _, ip := range ips {
|
||||
conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
||||
if dialErr == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = dialErr
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// dohResolve resolves host over DoH, IPv4 first. Failures are negative-cached
|
||||
// briefly so a burst of dials does not hammer the resolvers.
|
||||
func dohResolve(ctx context.Context, host string) ([]net.IP, error) {
|
||||
if ips, ok := dohCachedIPs(host); ok {
|
||||
if len(ips) == 0 {
|
||||
return nil, fmt.Errorf("doh: cached failure for %s", host)
|
||||
}
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, upstream := range dohUpstreams {
|
||||
ips, ttl, err := dohQuery(ctx, upstream, host, dnsmessage.TypeA)
|
||||
if err == nil && len(ips) == 0 {
|
||||
ips, ttl, err = dohQuery(ctx, upstream, host, dnsmessage.TypeAAAA)
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
ips = filterDialableIPs(ips)
|
||||
if len(ips) == 0 {
|
||||
break
|
||||
}
|
||||
dohCachePut(host, ips, min(max(ttl, dohCacheMinTTL), dohCacheMaxTTL))
|
||||
return ips, nil
|
||||
}
|
||||
dohCachePut(host, nil, dohCacheErrorTTL)
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("doh: no address for %s", host)
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func dohQuery(ctx context.Context, upstream, host string, qtype dnsmessage.Type) ([]net.IP, time.Duration, error) {
|
||||
name, err := dnsmessage.NewName(host + ".")
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("doh: invalid host %q: %w", host, err)
|
||||
}
|
||||
msg := dnsmessage.Message{
|
||||
Header: dnsmessage.Header{RecursionDesired: true},
|
||||
Questions: []dnsmessage.Question{{
|
||||
Name: name,
|
||||
Type: qtype,
|
||||
Class: dnsmessage.ClassINET,
|
||||
}},
|
||||
}
|
||||
packed, err := msg.Pack()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstream, bytes.NewReader(packed))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/dns-message")
|
||||
req.Header.Set("Accept", "application/dns-message")
|
||||
|
||||
resp, err := dohClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, 0, fmt.Errorf("doh: %s answered HTTP %d", upstream, resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var reply dnsmessage.Message
|
||||
if err := reply.Unpack(body); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if reply.RCode != dnsmessage.RCodeSuccess {
|
||||
return nil, 0, fmt.Errorf("doh: rcode %v for %s", reply.RCode, host)
|
||||
}
|
||||
|
||||
var ips []net.IP
|
||||
ttl := dohCacheMaxTTL
|
||||
for _, ans := range reply.Answers {
|
||||
var ip net.IP
|
||||
switch r := ans.Body.(type) {
|
||||
case *dnsmessage.AResource:
|
||||
ip = net.IP(r.A[:])
|
||||
case *dnsmessage.AAAAResource:
|
||||
ip = net.IP(r.AAAA[:])
|
||||
default:
|
||||
continue
|
||||
}
|
||||
ips = append(ips, ip)
|
||||
if t := time.Duration(ans.Header.TTL) * time.Second; t < ttl {
|
||||
ttl = t
|
||||
}
|
||||
}
|
||||
return ips, ttl, nil
|
||||
}
|
||||
|
||||
// filterDialableIPs drops private/loopback/link-local answers unless the user
|
||||
// opted into private-network access — a DoH answer must not bypass the SSRF
|
||||
// guard the OS-resolver path enforces.
|
||||
func filterDialableIPs(ips []net.IP) []net.IP {
|
||||
if IsPrivateNetworkAllowed() {
|
||||
return ips
|
||||
}
|
||||
kept := ips[:0]
|
||||
for _, ip := range ips {
|
||||
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, ip)
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
func dohCachedIPs(host string) ([]net.IP, bool) {
|
||||
dohMu.Lock()
|
||||
defer dohMu.Unlock()
|
||||
e, ok := dohCache[host]
|
||||
if !ok || time.Now().After(e.expiresAt) {
|
||||
return nil, false
|
||||
}
|
||||
return e.ips, true
|
||||
}
|
||||
|
||||
func dohCachePut(host string, ips []net.IP, ttl time.Duration) {
|
||||
dohMu.Lock()
|
||||
defer dohMu.Unlock()
|
||||
if len(dohCache) >= dohCacheMaxEntries {
|
||||
now := time.Now()
|
||||
for k, e := range dohCache {
|
||||
if now.After(e.expiresAt) {
|
||||
delete(dohCache, k)
|
||||
}
|
||||
}
|
||||
for k := range dohCache {
|
||||
if len(dohCache) < dohCacheMaxEntries {
|
||||
break
|
||||
}
|
||||
delete(dohCache, k)
|
||||
}
|
||||
}
|
||||
dohCache[host] = dohCacheEntry{ips: ips, expiresAt: time.Now().Add(ttl)}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// downloadPathLocks serializes writes per final output path so concurrent
|
||||
// downloads that resolve to the same file cannot interleave bytes into one
|
||||
// output or race the staged-promote rename. Keys are normalized case-folded
|
||||
// cleaned paths; entries live for the process lifetime (bounded by the number
|
||||
// of distinct output files in a session).
|
||||
var downloadPathLocks sync.Map
|
||||
|
||||
// lockDownloadOutputPath locks the given final output path and returns the
|
||||
// unlock function. Different paths keep downloading in parallel; a second
|
||||
// download of the same path blocks until the first finishes.
|
||||
func lockDownloadOutputPath(path string) func() {
|
||||
key := strings.ToLower(filepath.Clean(path))
|
||||
value, _ := downloadPathLocks.LoadOrStore(key, &sync.Mutex{})
|
||||
mu := value.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
return mu.Unlock
|
||||
}
|
||||
|
||||
// stagedDownloadPath returns the sibling name downloads are streamed into
|
||||
// before being promoted to the final path with an atomic rename. The suffix
|
||||
// keeps the staged file invisible to extension-based duplicate checks, which
|
||||
// match on the final audio extension.
|
||||
func stagedDownloadPath(finalPath string) string {
|
||||
return finalPath + ".partial"
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
downloadPreparationCacheTTL = 5 * time.Minute
|
||||
downloadPreparationCacheMax = 128
|
||||
)
|
||||
|
||||
type preparedDownloadRequestEntry struct {
|
||||
key string
|
||||
request DownloadRequest
|
||||
metadataPrepared bool
|
||||
createdAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
preparedDownloadRequests = make(map[string]preparedDownloadRequestEntry)
|
||||
preparedDownloadRequestsMu sync.Mutex
|
||||
)
|
||||
|
||||
func downloadPreparationKey(req DownloadRequest) string {
|
||||
return strings.Join([]string{
|
||||
strings.TrimSpace(req.ItemID),
|
||||
strings.ToLower(strings.TrimSpace(req.Service)),
|
||||
strings.ToLower(strings.TrimSpace(req.Source)),
|
||||
strings.TrimSpace(req.SpotifyID),
|
||||
strings.TrimSpace(req.TidalID),
|
||||
strings.TrimSpace(req.QobuzID),
|
||||
strings.TrimSpace(req.DeezerID),
|
||||
strings.ToLower(strings.TrimSpace(req.TrackName)),
|
||||
strings.ToLower(strings.TrimSpace(req.ArtistName)),
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func prunePreparedDownloadRequestsLocked(now time.Time) {
|
||||
for itemID, entry := range preparedDownloadRequests {
|
||||
if now.Sub(entry.createdAt) >= downloadPreparationCacheTTL {
|
||||
delete(preparedDownloadRequests, itemID)
|
||||
}
|
||||
}
|
||||
for len(preparedDownloadRequests) >= downloadPreparationCacheMax {
|
||||
var oldestID string
|
||||
var oldestAt time.Time
|
||||
for itemID, entry := range preparedDownloadRequests {
|
||||
if oldestID == "" || entry.createdAt.Before(oldestAt) {
|
||||
oldestID = itemID
|
||||
oldestAt = entry.createdAt
|
||||
}
|
||||
}
|
||||
if oldestID == "" {
|
||||
break
|
||||
}
|
||||
delete(preparedDownloadRequests, oldestID)
|
||||
}
|
||||
}
|
||||
|
||||
func cacheDownloadRequestForVerification(key string, req DownloadRequest, metadataPrepared bool) {
|
||||
itemID := strings.TrimSpace(req.ItemID)
|
||||
if itemID == "" || strings.TrimSpace(key) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
preparedDownloadRequestsMu.Lock()
|
||||
defer preparedDownloadRequestsMu.Unlock()
|
||||
now := time.Now()
|
||||
prunePreparedDownloadRequestsLocked(now)
|
||||
preparedDownloadRequests[itemID] = preparedDownloadRequestEntry{
|
||||
key: key,
|
||||
request: req,
|
||||
metadataPrepared: metadataPrepared,
|
||||
createdAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func cachePreparedDownloadRequest(key string, req DownloadRequest) {
|
||||
cacheDownloadRequestForVerification(key, req, true)
|
||||
}
|
||||
|
||||
func cacheUnpreparedDownloadRequest(key string, req DownloadRequest) {
|
||||
cacheDownloadRequestForVerification(key, req, false)
|
||||
}
|
||||
|
||||
func takePreparedDownloadRequest(key string, fresh DownloadRequest) (DownloadRequest, bool, bool) {
|
||||
itemID := strings.TrimSpace(fresh.ItemID)
|
||||
if itemID == "" || strings.TrimSpace(key) == "" {
|
||||
return fresh, false, false
|
||||
}
|
||||
|
||||
preparedDownloadRequestsMu.Lock()
|
||||
defer preparedDownloadRequestsMu.Unlock()
|
||||
now := time.Now()
|
||||
prunePreparedDownloadRequestsLocked(now)
|
||||
entry, ok := preparedDownloadRequests[itemID]
|
||||
if !ok {
|
||||
return fresh, false, false
|
||||
}
|
||||
delete(preparedDownloadRequests, itemID)
|
||||
if entry.key != key {
|
||||
return fresh, false, false
|
||||
}
|
||||
|
||||
prepared := entry.request
|
||||
fresh.ISRC = prepared.ISRC
|
||||
fresh.SpotifyID = prepared.SpotifyID
|
||||
fresh.TrackName = prepared.TrackName
|
||||
fresh.ArtistName = prepared.ArtistName
|
||||
fresh.AlbumName = prepared.AlbumName
|
||||
fresh.AlbumArtist = prepared.AlbumArtist
|
||||
fresh.CoverURL = prepared.CoverURL
|
||||
fresh.TrackNumber = prepared.TrackNumber
|
||||
fresh.DiscNumber = prepared.DiscNumber
|
||||
fresh.TotalTracks = prepared.TotalTracks
|
||||
fresh.TotalDiscs = prepared.TotalDiscs
|
||||
fresh.ReleaseDate = prepared.ReleaseDate
|
||||
fresh.DurationMS = prepared.DurationMS
|
||||
fresh.Genre = prepared.Genre
|
||||
fresh.Label = prepared.Label
|
||||
fresh.Copyright = prepared.Copyright
|
||||
fresh.Composer = prepared.Composer
|
||||
fresh.TidalID = prepared.TidalID
|
||||
fresh.QobuzID = prepared.QobuzID
|
||||
fresh.DeezerID = prepared.DeezerID
|
||||
return fresh, entry.metadataPrepared, true
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func resetPreparedDownloadRequestCacheForTest() {
|
||||
preparedDownloadRequestsMu.Lock()
|
||||
preparedDownloadRequests = make(map[string]preparedDownloadRequestEntry)
|
||||
preparedDownloadRequestsMu.Unlock()
|
||||
}
|
||||
|
||||
func TestPreparedDownloadRequestCache(t *testing.T) {
|
||||
t.Cleanup(resetPreparedDownloadRequestCacheForTest)
|
||||
resetPreparedDownloadRequestCacheForTest()
|
||||
|
||||
fresh := DownloadRequest{
|
||||
ItemID: "item-1",
|
||||
Service: "provider-a",
|
||||
Source: "source-a",
|
||||
SpotifyID: "spotify-1",
|
||||
TrackName: "Track",
|
||||
ArtistName: "Artist",
|
||||
OutputDir: "/new/output",
|
||||
OutputPath: "/new/output/current.flac",
|
||||
OutputFD: 42,
|
||||
Quality: "lossless",
|
||||
EmbedMetadata: true,
|
||||
}
|
||||
key := downloadPreparationKey(fresh)
|
||||
prepared := fresh
|
||||
prepared.ISRC = "USRC17607839"
|
||||
prepared.AlbumName = "Resolved Album"
|
||||
prepared.AlbumArtist = "Resolved Album Artist"
|
||||
prepared.DeezerID = "deezer-1"
|
||||
prepared.Genre = "Pop"
|
||||
prepared.OutputDir = "/stale/output"
|
||||
prepared.OutputPath = "/stale/output/old.flac"
|
||||
prepared.OutputFD = 7
|
||||
prepared.Quality = "stale-quality"
|
||||
prepared.EmbedMetadata = false
|
||||
cachePreparedDownloadRequest(key, prepared)
|
||||
|
||||
got, metadataPrepared, ok := takePreparedDownloadRequest(key, fresh)
|
||||
if !ok {
|
||||
t.Fatal("expected prepared request cache hit")
|
||||
}
|
||||
if !metadataPrepared {
|
||||
t.Fatal("prepared request should be marked as metadata-prepared")
|
||||
}
|
||||
if got.ISRC != prepared.ISRC || got.AlbumName != prepared.AlbumName || got.DeezerID != prepared.DeezerID || got.Genre != prepared.Genre {
|
||||
t.Fatalf("prepared metadata was not restored: %#v", got)
|
||||
}
|
||||
if got.OutputDir != fresh.OutputDir || got.OutputPath != fresh.OutputPath || got.OutputFD != fresh.OutputFD || got.Quality != fresh.Quality || got.EmbedMetadata != fresh.EmbedMetadata {
|
||||
t.Fatalf("fresh output/settings fields were overwritten: %#v", got)
|
||||
}
|
||||
if _, _, ok := takePreparedDownloadRequest(key, fresh); ok {
|
||||
t.Fatal("prepared request should be consumed after one retry")
|
||||
}
|
||||
|
||||
cacheUnpreparedDownloadRequest(key, fresh)
|
||||
_, metadataPrepared, ok = takePreparedDownloadRequest(key, fresh)
|
||||
if !ok || metadataPrepared {
|
||||
t.Fatalf("unprepared verification request = hit:%v metadataPrepared:%v", ok, metadataPrepared)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparedDownloadRequestCacheRejectsChangedTrackAndExpiry(t *testing.T) {
|
||||
t.Cleanup(resetPreparedDownloadRequestCacheForTest)
|
||||
resetPreparedDownloadRequestCacheForTest()
|
||||
|
||||
req := DownloadRequest{
|
||||
ItemID: "item-2",
|
||||
Service: "provider-a",
|
||||
SpotifyID: "spotify-2",
|
||||
TrackName: "Track",
|
||||
ArtistName: "Artist",
|
||||
}
|
||||
key := downloadPreparationKey(req)
|
||||
cachePreparedDownloadRequest(key, req)
|
||||
|
||||
changed := req
|
||||
changed.SpotifyID = "spotify-other"
|
||||
if _, _, ok := takePreparedDownloadRequest(downloadPreparationKey(changed), changed); ok {
|
||||
t.Fatal("changed track must not reuse another track's prepared metadata")
|
||||
}
|
||||
preparedDownloadRequestsMu.Lock()
|
||||
_, staleEntryExists := preparedDownloadRequests[req.ItemID]
|
||||
preparedDownloadRequestsMu.Unlock()
|
||||
if staleEntryExists {
|
||||
t.Fatal("mismatched prepared request should be discarded")
|
||||
}
|
||||
|
||||
cachePreparedDownloadRequest(key, req)
|
||||
preparedDownloadRequestsMu.Lock()
|
||||
entry := preparedDownloadRequests[req.ItemID]
|
||||
entry.createdAt = time.Now().Add(-downloadPreparationCacheTTL)
|
||||
preparedDownloadRequests[req.ItemID] = entry
|
||||
preparedDownloadRequestsMu.Unlock()
|
||||
if _, _, ok := takePreparedDownloadRequest(key, req); ok {
|
||||
t.Fatal("expired prepared request must not be reused")
|
||||
}
|
||||
}
|
||||
+234
-43
@@ -1,19 +1,31 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// isrcFileEntry caches the parse result for one file so index rebuilds only
|
||||
// re-read files whose size or mtime changed.
|
||||
type isrcFileEntry struct {
|
||||
size int64
|
||||
modTime int64 // UnixNano
|
||||
isrc string // uppercase; empty when the file carries no ISRC tag
|
||||
}
|
||||
|
||||
type ISRCIndex struct {
|
||||
index map[string]string // ISRC (uppercase) -> file path
|
||||
index map[string]string // ISRC (uppercase) -> file path
|
||||
files map[string]isrcFileEntry // file path -> cached parse result
|
||||
outputDir string
|
||||
buildTime time.Time
|
||||
buildTime atomic.Int64 // UnixNano of the last build or write
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
@@ -22,14 +34,20 @@ var (
|
||||
isrcIndexCacheMu sync.RWMutex
|
||||
isrcBuildingMu sync.Map // Per-directory build lock to prevent concurrent builds
|
||||
isrcIndexTTL = 5 * time.Minute
|
||||
|
||||
isrcIndexBuildWorkers = 4
|
||||
)
|
||||
|
||||
func (idx *ISRCIndex) isFresh() bool {
|
||||
return time.Since(time.Unix(0, idx.buildTime.Load())) < isrcIndexTTL
|
||||
}
|
||||
|
||||
func GetISRCIndex(outputDir string) *ISRCIndex {
|
||||
isrcIndexCacheMu.RLock()
|
||||
idx, exists := isrcIndexCache[outputDir]
|
||||
isrcIndexCacheMu.RUnlock()
|
||||
|
||||
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
|
||||
if exists && idx.isFresh() {
|
||||
return idx
|
||||
}
|
||||
|
||||
@@ -42,7 +60,7 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
|
||||
idx, exists = isrcIndexCache[outputDir]
|
||||
isrcIndexCacheMu.RUnlock()
|
||||
|
||||
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
|
||||
if exists && idx.isFresh() {
|
||||
return idx
|
||||
}
|
||||
|
||||
@@ -52,16 +70,37 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
|
||||
func buildISRCIndex(outputDir string) *ISRCIndex {
|
||||
idx := &ISRCIndex{
|
||||
index: make(map[string]string),
|
||||
files: make(map[string]isrcFileEntry),
|
||||
outputDir: outputDir,
|
||||
buildTime: time.Now(),
|
||||
}
|
||||
idx.buildTime.Store(time.Now().UnixNano())
|
||||
|
||||
if outputDir == "" {
|
||||
return idx
|
||||
}
|
||||
|
||||
// Reuse the previous build's per-file cache: files whose size and mtime
|
||||
// are unchanged are adopted without touching their content, so a rebuild
|
||||
// is normally just a stat walk.
|
||||
prevFiles := map[string]isrcFileEntry{}
|
||||
isrcIndexCacheMu.RLock()
|
||||
if prev, ok := isrcIndexCache[outputDir]; ok {
|
||||
prev.mu.RLock()
|
||||
for path, entry := range prev.files {
|
||||
prevFiles[path] = entry
|
||||
}
|
||||
prev.mu.RUnlock()
|
||||
}
|
||||
isrcIndexCacheMu.RUnlock()
|
||||
|
||||
startTime := time.Now()
|
||||
fileCount := 0
|
||||
type parseTask struct {
|
||||
path string
|
||||
size int64
|
||||
modTime int64
|
||||
}
|
||||
var toParse []parseTask
|
||||
reused := 0
|
||||
|
||||
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
@@ -69,22 +108,60 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if ext != ".flac" {
|
||||
if !isrcIndexExts[ext] {
|
||||
return nil
|
||||
}
|
||||
|
||||
metadata, err := ReadMetadata(path)
|
||||
if err != nil || metadata.ISRC == "" {
|
||||
size := info.Size()
|
||||
modTime := info.ModTime().UnixNano()
|
||||
if entry, ok := prevFiles[path]; ok && entry.size == size && entry.modTime == modTime {
|
||||
idx.files[path] = entry
|
||||
if entry.isrc != "" {
|
||||
idx.index[entry.isrc] = path
|
||||
}
|
||||
reused++
|
||||
return nil
|
||||
}
|
||||
|
||||
idx.index[strings.ToUpper(metadata.ISRC)] = path
|
||||
fileCount++
|
||||
toParse = append(toParse, parseTask{path: path, size: size, modTime: modTime})
|
||||
return nil
|
||||
})
|
||||
|
||||
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
|
||||
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
|
||||
if len(toParse) > 0 {
|
||||
// New/changed files: read only their Vorbis comment block, in
|
||||
// parallel. Embedded cover art (megabytes per file) is never loaded.
|
||||
isrcs := make([]string, len(toParse))
|
||||
workerCount := isrcIndexBuildWorkers
|
||||
if len(toParse) < workerCount {
|
||||
workerCount = len(toParse)
|
||||
}
|
||||
tasks := make(chan int)
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < workerCount; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := range tasks {
|
||||
isrcs[i] = strings.ToUpper(readFileISRC(toParse[i].path))
|
||||
}
|
||||
}()
|
||||
}
|
||||
for i := range toParse {
|
||||
tasks <- i
|
||||
}
|
||||
close(tasks)
|
||||
wg.Wait()
|
||||
|
||||
for i, task := range toParse {
|
||||
entry := isrcFileEntry{size: task.size, modTime: task.modTime, isrc: isrcs[i]}
|
||||
idx.files[task.path] = entry
|
||||
if entry.isrc != "" {
|
||||
idx.index[entry.isrc] = task.path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("[ISRCIndex] Built index for %s: %d files (%d parsed, %d cached) in %v\n",
|
||||
outputDir, len(idx.files), len(toParse), reused, time.Since(startTime).Round(time.Millisecond))
|
||||
|
||||
isrcIndexCacheMu.Lock()
|
||||
isrcIndexCache[outputDir] = idx
|
||||
@@ -93,6 +170,110 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
|
||||
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
|
||||
// the metadata block headers and reading only the VORBIS_COMMENT payload;
|
||||
// picture and padding blocks are seeked past, never loaded. Returns "" when
|
||||
// the file is not FLAC or carries no ISRC tag.
|
||||
func readFlacISRC(path string) string {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
magic := make([]byte, 4)
|
||||
if _, err := io.ReadFull(f, magic); err != nil || string(magic) != "fLaC" {
|
||||
return ""
|
||||
}
|
||||
|
||||
header := make([]byte, 4)
|
||||
for {
|
||||
if _, err := io.ReadFull(f, header); err != nil {
|
||||
return ""
|
||||
}
|
||||
last := header[0]&0x80 != 0
|
||||
blockType := header[0] & 0x7F
|
||||
length := int64(header[1])<<16 | int64(header[2])<<8 | int64(header[3])
|
||||
if blockType == 4 { // VORBIS_COMMENT
|
||||
if length > 16<<20 {
|
||||
return ""
|
||||
}
|
||||
payload := make([]byte, length)
|
||||
if _, err := io.ReadFull(f, payload); err != nil {
|
||||
return ""
|
||||
}
|
||||
return vorbisCommentISRC(payload)
|
||||
}
|
||||
if last {
|
||||
return ""
|
||||
}
|
||||
if _, err := f.Seek(length, io.SeekCurrent); err != nil {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func vorbisCommentISRC(payload []byte) string {
|
||||
if len(payload) < 8 {
|
||||
return ""
|
||||
}
|
||||
offset := int(binary.LittleEndian.Uint32(payload[0:4])) + 4
|
||||
if offset < 4 || offset+4 > len(payload) {
|
||||
return ""
|
||||
}
|
||||
count := int(binary.LittleEndian.Uint32(payload[offset : offset+4]))
|
||||
offset += 4
|
||||
for i := 0; i < count; i++ {
|
||||
if offset+4 > len(payload) {
|
||||
return ""
|
||||
}
|
||||
commentLen := int(binary.LittleEndian.Uint32(payload[offset : offset+4]))
|
||||
offset += 4
|
||||
if commentLen < 0 || offset+commentLen > len(payload) {
|
||||
return ""
|
||||
}
|
||||
comment := payload[offset : offset+commentLen]
|
||||
offset += commentLen
|
||||
eq := strings.IndexByte(string(comment), '=')
|
||||
if eq > 0 && strings.EqualFold(string(comment[:eq]), "ISRC") {
|
||||
return strings.TrimSpace(string(comment[eq+1:]))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
|
||||
if isrc == "" {
|
||||
return "", false
|
||||
@@ -126,10 +307,30 @@ func (idx *ISRCIndex) Add(isrc, filePath string) {
|
||||
return
|
||||
}
|
||||
|
||||
idx.mu.Lock()
|
||||
defer idx.mu.Unlock()
|
||||
upper := strings.ToUpper(isrc)
|
||||
var entry *isrcFileEntry
|
||||
if info, err := os.Stat(filePath); err == nil {
|
||||
entry = &isrcFileEntry{
|
||||
size: info.Size(),
|
||||
modTime: info.ModTime().UnixNano(),
|
||||
isrc: upper,
|
||||
}
|
||||
}
|
||||
|
||||
idx.index[strings.ToUpper(isrc)] = filePath
|
||||
idx.mu.Lock()
|
||||
idx.index[upper] = filePath
|
||||
if entry != nil {
|
||||
if idx.files == nil {
|
||||
idx.files = make(map[string]isrcFileEntry)
|
||||
}
|
||||
idx.files[filePath] = *entry
|
||||
}
|
||||
idx.mu.Unlock()
|
||||
|
||||
// The index is write-maintained after every successful download;
|
||||
// refreshing the timestamp keeps the TTL from forcing a full rebuild in
|
||||
// the middle of the exact workload the index exists to serve.
|
||||
idx.buildTime.Store(time.Now().UnixNano())
|
||||
}
|
||||
|
||||
func InvalidateISRCCache(outputDir string) {
|
||||
@@ -193,35 +394,25 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
|
||||
|
||||
isrcIdx := GetISRCIndex(outputDir)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
// A lookup is a single map read. Holding one read lock for the batch avoids
|
||||
// one goroutine and one lock/unlock pair per track, which was slower and
|
||||
// could create thousands of goroutines for large playlists.
|
||||
isrcIdx.mu.RLock()
|
||||
for i, track := range tracks {
|
||||
wg.Add(1)
|
||||
go func(resultIdx int, t struct {
|
||||
ISRC string `json:"isrc"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
}) {
|
||||
defer wg.Done()
|
||||
|
||||
result := FileExistenceResult{
|
||||
ISRC: t.ISRC,
|
||||
TrackName: t.TrackName,
|
||||
ArtistName: t.ArtistName,
|
||||
Exists: false,
|
||||
result := FileExistenceResult{
|
||||
ISRC: track.ISRC,
|
||||
TrackName: track.TrackName,
|
||||
ArtistName: track.ArtistName,
|
||||
}
|
||||
if track.ISRC != "" {
|
||||
if filePath, exists := isrcIdx.index[strings.ToUpper(track.ISRC)]; exists {
|
||||
result.Exists = true
|
||||
result.FilePath = filePath
|
||||
}
|
||||
|
||||
if t.ISRC != "" {
|
||||
if filePath, exists := isrcIdx.lookup(t.ISRC); exists {
|
||||
result.Exists = true
|
||||
result.FilePath = filePath
|
||||
}
|
||||
}
|
||||
|
||||
results[resultIdx] = result
|
||||
}(i, track)
|
||||
}
|
||||
results[i] = result
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
isrcIdx.mu.RUnlock()
|
||||
|
||||
resultJSON, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
|
||||
+55
-3923
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,198 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GetTrackCacheSize and ClearTrackIDCache back the Settings cache screen. The
|
||||
// track-ID cache is currently a no-op, so these report an empty cache and clear
|
||||
// nothing, but the gomobile export contract is kept for the Dart/Kotlin callers.
|
||||
func GetTrackCacheSize() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func ClearTrackIDCache() {
|
||||
}
|
||||
|
||||
func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := GetDeezerClient()
|
||||
var data any
|
||||
var err error
|
||||
|
||||
switch resourceType {
|
||||
case "track":
|
||||
data, err = client.GetTrack(ctx, resourceID)
|
||||
case "album":
|
||||
data, err = client.GetAlbum(ctx, resourceID)
|
||||
case "artist":
|
||||
data, err = client.GetArtist(ctx, resourceID)
|
||||
case "playlist":
|
||||
data, err = client.GetPlaylist(ctx, resourceID)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported Deezer resource type: %s", resourceType)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return marshalJSONString(data)
|
||||
}
|
||||
|
||||
func GetDeezerExtendedMetadata(trackID string) (string, error) {
|
||||
if trackID == "" {
|
||||
return "", fmt.Errorf("empty track ID")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := GetDeezerClient()
|
||||
metadata, err := client.GetExtendedMetadataByTrackID(ctx, trackID)
|
||||
if err != nil {
|
||||
GoLog("[Deezer] Failed to get extended metadata: %v\n", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
result := buildDeezerExtendedMetadataResult(metadata)
|
||||
|
||||
return marshalJSONString(result)
|
||||
}
|
||||
|
||||
func SearchDeezerByISRC(isrc string) (string, error) {
|
||||
return SearchDeezerByISRCForItemID(isrc, "")
|
||||
}
|
||||
|
||||
func SearchDeezerByISRCForItemID(isrc string, itemID string) (string, error) {
|
||||
parentCtx := context.Background()
|
||||
if itemID != "" {
|
||||
parentCtx = initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return "", ErrDownloadCancelled
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := GetDeezerClient()
|
||||
track, err := client.SearchByISRC(ctx, isrc)
|
||||
if err != nil {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return "", ErrDownloadCancelled
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if isDownloadCancelled(itemID) {
|
||||
return "", ErrDownloadCancelled
|
||||
}
|
||||
|
||||
result := buildDeezerISRCSearchResult(track)
|
||||
return marshalJSONString(result)
|
||||
}
|
||||
|
||||
func buildDeezerExtendedMetadataResult(metadata *AlbumExtendedMetadata) map[string]string {
|
||||
if metadata == nil {
|
||||
return map[string]string{
|
||||
"genre": "",
|
||||
"label": "",
|
||||
"copyright": "",
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"genre": metadata.Genre,
|
||||
"label": metadata.Label,
|
||||
"copyright": metadata.Copyright,
|
||||
}
|
||||
}
|
||||
|
||||
func buildDeezerISRCSearchResult(track *TrackMetadata) map[string]any {
|
||||
if track == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"spotify_id": track.SpotifyID,
|
||||
"artists": track.Artists,
|
||||
"name": track.Name,
|
||||
"album_name": track.AlbumName,
|
||||
"album_artist": track.AlbumArtist,
|
||||
"duration_ms": track.DurationMS,
|
||||
"images": track.Images,
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": track.TrackNumber,
|
||||
"total_tracks": track.TotalTracks,
|
||||
"disc_number": track.DiscNumber,
|
||||
"total_discs": track.TotalDiscs,
|
||||
"external_urls": track.ExternalURL,
|
||||
"isrc": track.ISRC,
|
||||
"album_id": track.AlbumID,
|
||||
"artist_id": track.ArtistID,
|
||||
"album_type": track.AlbumType,
|
||||
"composer": track.Composer,
|
||||
}
|
||||
|
||||
if deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:")); deezerID != "" {
|
||||
result["id"] = deezerID
|
||||
result["track_id"] = deezerID
|
||||
result["success"] = true
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
songlink := NewSongLinkClient()
|
||||
deezerClient := GetDeezerClient()
|
||||
|
||||
if resourceType == "track" {
|
||||
deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not find Deezer equivalent: %w", err)
|
||||
}
|
||||
|
||||
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err)
|
||||
}
|
||||
|
||||
return marshalJSONString(trackResp)
|
||||
}
|
||||
|
||||
if resourceType == "album" {
|
||||
deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not find Deezer album: %w", err)
|
||||
}
|
||||
|
||||
albumResp, err := deezerClient.GetAlbum(ctx, deezerID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err)
|
||||
}
|
||||
|
||||
return marshalJSONString(albumResp)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("spotify to Deezer conversion only supported for tracks and albums: please search by name for %s", resourceType)
|
||||
}
|
||||
|
||||
func GetSpotifyIDFromDeezerTrack(deezerTrackID string) (string, error) {
|
||||
client := NewSongLinkClient()
|
||||
return client.GetSpotifyIDFromDeezer(deezerTrackID)
|
||||
}
|
||||
|
||||
func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) {
|
||||
client := NewSongLinkClient()
|
||||
return client.GetTidalURLFromDeezer(deezerTrackID)
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DownloadRequest struct {
|
||||
ContractVersion int `json:"contract_version,omitempty"`
|
||||
ISRC string `json:"isrc"`
|
||||
Service string `json:"service"`
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
AlbumName string `json:"album_name"`
|
||||
AlbumArtist string `json:"album_artist"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
OutputPath string `json:"output_path,omitempty"`
|
||||
OutputFD int `json:"output_fd,omitempty"`
|
||||
OutputExt string `json:"output_ext,omitempty"`
|
||||
FilenameFormat string `json:"filename_format"`
|
||||
Quality string `json:"quality"`
|
||||
EmbedMetadata bool `json:"embed_metadata"`
|
||||
ArtistTagMode string `json:"artist_tag_mode,omitempty"`
|
||||
EmbedLyrics bool `json:"embed_lyrics"`
|
||||
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
|
||||
EmbedReplayGain bool `json:"embed_replaygain,omitempty"`
|
||||
PostProcessingEnabled bool `json:"post_processing_enabled,omitempty"`
|
||||
TidalHighFormat string `json:"tidal_high_format,omitempty"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
PlaylistPosition int `json:"playlist_position,omitempty"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
TotalDiscs int `json:"total_discs,omitempty"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
ItemID string `json:"item_id"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
Source string `json:"source"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
TidalID string `json:"tidal_id,omitempty"`
|
||||
QobuzID string `json:"qobuz_id,omitempty"`
|
||||
DeezerID string `json:"deezer_id,omitempty"`
|
||||
LyricsMode string `json:"lyrics_mode,omitempty"`
|
||||
UseExtensions bool `json:"use_extensions,omitempty"`
|
||||
UseFallback bool `json:"use_fallback,omitempty"`
|
||||
RequiresContainerConversion bool `json:"requires_container_conversion,omitempty"`
|
||||
AllowQualityVariant bool `json:"allow_quality_variant,omitempty"`
|
||||
QualityVariant string `json:"quality_variant,omitempty"`
|
||||
SongLinkRegion string `json:"songlink_region,omitempty"`
|
||||
}
|
||||
|
||||
type DownloadResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorType string `json:"error_type,omitempty"`
|
||||
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
||||
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
||||
AudioCodec string `json:"audio_codec,omitempty"`
|
||||
ActualExtension string `json:"actual_extension,omitempty"`
|
||||
ActualContainer string `json:"actual_container,omitempty"`
|
||||
RequiresContainerConversion bool `json:"requires_container_conversion,omitempty"`
|
||||
Service string `json:"service,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Artist string `json:"artist,omitempty"`
|
||||
Album string `json:"album,omitempty"`
|
||||
AlbumArtist string `json:"album_artist,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
TrackNumber int `json:"track_number,omitempty"`
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
TotalTracks int `json:"total_tracks,omitempty"`
|
||||
TotalDiscs int `json:"total_discs,omitempty"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
|
||||
LyricsLRC string `json:"lyrics_lrc,omitempty"`
|
||||
DecryptionKey string `json:"decryption_key,omitempty"`
|
||||
Decryption *DownloadDecryptionInfo `json:"decryption,omitempty"`
|
||||
}
|
||||
|
||||
type DownloadResult struct {
|
||||
FilePath string
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
AudioCodec string
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
ReleaseDate string
|
||||
TrackNumber int
|
||||
TotalTracks int
|
||||
DiscNumber int
|
||||
TotalDiscs int
|
||||
ISRC string
|
||||
CoverURL string
|
||||
Genre string
|
||||
Label string
|
||||
Copyright string
|
||||
Composer string
|
||||
LyricsLRC string
|
||||
DecryptionKey string
|
||||
Decryption *DownloadDecryptionInfo
|
||||
ActualExtension string
|
||||
ActualContainer string
|
||||
RequiresContainerConversion bool
|
||||
}
|
||||
|
||||
func buildDownloadSuccessResponse(
|
||||
req DownloadRequest,
|
||||
result DownloadResult,
|
||||
service string,
|
||||
message string,
|
||||
filePath string,
|
||||
alreadyExists bool,
|
||||
) DownloadResponse {
|
||||
title := result.Title
|
||||
if title == "" {
|
||||
title = req.TrackName
|
||||
}
|
||||
|
||||
artist := result.Artist
|
||||
if artist == "" {
|
||||
artist = req.ArtistName
|
||||
}
|
||||
|
||||
// Preserve requested release metadata when available so mixed-provider
|
||||
// fallback downloads from the same source album do not get split into
|
||||
// different albums just because Tidal/Qobuz report variant titles/dates.
|
||||
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
|
||||
req,
|
||||
result.Album,
|
||||
result.ReleaseDate,
|
||||
result.TrackNumber,
|
||||
result.DiscNumber,
|
||||
)
|
||||
|
||||
isrc := result.ISRC
|
||||
if isrc == "" {
|
||||
isrc = req.ISRC
|
||||
}
|
||||
|
||||
genre := result.Genre
|
||||
if genre == "" {
|
||||
genre = req.Genre
|
||||
}
|
||||
|
||||
label := result.Label
|
||||
if label == "" {
|
||||
label = req.Label
|
||||
}
|
||||
|
||||
copyright := result.Copyright
|
||||
if copyright == "" {
|
||||
copyright = req.Copyright
|
||||
}
|
||||
|
||||
composer := result.Composer
|
||||
if composer == "" {
|
||||
composer = req.Composer
|
||||
}
|
||||
|
||||
coverURL := strings.TrimSpace(result.CoverURL)
|
||||
if coverURL == "" {
|
||||
coverURL = strings.TrimSpace(req.CoverURL)
|
||||
}
|
||||
|
||||
return DownloadResponse{
|
||||
Success: true,
|
||||
Message: message,
|
||||
FilePath: filePath,
|
||||
AlreadyExists: alreadyExists,
|
||||
ActualBitDepth: result.BitDepth,
|
||||
ActualSampleRate: result.SampleRate,
|
||||
AudioCodec: result.AudioCodec,
|
||||
ActualExtension: result.ActualExtension,
|
||||
ActualContainer: result.ActualContainer,
|
||||
RequiresContainerConversion: result.RequiresContainerConversion,
|
||||
Service: service,
|
||||
Title: title,
|
||||
Artist: artist,
|
||||
Album: album,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
ReleaseDate: releaseDate,
|
||||
TrackNumber: trackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: discNumber,
|
||||
TotalDiscs: req.TotalDiscs,
|
||||
ISRC: isrc,
|
||||
CoverURL: coverURL,
|
||||
Genre: genre,
|
||||
Label: label,
|
||||
Copyright: copyright,
|
||||
Composer: composer,
|
||||
LyricsLRC: result.LyricsLRC,
|
||||
DecryptionKey: result.DecryptionKey,
|
||||
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
|
||||
}
|
||||
}
|
||||
|
||||
func shouldSkipQualityProbe(filePath string) bool {
|
||||
path := strings.TrimSpace(filePath)
|
||||
if path == "" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(path, "/proc/self/fd/") {
|
||||
return true
|
||||
}
|
||||
// Content URI and other non-filesystem schemes cannot be read directly by os.Open.
|
||||
if strings.Contains(path, "://") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func enrichResultQualityFromFile(result *DownloadResult) {
|
||||
if result == nil {
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimSpace(result.FilePath)
|
||||
if shouldSkipQualityProbe(path) {
|
||||
if strings.HasPrefix(path, "/proc/self/fd/") {
|
||||
LogDebug("Download", "Skipping quality probe for ephemeral SAF FD output: %s", path)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
quality, qErr := GetAudioQuality(path)
|
||||
if qErr == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
result.AudioCodec = quality.Codec
|
||||
if quality.Codec != "" {
|
||||
GoLog("[Download] Actual quality from file: %s %d-bit/%dHz\n", quality.Codec, quality.BitDepth, quality.SampleRate)
|
||||
} else {
|
||||
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
LogDebug("Download", "Post-download quality probe unavailable for %s: %v", path, qErr)
|
||||
}
|
||||
|
||||
func applyExtendedMetadataFields(
|
||||
genre *string,
|
||||
label *string,
|
||||
copyright *string,
|
||||
extMeta *AlbumExtendedMetadata,
|
||||
) {
|
||||
if extMeta == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if genre != nil && *genre == "" && extMeta.Genre != "" {
|
||||
*genre = extMeta.Genre
|
||||
}
|
||||
if label != nil && *label == "" && extMeta.Label != "" {
|
||||
*label = extMeta.Label
|
||||
}
|
||||
if copyright != nil && *copyright == "" && extMeta.Copyright != "" {
|
||||
*copyright = extMeta.Copyright
|
||||
}
|
||||
}
|
||||
|
||||
func enrichExtraMetadataByISRC(
|
||||
logPrefix string,
|
||||
isrc string,
|
||||
genre *string,
|
||||
label *string,
|
||||
copyright *string,
|
||||
) {
|
||||
normalizedISRC := strings.TrimSpace(isrc)
|
||||
if normalizedISRC == "" {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
extMeta, err := fetchDeezerExtendedMetadataByISRC(ctx, normalizedISRC)
|
||||
if err != nil {
|
||||
GoLog("[%s] Failed to get extended metadata from Deezer: %v\n", logPrefix, err)
|
||||
}
|
||||
applyExtendedMetadataFields(genre, label, copyright, extMeta)
|
||||
|
||||
if genre != nil && *genre == "" {
|
||||
musicBrainzGenre, err := fetchMusicBrainzGenreByISRC(normalizedISRC)
|
||||
if err != nil {
|
||||
GoLog("[%s] Failed to get genre from MusicBrainz: %v\n", logPrefix, err)
|
||||
} else if musicBrainzGenre != "" {
|
||||
*genre = musicBrainzGenre
|
||||
GoLog("[%s] Genre fallback from MusicBrainz: %s\n", logPrefix, *genre)
|
||||
}
|
||||
}
|
||||
|
||||
currentGenre := ""
|
||||
currentLabel := ""
|
||||
currentCopyright := ""
|
||||
if genre != nil {
|
||||
currentGenre = *genre
|
||||
}
|
||||
if label != nil {
|
||||
currentLabel = *label
|
||||
}
|
||||
if copyright != nil {
|
||||
currentCopyright = *copyright
|
||||
}
|
||||
if currentGenre != "" || currentLabel != "" || currentCopyright != "" {
|
||||
GoLog("[%s] Extended metadata ready: genre=%s, label=%s, copyright=%s\n", logPrefix, currentGenre, currentLabel, currentCopyright)
|
||||
}
|
||||
}
|
||||
|
||||
func enrichRequestExtendedMetadata(req *DownloadRequest) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if req.ISRC == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.AlbumArtist) == "" {
|
||||
albumArtist, err := fetchMusicBrainzAlbumArtistByISRC(req.ISRC, req.AlbumName)
|
||||
if err != nil {
|
||||
GoLog("[DownloadWithFallback] Failed to get album artist from MusicBrainz: %v\n", err)
|
||||
} else if strings.TrimSpace(albumArtist) != "" {
|
||||
req.AlbumArtist = strings.TrimSpace(albumArtist)
|
||||
GoLog("[DownloadWithFallback] Album artist fallback from MusicBrainz: %s\n", req.AlbumArtist)
|
||||
}
|
||||
}
|
||||
|
||||
if req.Genre == "" || req.Label == "" || req.Copyright == "" {
|
||||
enrichExtraMetadataByISRC(
|
||||
"DownloadWithFallback",
|
||||
req.ISRC,
|
||||
&req.Genre,
|
||||
&req.Label,
|
||||
&req.Copyright,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func applySongLinkRegionFromRequest(req *DownloadRequest) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
SetSongLinkRegion(req.SongLinkRegion)
|
||||
}
|
||||
|
||||
// DownloadByStrategy routes all download requests through extension providers.
|
||||
func DownloadByStrategy(requestJSON string) (string, error) {
|
||||
var req DownloadRequest
|
||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||
return errorResponse("Invalid request: " + err.Error())
|
||||
}
|
||||
normalizedBytes, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return errorResponse("Invalid request: " + err.Error())
|
||||
}
|
||||
normalizedJSON := string(normalizedBytes)
|
||||
|
||||
if req.UseExtensions {
|
||||
resp, err := DownloadWithExtensionsJSON(normalizedJSON)
|
||||
if err != nil {
|
||||
return errorResponse(err.Error())
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
return errorResponse("Extension providers are disabled; built-in download providers have been retired")
|
||||
}
|
||||
|
||||
func GetAllDownloadProgress() string {
|
||||
return GetMultiProgress()
|
||||
}
|
||||
|
||||
func GetAllDownloadProgressDelta(sinceSeq int64) string {
|
||||
return GetMultiProgressDelta(sinceSeq)
|
||||
}
|
||||
|
||||
func InitItemProgress(itemID string) {
|
||||
StartItemProgress(itemID)
|
||||
}
|
||||
|
||||
func ClearItemProgress(itemID string) {
|
||||
RemoveItemProgress(itemID)
|
||||
}
|
||||
|
||||
func CancelDownload(itemID string) {
|
||||
cancelDownload(itemID)
|
||||
}
|
||||
|
||||
// ResetDownloadCancel drops a pre-registered cancellation flag for an item
|
||||
// with no active download, so a user-initiated retry does not consume a stale
|
||||
// cancel and abort instantly. Entries with live references are left alone.
|
||||
func ResetDownloadCancel(itemID string) {
|
||||
resetDownloadCancel(itemID)
|
||||
}
|
||||
|
||||
func CleanupConnections() {
|
||||
CloseIdleConnections()
|
||||
}
|
||||
|
||||
func errorResponse(msg string) (string, error) {
|
||||
errorType := classifyDownloadErrorType(msg)
|
||||
|
||||
resp := DownloadResponse{
|
||||
Success: false,
|
||||
Error: msg,
|
||||
ErrorType: errorType,
|
||||
}
|
||||
s, _ := marshalJSONString(resp)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func classifyDownloadErrorType(msg string) string {
|
||||
lowerMsg := strings.ToLower(msg)
|
||||
|
||||
if strings.Contains(lowerMsg, "isp blocking") ||
|
||||
strings.Contains(lowerMsg, "try using vpn") ||
|
||||
strings.Contains(lowerMsg, "change dns") {
|
||||
return "isp_blocked"
|
||||
} else if strings.Contains(lowerMsg, "cancel") {
|
||||
return "cancelled"
|
||||
} else if strings.Contains(lowerMsg, "verification_required") ||
|
||||
strings.Contains(lowerMsg, "session is not authenticated") ||
|
||||
strings.Contains(lowerMsg, "signed session is not authenticated") ||
|
||||
strings.Contains(lowerMsg, "signed session expired") {
|
||||
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") ||
|
||||
messageHasHTTPStatusCode(lowerMsg, "429") ||
|
||||
strings.Contains(lowerMsg, "too many requests") {
|
||||
return "rate_limit"
|
||||
} else if strings.Contains(lowerMsg, "permission") ||
|
||||
strings.Contains(lowerMsg, "operation not permitted") ||
|
||||
strings.Contains(lowerMsg, "access denied") ||
|
||||
strings.Contains(lowerMsg, "failed to create file") ||
|
||||
strings.Contains(lowerMsg, "failed to create directory") {
|
||||
return "permission"
|
||||
} else if strings.Contains(lowerMsg, "not found") ||
|
||||
strings.Contains(lowerMsg, "not available") ||
|
||||
strings.Contains(lowerMsg, "no results") ||
|
||||
strings.Contains(lowerMsg, "track not found") ||
|
||||
strings.Contains(lowerMsg, "all services failed") {
|
||||
return "not_found"
|
||||
} else if strings.Contains(lowerMsg, "network") ||
|
||||
strings.Contains(lowerMsg, "connection") ||
|
||||
strings.Contains(lowerMsg, "timeout") ||
|
||||
strings.Contains(lowerMsg, "dial") {
|
||||
return "network"
|
||||
}
|
||||
|
||||
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 {
|
||||
return strings.Contains(lowerMsg, "http "+code) ||
|
||||
strings.Contains(lowerMsg, "http status "+code) ||
|
||||
strings.Contains(lowerMsg, "status "+code) ||
|
||||
strings.Contains(lowerMsg, code+" for ") ||
|
||||
strings.Contains(lowerMsg, code+":") ||
|
||||
strings.Contains(lowerMsg, code+";")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
package gobackend
|
||||
|
||||
func SetLibraryCoverCacheDirJSON(cacheDir string) {
|
||||
SetLibraryCoverCacheDir(cacheDir)
|
||||
}
|
||||
|
||||
func ScanLibraryFolderJSON(folderPath string) (string, error) {
|
||||
return ScanLibraryFolder(folderPath)
|
||||
}
|
||||
|
||||
func ScanLibraryFolderIncrementalJSON(folderPath, existingFilesJSON string) (string, error) {
|
||||
return ScanLibraryFolderIncremental(folderPath, existingFilesJSON)
|
||||
}
|
||||
|
||||
func ScanLibraryFolderIncrementalFromSnapshotJSON(folderPath, snapshotPath string) (string, error) {
|
||||
return ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath)
|
||||
}
|
||||
|
||||
func GetLibraryScanProgressJSON() string {
|
||||
return GetLibraryScanProgress()
|
||||
}
|
||||
|
||||
func CancelLibraryScanJSON() {
|
||||
CancelLibraryScan()
|
||||
}
|
||||
|
||||
func ReadAudioMetadataJSON(filePath string) (string, error) {
|
||||
return ReadAudioMetadata(filePath)
|
||||
}
|
||||
|
||||
func ReadAudioMetadataWithHintAndCoverCacheKeyJSON(filePath, displayName, coverCacheKey string) (string, error) {
|
||||
return ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayName, coverCacheKey)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
|
||||
if filePath != "" {
|
||||
lyrics, err := ExtractLyrics(filePath)
|
||||
if err == nil && rawLyricsHasUsableContent(lyrics) {
|
||||
return lyrics, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
client := NewLyricsClient()
|
||||
durationSec := float64(durationMs) / 1000.0
|
||||
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if lyricsData.Instrumental {
|
||||
return "[instrumental:true]", nil
|
||||
}
|
||||
|
||||
lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
|
||||
return lrcContent, nil
|
||||
}
|
||||
|
||||
func GetLyricsLRCWithSource(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
|
||||
if filePath != "" {
|
||||
lyrics, err := ExtractLyrics(filePath)
|
||||
if err == nil && rawLyricsHasUsableContent(lyrics) {
|
||||
source := extractLyricsSourceFromLRC(lyrics)
|
||||
if source == "" {
|
||||
source = "Embedded"
|
||||
}
|
||||
result := map[string]any{
|
||||
"lyrics": lyrics,
|
||||
"source": source,
|
||||
"sync_type": "EMBEDDED",
|
||||
"instrumental": isInstrumentalLyricsMarker(lyrics),
|
||||
}
|
||||
return marshalJSONString(result)
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"lyrics": "",
|
||||
"source": "",
|
||||
"sync_type": "",
|
||||
"instrumental": false,
|
||||
}
|
||||
return marshalJSONString(result)
|
||||
}
|
||||
|
||||
client := NewLyricsClient()
|
||||
durationSec := float64(durationMs) / 1000.0
|
||||
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
lrcContent := ""
|
||||
if lyricsData.Instrumental {
|
||||
lrcContent = "[instrumental:true]"
|
||||
} else {
|
||||
lrcContent = convertToLRCWithMetadata(lyricsData, trackName, artistName)
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"lyrics": lrcContent,
|
||||
"source": lyricsData.Source,
|
||||
"sync_type": lyricsData.SyncType,
|
||||
"instrumental": lyricsData.Instrumental,
|
||||
}
|
||||
return marshalJSONString(result)
|
||||
}
|
||||
|
||||
func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
|
||||
err := EmbedLyrics(filePath, lyrics)
|
||||
if err != nil {
|
||||
return errorResponse("Failed to embed lyrics: " + err.Error())
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"success": true,
|
||||
"message": "Lyrics embedded successfully",
|
||||
}
|
||||
|
||||
s, _ := marshalJSONString(resp)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func FetchAndSaveLyrics(trackName, artistName, spotifyID string, durationMs int64, outputPath string, audioFilePath string) error {
|
||||
// If the audio file already has embedded lyrics or a sidecar .lrc,
|
||||
// use those directly instead of making redundant network requests.
|
||||
if audioFilePath != "" {
|
||||
existing, err := ExtractLyrics(audioFilePath)
|
||||
if err == nil && rawLyricsHasUsableContent(existing) {
|
||||
if err := os.WriteFile(outputPath, []byte(existing), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write LRC file: %w", err)
|
||||
}
|
||||
GoLog("[Lyrics] Saved LRC from embedded/sidecar to: %s\n", outputPath)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
client := NewLyricsClient()
|
||||
durationSec := float64(durationMs) / 1000.0
|
||||
|
||||
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lyrics not found: %w", err)
|
||||
}
|
||||
|
||||
if lyrics.Instrumental {
|
||||
return fmt.Errorf("track is instrumental, no lyrics available")
|
||||
}
|
||||
|
||||
lrcContent := convertToLRCWithMetadata(lyrics, trackName, artistName)
|
||||
if lrcContent == "" {
|
||||
return fmt.Errorf("failed to generate LRC content")
|
||||
}
|
||||
|
||||
if err := os.WriteFile(outputPath, []byte(lrcContent), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write LRC file: %w", err)
|
||||
}
|
||||
|
||||
GoLog("[Lyrics] Saved LRC to: %s (%d lines)\n", outputPath, len(lyrics.Lines))
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetLyricsProvidersJSON(providersJSON string) error {
|
||||
var providers []string
|
||||
if err := json.Unmarshal([]byte(providersJSON), &providers); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
SetLyricsProviderOrder(providers)
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetLyricsProvidersJSON() (string, error) {
|
||||
providers := GetLyricsProviderOrder()
|
||||
return marshalJSONString(providers)
|
||||
}
|
||||
|
||||
func GetAvailableLyricsProvidersJSON() (string, error) {
|
||||
providers := GetAvailableLyricsProviders()
|
||||
return marshalJSONString(providers)
|
||||
}
|
||||
|
||||
func SetLyricsFetchOptionsJSON(optionsJSON string) error {
|
||||
opts := GetLyricsFetchOptions()
|
||||
if strings.TrimSpace(optionsJSON) != "" {
|
||||
if err := json.Unmarshal([]byte(optionsJSON), &opts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
SetLyricsFetchOptions(opts)
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetLyricsFetchOptionsJSON() (string, error) {
|
||||
opts := GetLyricsFetchOptions()
|
||||
return marshalJSONString(opts)
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func applyAudioMetadataToResult(result map[string]any, meta *AudioMetadata) {
|
||||
result["title"] = meta.Title
|
||||
result["artist"] = meta.Artist
|
||||
result["album"] = meta.Album
|
||||
result["album_artist"] = meta.AlbumArtist
|
||||
result["date"] = meta.Date
|
||||
if meta.Date == "" {
|
||||
result["date"] = meta.Year
|
||||
}
|
||||
result["track_number"] = meta.TrackNumber
|
||||
result["total_tracks"] = meta.TotalTracks
|
||||
result["disc_number"] = meta.DiscNumber
|
||||
result["total_discs"] = meta.TotalDiscs
|
||||
result["isrc"] = meta.ISRC
|
||||
result["lyrics"] = meta.Lyrics
|
||||
result["genre"] = meta.Genre
|
||||
result["label"] = meta.Label
|
||||
result["copyright"] = meta.Copyright
|
||||
result["composer"] = meta.Composer
|
||||
result["comment"] = meta.Comment
|
||||
result["replaygain_track_gain"] = meta.ReplayGainTrackGain
|
||||
result["replaygain_track_peak"] = meta.ReplayGainTrackPeak
|
||||
result["replaygain_album_gain"] = meta.ReplayGainAlbumGain
|
||||
result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak
|
||||
}
|
||||
|
||||
func successMethodJSON(method string) (string, error) {
|
||||
return marshalJSONString(map[string]any{"success": true, "method": method})
|
||||
}
|
||||
|
||||
func ReadFileMetadata(filePath string) (string, error) {
|
||||
lower := strings.ToLower(filePath)
|
||||
isFlac := strings.HasSuffix(lower, ".flac")
|
||||
isM4A := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".aac")
|
||||
isMp3 := strings.HasSuffix(lower, ".mp3")
|
||||
isOgg := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg")
|
||||
isApe := strings.HasSuffix(lower, ".ape")
|
||||
isWv := strings.HasSuffix(lower, ".wv")
|
||||
isMpc := strings.HasSuffix(lower, ".mpc")
|
||||
isWav := strings.HasSuffix(lower, ".wav")
|
||||
isAiff := strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc")
|
||||
|
||||
result := map[string]any{
|
||||
"title": "",
|
||||
"artist": "",
|
||||
"album": "",
|
||||
"album_artist": "",
|
||||
"date": "",
|
||||
"track_number": 0,
|
||||
"total_tracks": 0,
|
||||
"disc_number": 0,
|
||||
"total_discs": 0,
|
||||
"isrc": "",
|
||||
"lyrics": "",
|
||||
"genre": "",
|
||||
"label": "",
|
||||
"copyright": "",
|
||||
"composer": "",
|
||||
"comment": "",
|
||||
"duration": 0,
|
||||
"format": "",
|
||||
"audio_codec": "",
|
||||
}
|
||||
|
||||
if isFlac {
|
||||
result["format"] = "flac"
|
||||
result["audio_codec"] = "flac"
|
||||
metadata, err := ReadMetadata(filePath)
|
||||
if err != nil {
|
||||
// File may have wrong extension (e.g. opus saved as .flac).
|
||||
// Try Ogg/Opus parser as fallback before giving up.
|
||||
GoLog("[ReadFileMetadata] FLAC parse failed for %s, trying Ogg fallback: %v\n", filePath, err)
|
||||
oggMeta, oggErr := ReadOggVorbisComments(filePath)
|
||||
if oggErr == nil && oggMeta != nil {
|
||||
result["title"] = oggMeta.Title
|
||||
result["artist"] = oggMeta.Artist
|
||||
result["album"] = oggMeta.Album
|
||||
result["album_artist"] = oggMeta.AlbumArtist
|
||||
result["date"] = oggMeta.Date
|
||||
if oggMeta.Date == "" {
|
||||
result["date"] = oggMeta.Year
|
||||
}
|
||||
result["track_number"] = oggMeta.TrackNumber
|
||||
result["total_tracks"] = oggMeta.TotalTracks
|
||||
result["disc_number"] = oggMeta.DiscNumber
|
||||
result["total_discs"] = oggMeta.TotalDiscs
|
||||
result["isrc"] = oggMeta.ISRC
|
||||
result["lyrics"] = oggMeta.Lyrics
|
||||
result["genre"] = oggMeta.Genre
|
||||
result["composer"] = oggMeta.Composer
|
||||
result["comment"] = oggMeta.Comment
|
||||
quality, qualityErr := GetOggQuality(filePath)
|
||||
if qualityErr == nil {
|
||||
result["sample_rate"] = quality.SampleRate
|
||||
result["duration"] = quality.Duration
|
||||
if quality.Bitrate > 0 {
|
||||
result["bitrate"] = quality.Bitrate / 1000
|
||||
}
|
||||
}
|
||||
result["format"] = "opus"
|
||||
result["audio_codec"] = "opus"
|
||||
} else {
|
||||
return "", fmt.Errorf("failed to read metadata: %w", err)
|
||||
}
|
||||
} else {
|
||||
result["title"] = metadata.Title
|
||||
result["artist"] = metadata.Artist
|
||||
result["album"] = metadata.Album
|
||||
result["album_artist"] = metadata.AlbumArtist
|
||||
result["date"] = metadata.Date
|
||||
result["track_number"] = metadata.TrackNumber
|
||||
result["total_tracks"] = metadata.TotalTracks
|
||||
result["disc_number"] = metadata.DiscNumber
|
||||
result["total_discs"] = metadata.TotalDiscs
|
||||
result["isrc"] = metadata.ISRC
|
||||
result["lyrics"] = metadata.Lyrics
|
||||
result["genre"] = metadata.Genre
|
||||
result["label"] = metadata.Label
|
||||
result["copyright"] = metadata.Copyright
|
||||
result["composer"] = metadata.Composer
|
||||
result["comment"] = metadata.Comment
|
||||
result["replaygain_track_gain"] = metadata.ReplayGainTrackGain
|
||||
result["replaygain_track_peak"] = metadata.ReplayGainTrackPeak
|
||||
result["replaygain_album_gain"] = metadata.ReplayGainAlbumGain
|
||||
result["replaygain_album_peak"] = metadata.ReplayGainAlbumPeak
|
||||
|
||||
quality, qualityErr := GetAudioQuality(filePath)
|
||||
if qualityErr == nil {
|
||||
result["bit_depth"] = quality.BitDepth
|
||||
result["sample_rate"] = quality.SampleRate
|
||||
if quality.Codec != "" {
|
||||
result["audio_codec"] = quality.Codec
|
||||
}
|
||||
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if isM4A {
|
||||
result["format"] = "m4a"
|
||||
meta, err := ReadM4ATags(filePath)
|
||||
if err == nil && meta != nil {
|
||||
applyAudioMetadataToResult(result, meta)
|
||||
}
|
||||
quality, qualityErr := GetM4AQuality(filePath)
|
||||
if qualityErr == nil {
|
||||
result["bit_depth"] = quality.BitDepth
|
||||
result["sample_rate"] = quality.SampleRate
|
||||
result["duration"] = quality.Duration
|
||||
result["audio_codec"] = quality.Codec
|
||||
if format := libraryFormatForM4ACodec(quality.Codec); format != "" {
|
||||
result["format"] = format
|
||||
}
|
||||
if quality.Bitrate > 0 && !isLosslessLibraryFormat(fmt.Sprint(result["format"])) {
|
||||
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 {
|
||||
result["format"] = "mp3"
|
||||
result["audio_codec"] = "mp3"
|
||||
meta, err := ReadID3Tags(filePath)
|
||||
if err == nil && meta != nil {
|
||||
applyAudioMetadataToResult(result, meta)
|
||||
}
|
||||
quality, qualityErr := GetMP3Quality(filePath)
|
||||
if qualityErr == nil {
|
||||
result["bit_depth"] = quality.BitDepth
|
||||
result["sample_rate"] = quality.SampleRate
|
||||
result["duration"] = quality.Duration
|
||||
if quality.Bitrate > 0 {
|
||||
result["bitrate"] = quality.Bitrate / 1000
|
||||
}
|
||||
}
|
||||
} else if isOgg {
|
||||
result["format"] = "opus"
|
||||
result["audio_codec"] = "opus"
|
||||
meta, err := ReadOggVorbisComments(filePath)
|
||||
if err == nil && meta != nil {
|
||||
applyAudioMetadataToResult(result, meta)
|
||||
}
|
||||
quality, qualityErr := GetOggQuality(filePath)
|
||||
if qualityErr == nil {
|
||||
result["sample_rate"] = quality.SampleRate
|
||||
result["duration"] = quality.Duration
|
||||
if quality.Bitrate > 0 {
|
||||
result["bitrate"] = quality.Bitrate / 1000
|
||||
}
|
||||
}
|
||||
} else if isApe || isWv || isMpc {
|
||||
result["format"] = strings.TrimPrefix(filepath.Ext(filePath), ".")
|
||||
result["audio_codec"] = result["format"]
|
||||
apeTag, apeErr := ReadAPETags(filePath)
|
||||
if apeErr == nil && apeTag != nil {
|
||||
meta := APETagToAudioMetadata(apeTag)
|
||||
if meta != nil {
|
||||
applyAudioMetadataToResult(result, meta)
|
||||
}
|
||||
}
|
||||
} else if isWav || isAiff {
|
||||
var meta *AudioMetadata
|
||||
var quality *WAVQuality
|
||||
var qualityErr error
|
||||
if isAiff {
|
||||
result["format"] = "aiff"
|
||||
result["audio_codec"] = "pcm"
|
||||
meta, _ = ReadAIFFTags(filePath)
|
||||
quality, qualityErr = GetAIFFQuality(filePath)
|
||||
} else {
|
||||
result["format"] = "wav"
|
||||
result["audio_codec"] = "pcm"
|
||||
meta, _ = ReadWAVTags(filePath)
|
||||
quality, qualityErr = GetWAVQuality(filePath)
|
||||
}
|
||||
if meta != nil {
|
||||
applyAudioMetadataToResult(result, meta)
|
||||
}
|
||||
if qualityErr == nil && quality != nil {
|
||||
result["bit_depth"] = quality.BitDepth
|
||||
result["sample_rate"] = quality.SampleRate
|
||||
result["duration"] = quality.Duration
|
||||
}
|
||||
} else {
|
||||
return "", fmt.Errorf("unsupported file format: %s", filePath)
|
||||
}
|
||||
|
||||
return marshalJSONString(result)
|
||||
}
|
||||
|
||||
// ParseCueSheet is called from Dart to get track listing and timing data for CUE splitting.
|
||||
// audioDir, if non-empty, overrides the directory used for resolving the
|
||||
// referenced audio file (useful for SAF temp file scenarios).
|
||||
func ParseCueSheet(cuePath string, audioDir string) (string, error) {
|
||||
return ParseCueFileJSON(cuePath, audioDir)
|
||||
}
|
||||
|
||||
// ScanCueSheetForLibrary parses a .cue file and returns a JSON array of
|
||||
// LibraryScanResult entries (one per track). This is the SAF-friendly variant:
|
||||
// - audioDir overrides where the referenced audio file is resolved
|
||||
// - virtualPathPrefix replaces cuePath in filePath / id fields (e.g. a content:// URI)
|
||||
// - fileModTime is stamped on every result (pass 0 to stat cuePath instead)
|
||||
func ScanCueSheetForLibrary(cuePath, audioDir, virtualPathPrefix string, fileModTime int64) (string, error) {
|
||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||
results, err := ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix, fileModTime, scanTime)
|
||||
if err != nil {
|
||||
return "[]", err
|
||||
}
|
||||
jsonBytes, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
return "[]", fmt.Errorf("failed to marshal cue scan results: %w", err)
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func ScanCueSheetForLibraryWithCoverCacheKey(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, coverCacheKey string) (string, error) {
|
||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||
results, err := ScanCueFileForLibraryExtWithCoverCacheKey(
|
||||
cuePath,
|
||||
audioDir,
|
||||
virtualPathPrefix,
|
||||
fileModTime,
|
||||
coverCacheKey,
|
||||
scanTime,
|
||||
)
|
||||
if err != nil {
|
||||
return "[]", err
|
||||
}
|
||||
jsonBytes, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
return "[]", fmt.Errorf("failed to marshal cue scan results: %w", err)
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// WriteM4AFreeformTags writes ISRC and label into an M4A/MP4 file as iTunes
|
||||
// freeform atoms. FFmpeg's MP4 muxer ignores these keys, so they must be
|
||||
// written natively after the FFmpeg metadata pass for the values to persist.
|
||||
// Only keys present in the JSON are touched; an empty value clears the tag.
|
||||
func WriteM4AFreeformTags(filePath, metadataJSON string) (string, error) {
|
||||
var fields map[string]string
|
||||
if err := json.Unmarshal([]byte(metadataJSON), &fields); err != nil {
|
||||
return "", fmt.Errorf("invalid metadata JSON: %w", err)
|
||||
}
|
||||
|
||||
if err := EditM4AFreeformText(filePath, fields); err != nil {
|
||||
return "", fmt.Errorf("failed to write M4A freeform tags: %w", err)
|
||||
}
|
||||
|
||||
return successMethodJSON("native_m4a_freeform")
|
||||
}
|
||||
|
||||
// EnsureAC4Config normalizes a decrypted AC-4 file to a standards-compliant ISO
|
||||
// MP4 and injects the dac4 configuration box copied from sourcePath. No-op when
|
||||
// the file is not AC-4.
|
||||
func EnsureAC4Config(filePath, sourcePath string) (string, error) {
|
||||
if err := EnsureAC4ConfigBox(filePath, sourcePath); err != nil {
|
||||
return "", fmt.Errorf("failed to finalize AC-4 container: %w", err)
|
||||
}
|
||||
return `{"success":true}`, nil
|
||||
}
|
||||
|
||||
// WriteAC4Metadata writes iTunes-style metadata into an AC-4 MP4. The JSON
|
||||
// "handled" field reports whether the file was AC-4 (true) so the caller can
|
||||
// skip the FFmpeg metadata pass that would re-wrap it as QuickTime.
|
||||
func WriteAC4Metadata(filePath, metadataJSON, coverPath string) (string, error) {
|
||||
handled, err := WriteAC4MetadataIfApplicable(filePath, metadataJSON, coverPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to write AC-4 metadata: %w", err)
|
||||
}
|
||||
resp := map[string]any{"success": true, "handled": handled}
|
||||
s, _ := marshalJSONString(resp)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// EditFileMetadata writes audio file tags: FLAC via native Go library, MP3/Opus returns map for Dart/FFmpeg.
|
||||
func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
||||
var fields map[string]string
|
||||
if err := json.Unmarshal([]byte(metadataJSON), &fields); err != nil {
|
||||
return "", fmt.Errorf("invalid metadata JSON: %w", err)
|
||||
}
|
||||
|
||||
lower := strings.ToLower(filePath)
|
||||
isFlac := strings.HasSuffix(lower, ".flac")
|
||||
isApeFile := strings.HasSuffix(lower, ".ape") || strings.HasSuffix(lower, ".wv") || strings.HasSuffix(lower, ".mpc")
|
||||
isM4AFile := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".m4b")
|
||||
isWavFile := strings.HasSuffix(lower, ".wav")
|
||||
isAiffFile := strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc")
|
||||
coverPath := strings.TrimSpace(fields["cover_path"])
|
||||
|
||||
if hasOnlyM4AReplayGainFields(fields) && (isM4AFile || isMP4ContainerFile(filePath)) {
|
||||
if err := EditM4AReplayGain(filePath, fields); err != nil {
|
||||
return "", fmt.Errorf("failed to write M4A metadata: %w", err)
|
||||
}
|
||||
|
||||
return successMethodJSON("native_m4a_replaygain")
|
||||
}
|
||||
|
||||
if isFlac {
|
||||
// A .flac name does not guarantee FLAC content: providers sometimes
|
||||
// deliver an MP4/M4A stream that ends up under the requested name.
|
||||
// The FLAC writer would fail "fLaC head incorrect" on every attempt,
|
||||
// so detect the mismatch up front and say what is actually wrong.
|
||||
if isMP4ContainerFile(filePath) {
|
||||
return "", fmt.Errorf(
|
||||
"failed to write FLAC metadata: file is an MP4/M4A stream under a .flac name; rename it to .m4a",
|
||||
)
|
||||
}
|
||||
if err := EditFlacFields(filePath, fields); err != nil {
|
||||
return "", fmt.Errorf("failed to write FLAC metadata: %w", err)
|
||||
}
|
||||
|
||||
return successMethodJSON("native")
|
||||
}
|
||||
|
||||
// WAV / AIFF: write tags into an embedded ID3v2.4 chunk natively.
|
||||
if isWavFile {
|
||||
if err := WriteWAVTags(filePath, fields); err != nil {
|
||||
return "", fmt.Errorf("failed to write WAV metadata: %w", err)
|
||||
}
|
||||
return successMethodJSON("native_wav")
|
||||
}
|
||||
if isAiffFile {
|
||||
if err := WriteAIFFTags(filePath, fields); err != nil {
|
||||
return "", fmt.Errorf("failed to write AIFF metadata: %w", err)
|
||||
}
|
||||
return successMethodJSON("native_aiff")
|
||||
}
|
||||
|
||||
if isApeFile {
|
||||
meta := audioMetadataFromEditFields(fields)
|
||||
|
||||
newItems := AudioMetadataToAPEItems(meta)
|
||||
|
||||
// If a cover image was provided, embed it as a binary APE item.
|
||||
// APEv2 cover format: "cover.jpg\0<binary image data>", flagged binary.
|
||||
if coverPath != "" {
|
||||
coverData, coverErr := os.ReadFile(coverPath)
|
||||
if coverErr == nil && len(coverData) > 0 {
|
||||
// The value is "filename\0" + raw bytes. We store the
|
||||
// description as the Value field, but since the item is
|
||||
// flagged binary, the writer serializes it verbatim.
|
||||
desc := "cover.jpg\x00"
|
||||
binaryValue := desc + string(coverData)
|
||||
newItems = append(newItems, APETagItem{
|
||||
Key: "Cover Art (Front)",
|
||||
Value: binaryValue,
|
||||
Flags: apeItemFlagBinary,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Build the set of APE keys that the edit explicitly controls.
|
||||
// Even if the value is empty (user cleared the field), the old
|
||||
// value must be removed during merge.
|
||||
overrideKeys := apeKeysFromFields(fields)
|
||||
if coverPath != "" {
|
||||
overrideKeys["COVER ART (FRONT)"] = struct{}{}
|
||||
}
|
||||
|
||||
// Read existing tags so we can merge rather than replace.
|
||||
// This preserves cover art and custom items not in the edit set.
|
||||
existingTag, _ := ReadAPETags(filePath)
|
||||
var finalItems []APETagItem
|
||||
if existingTag != nil && len(existingTag.Items) > 0 {
|
||||
finalItems = MergeAPEItems(existingTag.Items, newItems, overrideKeys)
|
||||
} else {
|
||||
finalItems = newItems
|
||||
}
|
||||
|
||||
tag := &APETag{
|
||||
Version: apeTagVersion2,
|
||||
Items: finalItems,
|
||||
}
|
||||
|
||||
if err := WriteAPETags(filePath, tag); err != nil {
|
||||
return "", fmt.Errorf("failed to write APE tags: %w", err)
|
||||
}
|
||||
|
||||
return successMethodJSON("native_ape")
|
||||
}
|
||||
|
||||
// MP3, Ogg/Opus, and M4A have native editors that preserve foreign
|
||||
// tags and skip the ffmpeg remux. Any failure falls back to the ffmpeg
|
||||
// response so callers keep the old behavior for exotic files.
|
||||
isMp3 := strings.HasSuffix(lower, ".mp3")
|
||||
isOggFile := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg")
|
||||
|
||||
if isMp3 {
|
||||
if err := EditMP3Fields(filePath, fields); err != nil {
|
||||
GoLog("[Metadata] Native MP3 edit failed, falling back to ffmpeg: %v\n", err)
|
||||
} else {
|
||||
return successMethodJSON("native_mp3")
|
||||
}
|
||||
}
|
||||
if isOggFile {
|
||||
if err := EditOggFields(filePath, fields); err != nil {
|
||||
GoLog("[Metadata] Native Ogg edit failed, falling back to ffmpeg: %v\n", err)
|
||||
} else {
|
||||
return successMethodJSON("native_ogg")
|
||||
}
|
||||
}
|
||||
if isM4AFile || isMP4ContainerFile(filePath) {
|
||||
if err := EditM4AFields(filePath, fields); err != nil {
|
||||
GoLog("[Metadata] Native M4A edit failed, falling back to ffmpeg: %v\n", err)
|
||||
} else {
|
||||
return successMethodJSON("native_m4a")
|
||||
}
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"success": true,
|
||||
"method": "ffmpeg",
|
||||
"fields": fields,
|
||||
}
|
||||
s, _ := marshalJSONString(resp)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func isMP4ContainerFile(filePath string) bool {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
header := make([]byte, 12)
|
||||
n, err := f.Read(header)
|
||||
if err != nil || n < 8 {
|
||||
return false
|
||||
}
|
||||
return string(header[4:8]) == "ftyp"
|
||||
}
|
||||
|
||||
func hasOnlyM4AReplayGainFields(fields map[string]string) bool {
|
||||
allowed := map[string]struct{}{
|
||||
"replaygain_track_gain": {},
|
||||
"replaygain_track_peak": {},
|
||||
"replaygain_album_gain": {},
|
||||
"replaygain_album_peak": {},
|
||||
}
|
||||
|
||||
hasReplayGain := false
|
||||
for key, value := range fields {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowed[strings.ToLower(strings.TrimSpace(key))]; ok {
|
||||
hasReplayGain = true
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return hasReplayGain
|
||||
}
|
||||
|
||||
// RewriteSplitArtistTagsExport rewrites ARTIST and ALBUMARTIST Vorbis
|
||||
// comments in a FLAC file as multiple separate entries (one per artist).
|
||||
// Call this after FFmpeg metadata embedding to fix split artist tags,
|
||||
// since FFmpeg deduplicates -metadata keys and only keeps the last value.
|
||||
func RewriteSplitArtistTagsExport(filePath, artist, albumArtist string) (string, error) {
|
||||
err := RewriteSplitArtistTags(filePath, artist, albumArtist)
|
||||
if err != nil {
|
||||
return errorResponse("Failed to rewrite artist tags: " + err.Error())
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"success": true,
|
||||
"message": "Split artist tags written successfully",
|
||||
}
|
||||
|
||||
s, _ := marshalJSONString(resp)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
|
||||
if coverURL == "" {
|
||||
return fmt.Errorf("no cover URL provided")
|
||||
}
|
||||
|
||||
data, err := downloadCoverToMemory(coverURL, maxQuality)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download cover: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(outputPath, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write cover file: %w", err)
|
||||
}
|
||||
|
||||
GoLog("[Cover] Downloaded cover to: %s (%d KB)\n", outputPath, len(data)/1024)
|
||||
return nil
|
||||
}
|
||||
|
||||
func ExtractCoverToFile(audioPath string, outputPath string) error {
|
||||
lower := strings.ToLower(audioPath)
|
||||
|
||||
var coverData []byte
|
||||
var err error
|
||||
|
||||
if strings.HasSuffix(lower, ".flac") {
|
||||
coverData, err = ExtractCoverArt(audioPath)
|
||||
} else if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") {
|
||||
coverData, err = extractCoverFromM4A(audioPath)
|
||||
} else if strings.HasSuffix(lower, ".mp3") {
|
||||
coverData, _, err = extractMP3CoverArt(audioPath)
|
||||
} else if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
|
||||
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 {
|
||||
return fmt.Errorf("unsupported audio format for cover extraction")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract cover: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(outputPath, coverData, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write cover file: %w", err)
|
||||
}
|
||||
|
||||
GoLog("[Cover] Extracted cover art to: %s (%d KB)\n", outputPath, len(coverData)/1024)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
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 {
|
||||
Count int `json:"count"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type musicBrainzRecordingResponse struct {
|
||||
Recordings []struct {
|
||||
Tags []musicBrainzTag `json:"tags"`
|
||||
} `json:"recordings"`
|
||||
}
|
||||
|
||||
type musicBrainzArtistCredit struct {
|
||||
Name string `json:"name"`
|
||||
JoinPhrase string `json:"joinphrase"`
|
||||
}
|
||||
|
||||
type musicBrainzRelease struct {
|
||||
Title string `json:"title"`
|
||||
ArtistCredit []musicBrainzArtistCredit `json:"artist-credit"`
|
||||
}
|
||||
|
||||
type musicBrainzAlbumArtistResponse struct {
|
||||
Recordings []struct {
|
||||
Releases []musicBrainzRelease `json:"releases"`
|
||||
} `json:"recordings"`
|
||||
}
|
||||
|
||||
func formatMusicBrainzGenre(tags []musicBrainzTag) string {
|
||||
if len(tags) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
caser := cases.Title(language.English)
|
||||
seen := make(map[string]struct{}, len(tags))
|
||||
maxCount := -1
|
||||
bestTag := ""
|
||||
|
||||
for _, tag := range tags {
|
||||
name := strings.TrimSpace(tag.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.ToLower(name)
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
|
||||
formatted := caser.String(name)
|
||||
if tag.Count > maxCount {
|
||||
maxCount = tag.Count
|
||||
bestTag = formatted
|
||||
}
|
||||
}
|
||||
|
||||
return bestTag
|
||||
}
|
||||
|
||||
func formatMusicBrainzArtistCredit(credits []musicBrainzArtistCredit) string {
|
||||
var builder strings.Builder
|
||||
for _, credit := range credits {
|
||||
name := strings.TrimSpace(credit.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
builder.WriteString(name)
|
||||
builder.WriteString(credit.JoinPhrase)
|
||||
}
|
||||
return strings.TrimSpace(builder.String())
|
||||
}
|
||||
|
||||
func selectMusicBrainzAlbumArtist(releases []musicBrainzRelease, albumName string) string {
|
||||
if len(releases) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
normalizedAlbum := strings.ToLower(strings.TrimSpace(albumName))
|
||||
if normalizedAlbum != "" {
|
||||
for _, release := range releases {
|
||||
if strings.ToLower(strings.TrimSpace(release.Title)) != normalizedAlbum {
|
||||
continue
|
||||
}
|
||||
if albumArtist := formatMusicBrainzArtistCredit(release.ArtistCredit); albumArtist != "" {
|
||||
return albumArtist
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, release := range releases {
|
||||
if albumArtist := formatMusicBrainzArtistCredit(release.ArtistCredit); albumArtist != "" {
|
||||
return albumArtist
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// fetchMusicBrainzRecordingByISRC queries the MusicBrainz recording endpoint
|
||||
// for the given ISRC with the supplied inc= parameter, retrying up to 3 times,
|
||||
// and decodes the JSON response into payload. It returns the normalized ISRC.
|
||||
func fetchMusicBrainzRecordingByISRC(isrc string, inc string, payload any) (string, error) {
|
||||
normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc))
|
||||
if normalizedISRC == "" {
|
||||
return "", fmt.Errorf("no ISRC provided")
|
||||
}
|
||||
|
||||
client := NewMetadataHTTPClient(10 * time.Second)
|
||||
query := fmt.Sprintf("isrc:%s", normalizedISRC)
|
||||
reqURL := fmt.Sprintf(
|
||||
"%s/recording?query=%s&fmt=json&inc=%s",
|
||||
musicBrainzAPIBase,
|
||||
url.QueryEscape(query),
|
||||
inc,
|
||||
)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
var resp *http.Response
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
resp, lastErr = client.Do(req)
|
||||
if lastErr == nil && resp.StatusCode == http.StatusOK {
|
||||
break
|
||||
}
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if attempt < 2 {
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return "", lastErr
|
||||
}
|
||||
if resp == nil {
|
||||
return "", fmt.Errorf("MusicBrainz request failed without response")
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return "", fmt.Errorf("MusicBrainz API returned status: %d", resp.StatusCode)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return normalizedISRC, nil
|
||||
}
|
||||
|
||||
func FetchMusicBrainzAlbumArtistByISRC(isrc string, albumName string) (string, error) {
|
||||
key := "albumartist\x00" + strings.ToUpper(strings.TrimSpace(isrc)) +
|
||||
"\x00" + strings.ToLower(strings.TrimSpace(albumName))
|
||||
return musicBrainzCached(key, func() (string, error) {
|
||||
var payload musicBrainzAlbumArtistResponse
|
||||
normalizedISRC, err := fetchMusicBrainzRecordingByISRC(isrc, "releases+artist-credits", &payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
func FetchMusicBrainzGenreByISRC(isrc string) (string, error) {
|
||||
key := "genre\x00" + strings.ToUpper(strings.TrimSpace(isrc))
|
||||
return musicBrainzCached(key, func() (string, error) {
|
||||
var payload musicBrainzRecordingResponse
|
||||
normalizedISRC, err := fetchMusicBrainzRecordingByISRC(isrc, "tags", &payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(payload.Recordings) == 0 {
|
||||
return "", fmt.Errorf("no recordings found for ISRC: %s", normalizedISRC)
|
||||
}
|
||||
|
||||
genre := formatMusicBrainzGenre(payload.Recordings[0].Tags)
|
||||
if genre == "" {
|
||||
return "", fmt.Errorf("no MusicBrainz genre tags found for ISRC: %s", normalizedISRC)
|
||||
}
|
||||
return genre, nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,793 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
||||
return GetDeezerClient().GetExtendedMetadataByISRC(ctx, isrc)
|
||||
}
|
||||
|
||||
var fetchMusicBrainzGenreByISRC = FetchMusicBrainzGenreByISRC
|
||||
|
||||
var fetchMusicBrainzAlbumArtistByISRC = FetchMusicBrainzAlbumArtistByISRC
|
||||
|
||||
type reEnrichRequest struct {
|
||||
FilePath string `json:"file_path"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
MaxQuality bool `json:"max_quality"`
|
||||
EmbedLyrics bool `json:"embed_lyrics"`
|
||||
LyricsMode string `json:"lyrics_mode,omitempty"`
|
||||
ArtistTagMode string `json:"artist_tag_mode,omitempty"`
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
AlbumName string `json:"album_name"`
|
||||
AlbumArtist string `json:"album_artist"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
TotalTracks int `json:"total_tracks,omitempty"`
|
||||
TotalDiscs int `json:"total_discs,omitempty"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
ISRC string `json:"isrc"`
|
||||
Genre string `json:"genre"`
|
||||
Label string `json:"label"`
|
||||
Copyright string `json:"copyright"`
|
||||
Composer string `json:"composer"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
SearchOnline bool `json:"search_online"`
|
||||
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.
|
||||
// When UpdateFields is empty/nil, all fields are updated (backward compatible).
|
||||
func (r *reEnrichRequest) shouldUpdateField(field string) bool {
|
||||
if len(r.UpdateFields) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, f := range r.UpdateFields {
|
||||
if f == field {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// lyricsEmbedEnabled reports whether lyrics should be written into the audio
|
||||
// file's tags. It mirrors the download path semantics: 'embed' and 'both' embed,
|
||||
// 'external' does not. An empty mode keeps the legacy behavior (embed) so older
|
||||
// callers that do not send lyrics_mode are unaffected.
|
||||
func (r *reEnrichRequest) lyricsEmbedEnabled() bool {
|
||||
return strings.ToLower(strings.TrimSpace(r.LyricsMode)) != "external"
|
||||
}
|
||||
|
||||
// lyricsSidecarEnabled reports whether a .lrc sidecar file should be written
|
||||
// next to the audio file. Only 'external' and 'both' request a sidecar.
|
||||
func (r *reEnrichRequest) lyricsSidecarEnabled() bool {
|
||||
mode := strings.ToLower(strings.TrimSpace(r.LyricsMode))
|
||||
return mode == "external" || mode == "both"
|
||||
}
|
||||
|
||||
// reEnrichSameRelease reports whether the candidate track appears to come
|
||||
// from the same release as the file's existing album. An ISRC identifies a
|
||||
// recording, not a release: the same song often also resolves to a
|
||||
// compilation, whose album name, cover, and track positions must not
|
||||
// replace the original release's.
|
||||
func reEnrichSameRelease(currentAlbum, candidateAlbum string) bool {
|
||||
if isPlaceholderReEnrichValue(currentAlbum) ||
|
||||
strings.TrimSpace(candidateAlbum) == "" {
|
||||
return true
|
||||
}
|
||||
return titlesMatch(currentAlbum, candidateAlbum)
|
||||
}
|
||||
|
||||
func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
|
||||
albumMatches := reEnrichSameRelease(req.AlbumName, track.AlbumName)
|
||||
sameRelease := req.ReplaceReleaseMetadata || albumMatches
|
||||
if !sameRelease {
|
||||
GoLog("[ReEnrich] Candidate album %q differs from file album %q; keeping release identity (album, cover, positions, date)\n",
|
||||
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 != "" {
|
||||
req.SpotifyID = track.SpotifyID
|
||||
} else if track.DeezerID != "" {
|
||||
req.SpotifyID = "deezer:" + track.DeezerID
|
||||
} else if track.QobuzID != "" {
|
||||
req.SpotifyID = "qobuz:" + track.QobuzID
|
||||
} else if track.TidalID != "" {
|
||||
req.SpotifyID = "tidal:" + track.TidalID
|
||||
} else if track.ID != "" {
|
||||
req.SpotifyID = track.ID
|
||||
}
|
||||
|
||||
if req.shouldUpdateField("basic_tags") {
|
||||
if track.Name != "" {
|
||||
req.TrackName = track.Name
|
||||
}
|
||||
if track.Artists != "" {
|
||||
req.ArtistName = track.Artists
|
||||
}
|
||||
if sameRelease {
|
||||
if track.AlbumName != "" {
|
||||
req.AlbumName = track.AlbumName
|
||||
}
|
||||
if track.AlbumArtist != "" {
|
||||
req.AlbumArtist = track.AlbumArtist
|
||||
}
|
||||
}
|
||||
}
|
||||
if sameRelease && req.shouldUpdateField("track_info") {
|
||||
if track.TrackNumber > 0 {
|
||||
req.TrackNumber = track.TrackNumber
|
||||
}
|
||||
if track.TotalTracks > 0 {
|
||||
req.TotalTracks = track.TotalTracks
|
||||
}
|
||||
if track.DiscNumber > 0 {
|
||||
req.DiscNumber = track.DiscNumber
|
||||
}
|
||||
if track.TotalDiscs > 0 {
|
||||
req.TotalDiscs = track.TotalDiscs
|
||||
}
|
||||
}
|
||||
if req.shouldUpdateField("release_info") {
|
||||
if sameRelease && track.ReleaseDate != "" {
|
||||
req.ReleaseDate = track.ReleaseDate
|
||||
}
|
||||
if track.ISRC != "" {
|
||||
req.ISRC = track.ISRC
|
||||
}
|
||||
}
|
||||
if sameRelease && req.shouldUpdateField("cover") {
|
||||
if coverURL := track.ResolvedCoverURL(); coverURL != "" {
|
||||
req.CoverURL = coverURL
|
||||
}
|
||||
}
|
||||
if track.DurationMS > 0 {
|
||||
req.DurationMs = int64(track.DurationMS)
|
||||
}
|
||||
if req.shouldUpdateField("extra") {
|
||||
if track.Genre != "" {
|
||||
req.Genre = track.Genre
|
||||
}
|
||||
if track.Label != "" {
|
||||
req.Label = track.Label
|
||||
}
|
||||
if track.Copyright != "" {
|
||||
req.Copyright = track.Copyright
|
||||
}
|
||||
if track.Composer != "" {
|
||||
req.Composer = track.Composer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isPlaceholderReEnrichValue(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "unknown", "unknown artist", "unknown title", "unknown album":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func buildReEnrichSearchQuery(req reEnrichRequest) string {
|
||||
parts := make([]string, 0, 2)
|
||||
if !isPlaceholderReEnrichValue(req.TrackName) {
|
||||
parts = append(parts, strings.TrimSpace(req.TrackName))
|
||||
}
|
||||
if !isPlaceholderReEnrichValue(req.ArtistName) {
|
||||
parts = append(parts, strings.TrimSpace(req.ArtistName))
|
||||
}
|
||||
if len(parts) == 0 && !isPlaceholderReEnrichValue(req.AlbumName) {
|
||||
parts = append(parts, strings.TrimSpace(req.AlbumName))
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(parts, " "))
|
||||
}
|
||||
|
||||
func reEnrichDownloadRequest(req reEnrichRequest) DownloadRequest {
|
||||
return DownloadRequest{
|
||||
TrackName: req.TrackName,
|
||||
ArtistName: req.ArtistName,
|
||||
AlbumName: req.AlbumName,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
ISRC: req.ISRC,
|
||||
DurationMS: int(req.DurationMs),
|
||||
ArtistTagMode: req.ArtistTagMode,
|
||||
TrackNumber: req.TrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: req.DiscNumber,
|
||||
TotalDiscs: req.TotalDiscs,
|
||||
Composer: req.Composer,
|
||||
}
|
||||
}
|
||||
|
||||
func buildReEnrichFFmpegMetadata(req *reEnrichRequest, lyricsLRC string) map[string]string {
|
||||
metadata := map[string]string{}
|
||||
if req.shouldUpdateField("basic_tags") {
|
||||
if req.TrackName != "" {
|
||||
metadata["TITLE"] = req.TrackName
|
||||
}
|
||||
if req.ArtistName != "" {
|
||||
metadata["ARTIST"] = req.ArtistName
|
||||
}
|
||||
if req.AlbumName != "" {
|
||||
metadata["ALBUM"] = req.AlbumName
|
||||
}
|
||||
if req.AlbumArtist != "" {
|
||||
metadata["ALBUMARTIST"] = req.AlbumArtist
|
||||
}
|
||||
}
|
||||
if req.shouldUpdateField("release_info") {
|
||||
if req.ReleaseDate != "" {
|
||||
metadata["DATE"] = req.ReleaseDate
|
||||
}
|
||||
if req.ISRC != "" {
|
||||
metadata["ISRC"] = req.ISRC
|
||||
}
|
||||
}
|
||||
if req.shouldUpdateField("extra") {
|
||||
if req.Genre != "" {
|
||||
metadata["GENRE"] = req.Genre
|
||||
}
|
||||
if req.Label != "" {
|
||||
metadata["ORGANIZATION"] = req.Label
|
||||
}
|
||||
if req.Copyright != "" {
|
||||
metadata["COPYRIGHT"] = req.Copyright
|
||||
}
|
||||
if req.Composer != "" {
|
||||
metadata["COMPOSER"] = req.Composer
|
||||
}
|
||||
}
|
||||
if req.shouldUpdateField("track_info") {
|
||||
if req.TrackNumber > 0 {
|
||||
metadata["TRACKNUMBER"] = formatIndexValue(req.TrackNumber, req.TotalTracks)
|
||||
}
|
||||
if req.DiscNumber > 0 {
|
||||
metadata["DISCNUMBER"] = formatIndexValue(req.DiscNumber, req.TotalDiscs)
|
||||
}
|
||||
}
|
||||
if req.shouldUpdateField("lyrics") {
|
||||
if lyricsLRC != "" && req.lyricsEmbedEnabled() {
|
||||
metadata["LYRICS"] = lyricsLRC
|
||||
metadata["UNSYNCEDLYRICS"] = lyricsLRC
|
||||
}
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func selectBestReEnrichTrack(req reEnrichRequest, tracks []ExtTrackMetadata) *ExtTrackMetadata {
|
||||
if len(tracks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
downloadReq := reEnrichDownloadRequest(req)
|
||||
currentISRC := strings.TrimSpace(req.ISRC)
|
||||
currentAlbum := strings.TrimSpace(req.AlbumName)
|
||||
effectiveTrackName := req.TrackName
|
||||
if isPlaceholderReEnrichValue(effectiveTrackName) {
|
||||
effectiveTrackName = ""
|
||||
}
|
||||
effectiveArtistName := req.ArtistName
|
||||
if isPlaceholderReEnrichValue(effectiveArtistName) {
|
||||
effectiveArtistName = ""
|
||||
}
|
||||
var best *ExtTrackMetadata
|
||||
bestScore := -1 << 30
|
||||
|
||||
for i := range tracks {
|
||||
track := &tracks[i]
|
||||
score := 0
|
||||
exactISRCMatch := currentISRC != "" && strings.EqualFold(currentISRC, strings.TrimSpace(track.ISRC))
|
||||
titleMatches := effectiveTrackName != "" && track.Name != "" && titlesMatch(effectiveTrackName, track.Name)
|
||||
artistMatches := effectiveArtistName != "" && track.Artists != "" && artistsMatch(effectiveArtistName, track.Artists)
|
||||
albumMatches := currentAlbum != "" && track.AlbumName != "" && titlesMatch(currentAlbum, track.AlbumName)
|
||||
|
||||
resolved := resolvedTrackInfo{
|
||||
Title: track.Name,
|
||||
ArtistName: track.Artists,
|
||||
ISRC: track.ISRC,
|
||||
Duration: track.DurationMS / 1000,
|
||||
}
|
||||
verified := trackMatchesRequest(downloadReq, resolved, "ReEnrich")
|
||||
|
||||
if !exactISRCMatch {
|
||||
if effectiveTrackName != "" && !titleMatches {
|
||||
continue
|
||||
}
|
||||
if effectiveArtistName != "" && !artistMatches {
|
||||
continue
|
||||
}
|
||||
if effectiveTrackName == "" && effectiveArtistName == "" && currentAlbum != "" && !albumMatches {
|
||||
continue
|
||||
}
|
||||
if effectiveTrackName == "" && effectiveArtistName == "" && currentAlbum == "" && !verified {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if verified {
|
||||
score += 2000
|
||||
}
|
||||
|
||||
if exactISRCMatch {
|
||||
score += 10000
|
||||
}
|
||||
if titleMatches {
|
||||
score += 400
|
||||
}
|
||||
if artistMatches {
|
||||
score += 320
|
||||
}
|
||||
if currentAlbum != "" && track.AlbumName != "" {
|
||||
switch {
|
||||
case albumMatches:
|
||||
score += 120
|
||||
case strings.Contains(strings.ToLower(track.AlbumName), strings.ToLower(currentAlbum)),
|
||||
strings.Contains(strings.ToLower(currentAlbum), strings.ToLower(track.AlbumName)):
|
||||
score += 50
|
||||
}
|
||||
}
|
||||
|
||||
if req.DurationMs > 0 && track.DurationMS > 0 {
|
||||
diff := int(req.DurationMs/1000) - (track.DurationMS / 1000)
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff <= 10 {
|
||||
score += 80
|
||||
}
|
||||
}
|
||||
|
||||
if track.ReleaseDate != "" {
|
||||
score += 70
|
||||
}
|
||||
if track.TrackNumber > 0 {
|
||||
score += 20
|
||||
}
|
||||
if track.DiscNumber > 0 {
|
||||
score += 10
|
||||
}
|
||||
if track.ISRC != "" {
|
||||
score += 40
|
||||
}
|
||||
|
||||
if best == nil || score > bestScore {
|
||||
best = track
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
func extTrackFromTrackMetadata(track *TrackMetadata, providerID string) *ExtTrackMetadata {
|
||||
if track == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:"))
|
||||
return &ExtTrackMetadata{
|
||||
ID: track.SpotifyID,
|
||||
Name: track.Name,
|
||||
Artists: track.Artists,
|
||||
AlbumName: track.AlbumName,
|
||||
AlbumArtist: track.AlbumArtist,
|
||||
DurationMS: track.DurationMS,
|
||||
CoverURL: track.Images,
|
||||
Images: track.Images,
|
||||
ReleaseDate: track.ReleaseDate,
|
||||
TrackNumber: track.TrackNumber,
|
||||
TotalTracks: track.TotalTracks,
|
||||
DiscNumber: track.DiscNumber,
|
||||
TotalDiscs: track.TotalDiscs,
|
||||
ISRC: track.ISRC,
|
||||
ProviderID: providerID,
|
||||
DeezerID: deezerID,
|
||||
SpotifyID: track.SpotifyID,
|
||||
Composer: track.Composer,
|
||||
Explicit: track.Explicit,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeReEnrichSpotifyTrackID(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if extracted := extractSpotifyIDFromURL(trimmed); extracted != "" {
|
||||
return extracted
|
||||
}
|
||||
if len(trimmed) == 22 && !strings.Contains(trimmed, ":") && !strings.Contains(trimmed, "/") {
|
||||
return trimmed
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveReEnrichTrackFromIdentifiers(req reEnrichRequest) (*ExtTrackMetadata, error) {
|
||||
deezerClient := GetDeezerClient()
|
||||
downloadReq := reEnrichDownloadRequest(req)
|
||||
|
||||
if isrc := strings.TrimSpace(req.ISRC); isrc != "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
track, err := deezerClient.SearchByISRC(ctx, isrc)
|
||||
cancel()
|
||||
if err == nil && track != nil {
|
||||
resolved := resolvedTrackInfo{
|
||||
Title: track.Name,
|
||||
ArtistName: track.Artists,
|
||||
ISRC: track.ISRC,
|
||||
Duration: track.DurationMS / 1000,
|
||||
}
|
||||
if trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
|
||||
return extTrackFromTrackMetadata(track, "deezer"), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sourceTrackID := strings.TrimSpace(req.SpotifyID)
|
||||
if sourceTrackID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
deezerID := strings.TrimSpace(strings.TrimPrefix(sourceTrackID, "deezer:"))
|
||||
if deezerID == sourceTrackID {
|
||||
deezerID = extractDeezerIDFromURL(sourceTrackID)
|
||||
}
|
||||
if deezerID == "" {
|
||||
spotifyID := normalizeReEnrichSpotifyTrackID(sourceTrackID)
|
||||
if spotifyID != "" {
|
||||
resolvedDeezerID, err := NewSongLinkClient().GetDeezerIDFromSpotify(spotifyID)
|
||||
if err == nil {
|
||||
deezerID = strings.TrimSpace(resolvedDeezerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if deezerID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
|
||||
if err != nil || trackResp == nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
track := &trackResp.Track
|
||||
resolved := resolvedTrackInfo{
|
||||
Title: track.Name,
|
||||
ArtistName: track.Artists,
|
||||
ISRC: track.ISRC,
|
||||
Duration: track.DurationMS / 1000,
|
||||
}
|
||||
if !trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return extTrackFromTrackMetadata(track, "deezer"), nil
|
||||
}
|
||||
|
||||
func preferredReleaseMetadata(
|
||||
req DownloadRequest,
|
||||
album string,
|
||||
releaseDate string,
|
||||
trackNumber int,
|
||||
discNumber int,
|
||||
) (string, string, int, int) {
|
||||
preferredAlbum := strings.TrimSpace(req.AlbumName)
|
||||
if preferredAlbum == "" {
|
||||
preferredAlbum = album
|
||||
}
|
||||
|
||||
preferredReleaseDate := strings.TrimSpace(req.ReleaseDate)
|
||||
if preferredReleaseDate == "" {
|
||||
preferredReleaseDate = releaseDate
|
||||
}
|
||||
|
||||
preferredTrackNumber := req.TrackNumber
|
||||
if preferredTrackNumber == 0 {
|
||||
preferredTrackNumber = trackNumber
|
||||
}
|
||||
|
||||
preferredDiscNumber := req.DiscNumber
|
||||
if preferredDiscNumber == 0 {
|
||||
preferredDiscNumber = discNumber
|
||||
}
|
||||
|
||||
return preferredAlbum, preferredReleaseDate, preferredTrackNumber, preferredDiscNumber
|
||||
}
|
||||
|
||||
// ReEnrichFile re-embeds metadata, cover art, and lyrics into an existing audio file.
|
||||
// When search_online is true, searches Spotify/Deezer by track name + artist to fetch
|
||||
// complete metadata from the internet before embedding.
|
||||
func ReEnrichFile(requestJSON string) (string, error) {
|
||||
var req reEnrichRequest
|
||||
|
||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||
return "", fmt.Errorf("failed to parse request: %w", err)
|
||||
}
|
||||
|
||||
if req.FilePath == "" {
|
||||
return "", fmt.Errorf("file_path is required")
|
||||
}
|
||||
|
||||
GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath)
|
||||
|
||||
if req.SearchOnline {
|
||||
found := false
|
||||
|
||||
GoLog("[ReEnrich] Trying metadata providers in configured priority...\n")
|
||||
manager := getExtensionManager()
|
||||
if identifierTrack, err := resolveReEnrichTrackFromIdentifiers(req); err == nil && identifierTrack != nil {
|
||||
GoLog("[ReEnrich] Identifier-first metadata match (%s): %s - %s (album: %s, date: %s)\n",
|
||||
identifierTrack.ProviderID, identifierTrack.Name, identifierTrack.Artists, identifierTrack.AlbumName, identifierTrack.ReleaseDate)
|
||||
applyReEnrichTrackMetadata(&req, *identifierTrack)
|
||||
found = true
|
||||
}
|
||||
|
||||
searchQuery := buildReEnrichSearchQuery(req)
|
||||
if searchQuery != "" {
|
||||
GoLog("[ReEnrich] Searching online metadata for query: %s\n", searchQuery)
|
||||
tracks, searchErr := manager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
|
||||
if searchErr == nil && len(tracks) > 0 {
|
||||
track := selectBestReEnrichTrack(req, tracks)
|
||||
if track != nil {
|
||||
GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s, date: %s)\n",
|
||||
track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate)
|
||||
applyReEnrichTrackMetadata(&req, *track)
|
||||
found = true
|
||||
}
|
||||
} else if searchErr != nil {
|
||||
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
|
||||
}
|
||||
} else {
|
||||
GoLog("[ReEnrich] Skipping provider search: no usable title/artist/album query\n")
|
||||
}
|
||||
|
||||
if req.shouldUpdateField("basic_tags") && req.AlbumArtist == "" && req.ISRC != "" {
|
||||
albumArtist, err := fetchMusicBrainzAlbumArtistByISRC(req.ISRC, req.AlbumName)
|
||||
if err != nil {
|
||||
GoLog("[ReEnrich] Failed to get album artist from MusicBrainz: %v\n", err)
|
||||
} else if strings.TrimSpace(albumArtist) != "" {
|
||||
req.AlbumArtist = strings.TrimSpace(albumArtist)
|
||||
GoLog("[ReEnrich] Album artist fallback from MusicBrainz: %s\n", req.AlbumArtist)
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
// Try to enrich extra metadata from ISRC if not already set.
|
||||
if found && req.ISRC != "" && req.shouldUpdateField("extra") && (req.Genre == "" || req.Label == "" || req.Copyright == "") {
|
||||
enrichExtraMetadataByISRC("ReEnrich", req.ISRC, &req.Genre, &req.Label, &req.Copyright)
|
||||
}
|
||||
|
||||
if !found {
|
||||
GoLog("[ReEnrich] No online match found, using existing metadata\n")
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[ReEnrich] Metadata to embed: title=%s, artist=%s, album=%s, albumArtist=%s\n",
|
||||
req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist)
|
||||
GoLog("[ReEnrich] track=%d, disc=%d, date=%s, isrc=%s, genre=%s, label=%s\n",
|
||||
req.TrackNumber, req.DiscNumber, req.ReleaseDate, req.ISRC, req.Genre, req.Label)
|
||||
|
||||
lower := strings.ToLower(req.FilePath)
|
||||
isFlac := strings.HasSuffix(lower, ".flac")
|
||||
|
||||
var coverTempPath string
|
||||
var coverDataBytes []byte
|
||||
if req.CoverURL != "" && req.shouldUpdateField("cover") {
|
||||
coverData, err := downloadCoverToMemory(req.CoverURL, req.MaxQuality)
|
||||
if err != nil {
|
||||
GoLog("[ReEnrich] Failed to download cover: %v\n", err)
|
||||
} else {
|
||||
coverDataBytes = coverData
|
||||
GoLog("[ReEnrich] Cover downloaded: %d KB\n", len(coverData)/1024)
|
||||
// MP3/Opus requires a real image file path for Dart FFmpeg.
|
||||
// FLAC uses in-memory embed and does not require temp files.
|
||||
if !isFlac {
|
||||
tmpFile, err := os.CreateTemp("", "reenrich_cover_*.jpg")
|
||||
if err != nil {
|
||||
fallbackDir := filepath.Dir(req.FilePath)
|
||||
if fallbackDir == "" || fallbackDir == "." {
|
||||
GoLog("[ReEnrich] Failed to create cover temp file: %v\n", err)
|
||||
} else {
|
||||
tmpFile, err = os.CreateTemp(fallbackDir, "reenrich_cover_*.jpg")
|
||||
if err != nil {
|
||||
GoLog("[ReEnrich] Failed to create cover temp file (fallback dir %s): %v\n", fallbackDir, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == nil && tmpFile != nil {
|
||||
coverTempPath = tmpFile.Name()
|
||||
if _, writeErr := tmpFile.Write(coverData); writeErr != nil {
|
||||
GoLog("[ReEnrich] Failed writing cover temp file: %v\n", writeErr)
|
||||
tmpFile.Close()
|
||||
os.Remove(coverTempPath)
|
||||
coverTempPath = ""
|
||||
} else if closeErr := tmpFile.Close(); closeErr != nil {
|
||||
GoLog("[ReEnrich] Failed closing cover temp file: %v\n", closeErr)
|
||||
os.Remove(coverTempPath)
|
||||
coverTempPath = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only cleanup cover temp for FLAC (native embed).
|
||||
// For MP3/Opus, Dart needs the file for FFmpeg — Dart handles cleanup.
|
||||
cleanupCover := true
|
||||
|
||||
defer func() {
|
||||
if cleanupCover && coverTempPath != "" {
|
||||
os.Remove(coverTempPath)
|
||||
}
|
||||
}()
|
||||
|
||||
// Preserve existing lyrics when online enrichment does not return a replacement.
|
||||
var lyricsLRC string
|
||||
if req.shouldUpdateField("lyrics") {
|
||||
existingLyrics, existingLyricsErr := ExtractLyrics(req.FilePath)
|
||||
if existingLyricsErr == nil && strings.TrimSpace(existingLyrics) != "" {
|
||||
lyricsLRC = existingLyrics
|
||||
GoLog("[ReEnrich] Preserving existing embedded/sidecar lyrics\n")
|
||||
}
|
||||
}
|
||||
|
||||
if req.EmbedLyrics && req.shouldUpdateField("lyrics") {
|
||||
client := NewLyricsClient()
|
||||
durationSec := float64(req.DurationMs) / 1000.0
|
||||
lyrics, err := client.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, durationSec)
|
||||
if err != nil {
|
||||
GoLog("[ReEnrich] Lyrics not found: %v\n", err)
|
||||
} else if !lyrics.Instrumental {
|
||||
lyricsLRC = convertToLRCWithMetadata(lyrics, req.TrackName, req.ArtistName)
|
||||
GoLog("[ReEnrich] Lyrics fetched: %d lines\n", len(lyrics.Lines))
|
||||
} else {
|
||||
GoLog("[ReEnrich] Track is instrumental\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Build enrichedMeta map: only include fields from selected update groups
|
||||
// so that the caller (Dart) does not overwrite non-selected metadata in its
|
||||
// local library database with potentially stale cached values.
|
||||
enrichedMeta := map[string]any{
|
||||
"spotify_id": req.SpotifyID,
|
||||
"duration_ms": req.DurationMs,
|
||||
}
|
||||
if req.shouldUpdateField("basic_tags") {
|
||||
enrichedMeta["track_name"] = req.TrackName
|
||||
enrichedMeta["artist_name"] = req.ArtistName
|
||||
enrichedMeta["album_name"] = req.AlbumName
|
||||
enrichedMeta["album_artist"] = req.AlbumArtist
|
||||
}
|
||||
if req.shouldUpdateField("track_info") {
|
||||
enrichedMeta["track_number"] = req.TrackNumber
|
||||
enrichedMeta["total_tracks"] = req.TotalTracks
|
||||
enrichedMeta["disc_number"] = req.DiscNumber
|
||||
enrichedMeta["total_discs"] = req.TotalDiscs
|
||||
}
|
||||
if req.shouldUpdateField("release_info") {
|
||||
enrichedMeta["release_date"] = req.ReleaseDate
|
||||
enrichedMeta["isrc"] = req.ISRC
|
||||
}
|
||||
if req.shouldUpdateField("cover") {
|
||||
enrichedMeta["cover_url"] = req.CoverURL
|
||||
}
|
||||
if req.shouldUpdateField("extra") {
|
||||
enrichedMeta["genre"] = req.Genre
|
||||
enrichedMeta["label"] = req.Label
|
||||
enrichedMeta["copyright"] = req.Copyright
|
||||
enrichedMeta["composer"] = req.Composer
|
||||
}
|
||||
|
||||
if isFlac {
|
||||
// Only populate Metadata fields for selected update groups; empty/zero
|
||||
// values cause EmbedMetadata's setComment() to skip those tags,
|
||||
// preserving whatever is already in the file.
|
||||
metadata := Metadata{
|
||||
ArtistTagMode: req.ArtistTagMode,
|
||||
}
|
||||
if req.shouldUpdateField("basic_tags") {
|
||||
metadata.Title = req.TrackName
|
||||
metadata.Artist = req.ArtistName
|
||||
metadata.Album = req.AlbumName
|
||||
metadata.AlbumArtist = req.AlbumArtist
|
||||
}
|
||||
if req.shouldUpdateField("track_info") {
|
||||
metadata.TrackNumber = req.TrackNumber
|
||||
metadata.TotalTracks = req.TotalTracks
|
||||
metadata.DiscNumber = req.DiscNumber
|
||||
metadata.TotalDiscs = req.TotalDiscs
|
||||
}
|
||||
if req.shouldUpdateField("release_info") {
|
||||
metadata.Date = req.ReleaseDate
|
||||
metadata.ISRC = req.ISRC
|
||||
}
|
||||
if req.shouldUpdateField("lyrics") {
|
||||
if req.lyricsEmbedEnabled() {
|
||||
metadata.Lyrics = lyricsLRC
|
||||
}
|
||||
}
|
||||
if req.shouldUpdateField("extra") {
|
||||
metadata.Genre = req.Genre
|
||||
metadata.Label = req.Label
|
||||
metadata.Copyright = req.Copyright
|
||||
metadata.Composer = req.Composer
|
||||
}
|
||||
|
||||
if len(coverDataBytes) > 0 {
|
||||
if err := EmbedMetadataWithCoverData(req.FilePath, metadata, coverDataBytes); err != nil {
|
||||
return "", fmt.Errorf("failed to embed metadata with cover: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := EmbedMetadata(req.FilePath, metadata, ""); err != nil {
|
||||
return "", fmt.Errorf("failed to embed metadata: %w", err)
|
||||
}
|
||||
}
|
||||
if len(coverDataBytes) > 0 {
|
||||
embeddedCover, err := ExtractCoverArt(req.FilePath)
|
||||
if err != nil || len(embeddedCover) == 0 {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("metadata embedded but cover verification failed: %w", err)
|
||||
}
|
||||
return "", fmt.Errorf("metadata embedded but cover verification failed: empty embedded cover")
|
||||
}
|
||||
GoLog("[ReEnrich] Cover verified after embed (%d bytes)\n", len(embeddedCover))
|
||||
}
|
||||
|
||||
GoLog("[ReEnrich] FLAC metadata embedded successfully\n")
|
||||
|
||||
result := map[string]any{
|
||||
"method": "native",
|
||||
"success": true,
|
||||
"enriched_metadata": enrichedMeta,
|
||||
"lyrics": lyricsLRC,
|
||||
"write_external_lrc": req.EmbedLyrics &&
|
||||
req.shouldUpdateField("lyrics") &&
|
||||
req.lyricsSidecarEnabled() &&
|
||||
strings.TrimSpace(lyricsLRC) != "",
|
||||
}
|
||||
s, _ := marshalJSONString(result)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Don't cleanup cover temp — Dart needs it for FFmpeg embed
|
||||
cleanupCover = false
|
||||
ffmpegMetadata := buildReEnrichFFmpegMetadata(&req, lyricsLRC)
|
||||
|
||||
result := map[string]any{
|
||||
"method": "ffmpeg",
|
||||
"cover_path": coverTempPath,
|
||||
"lyrics": lyricsLRC,
|
||||
"enriched_metadata": enrichedMeta,
|
||||
"metadata": ffmpegMetadata,
|
||||
"write_external_lrc": req.EmbedLyrics &&
|
||||
req.shouldUpdateField("lyrics") &&
|
||||
req.lyricsSidecarEnabled() &&
|
||||
strings.TrimSpace(lyricsLRC) != "",
|
||||
}
|
||||
|
||||
s, _ := marshalJSONString(result)
|
||||
return s, nil
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func InitExtensionRepoJSON(cacheDir string) error {
|
||||
initExtensionRepo(cacheDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetRepoRegistryURLJSON(registryURL string) error {
|
||||
repo := getExtensionRepo()
|
||||
if repo == nil {
|
||||
return fmt.Errorf("extension repo not initialized")
|
||||
}
|
||||
|
||||
resolved, err := resolveRegistryURL(registryURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := requireHTTPSURL(resolved, "registry"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
repo.setRegistryURL(resolved)
|
||||
return nil
|
||||
}
|
||||
|
||||
func ClearRepoRegistryURLJSON() error {
|
||||
repo := getExtensionRepo()
|
||||
if repo == nil {
|
||||
return fmt.Errorf("extension repo not initialized")
|
||||
}
|
||||
|
||||
repo.setRegistryURL("")
|
||||
repo.clearCache()
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetRepoRegistryURLJSON() (string, error) {
|
||||
repo := getExtensionRepo()
|
||||
if repo == nil {
|
||||
return "", fmt.Errorf("extension repo not initialized")
|
||||
}
|
||||
|
||||
return repo.getRegistryURL(), nil
|
||||
}
|
||||
|
||||
func GetRepoExtensionsJSON(forceRefresh bool) (string, error) {
|
||||
repo := getExtensionRepo()
|
||||
if repo == nil {
|
||||
return "", fmt.Errorf("extension repo not initialized")
|
||||
}
|
||||
|
||||
extensions, err := repo.getExtensionsWithStatus(forceRefresh)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return marshalJSONString(extensions)
|
||||
}
|
||||
|
||||
func SearchRepoExtensionsJSON(query, category string) (string, error) {
|
||||
repo := getExtensionRepo()
|
||||
if repo == nil {
|
||||
return "", fmt.Errorf("extension repo not initialized")
|
||||
}
|
||||
|
||||
extensions, err := repo.searchExtensions(query, category)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return marshalJSONString(extensions)
|
||||
}
|
||||
|
||||
func GetRepoCategoriesJSON() (string, error) {
|
||||
repo := getExtensionRepo()
|
||||
if repo == nil {
|
||||
return "", fmt.Errorf("extension repo not initialized")
|
||||
}
|
||||
|
||||
categories := repo.getCategories()
|
||||
return marshalJSONString(categories)
|
||||
}
|
||||
|
||||
func repoExtensionPackageSuffix(downloadURL string) string {
|
||||
rawPath := downloadURL
|
||||
if parsed, err := url.Parse(downloadURL); err == nil {
|
||||
rawPath = parsed.Path
|
||||
}
|
||||
|
||||
lowerPath := strings.ToLower(rawPath)
|
||||
if strings.HasSuffix(lowerPath, ".sflx") {
|
||||
return ".sflx"
|
||||
}
|
||||
if strings.HasSuffix(lowerPath, ".spotiflac-ext") {
|
||||
return ".spotiflac-ext"
|
||||
}
|
||||
return ".spotiflac-ext"
|
||||
}
|
||||
|
||||
func buildRepoExtensionDestPath(destDir, extensionID, downloadURL string) (string, error) {
|
||||
if strings.TrimSpace(extensionID) == "" {
|
||||
return "", fmt.Errorf("invalid extension id")
|
||||
}
|
||||
|
||||
safeExtensionID := sanitizeFilename(extensionID)
|
||||
return filepath.Join(destDir, safeExtensionID+repoExtensionPackageSuffix(downloadURL)), nil
|
||||
}
|
||||
|
||||
func DownloadRepoExtensionJSON(extensionID, destDir string) (string, error) {
|
||||
repo := getExtensionRepo()
|
||||
if repo == nil {
|
||||
return "", fmt.Errorf("extension repo not initialized")
|
||||
}
|
||||
|
||||
ext, err := repo.findExtension(extensionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
destPath, err := buildRepoExtensionDestPath(destDir, extensionID, ext.getDownloadURL())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
err = repo.downloadExtension(extensionID, destPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return destPath, nil
|
||||
}
|
||||
|
||||
func ClearRepoCacheJSON() error {
|
||||
repo := getExtensionRepo()
|
||||
if repo == nil {
|
||||
return fmt.Errorf("extension repo not initialized")
|
||||
}
|
||||
|
||||
repo.clearCache()
|
||||
return nil
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -21,9 +20,6 @@ func TestLyricsExportWrappersWithoutNetwork(t *testing.T) {
|
||||
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]" {
|
||||
t.Fatalf("GetLyricsLRC instrumental = %q/%v", lrc, err)
|
||||
}
|
||||
@@ -52,16 +48,42 @@ 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) {
|
||||
origClient := globalSongLinkClient
|
||||
origRetryConfig := songLinkRetryConfig
|
||||
origSearchByISRC := songLinkSearchByISRC
|
||||
origCheckFromDeezer := songLinkCheckAvailabilityFromDeezer
|
||||
defer func() {
|
||||
globalSongLinkClient = origClient
|
||||
songLinkRetryConfig = origRetryConfig
|
||||
songLinkSearchByISRC = origSearchByISRC
|
||||
songLinkCheckAvailabilityFromDeezer = origCheckFromDeezer
|
||||
SetSongLinkNetworkOptions(false, false)
|
||||
}()
|
||||
songLinkRetryConfig = func() RetryConfig {
|
||||
@@ -69,11 +91,12 @@ func TestSongLinkExportWrappersWithFakeClient(t *testing.T) {
|
||||
}
|
||||
globalSongLinkClient = &SongLinkClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
var body string
|
||||
if req.URL.Host == "api.zarz.moe" {
|
||||
switch req.URL.Host {
|
||||
case "api.zarz.moe":
|
||||
body = `{"success":true,"songUrls":{"Spotify":"https://open.spotify.com/track/spotify-1","Deezer":"https://www.deezer.com/track/101","Tidal":"https://listen.tidal.com/track/202","YouTube":"https://youtu.be/yt1","AmazonMusic":"https://music.amazon.com/tracks/amz1","Qobuz":"https://open.qobuz.com/track/303"}}`
|
||||
} else if req.URL.Host == "api.song.link" {
|
||||
case "api.song.link":
|
||||
body = `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/spotify-1"},"deezer":{"url":"https://www.deezer.com/track/101"},"tidal":{"url":"https://listen.tidal.com/track/202"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=ytm1"},"amazonMusic":{"url":"https://music.amazon.com/tracks/amz1"},"qobuz":{"url":"https://open.qobuz.com/track/303"}}}`
|
||||
} else {
|
||||
default:
|
||||
t.Fatalf("unexpected SongLink request: %s", req.URL.String())
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
|
||||
@@ -81,15 +104,6 @@ func TestSongLinkExportWrappersWithFakeClient(t *testing.T) {
|
||||
songLinkClientOnce.Do(func() {})
|
||||
|
||||
SetSongLinkNetworkOptions(true, true)
|
||||
if availabilityJSON, err := CheckAvailability("spotify-1", ""); err != nil || !strings.Contains(availabilityJSON, `"deezer_id":"101"`) {
|
||||
t.Fatalf("CheckAvailability = %q/%v", availabilityJSON, err)
|
||||
}
|
||||
if availabilityJSON, err := CheckAvailabilityFromDeezerID("101"); err != nil || !strings.Contains(availabilityJSON, `"spotify_id":"spotify-1"`) {
|
||||
t.Fatalf("CheckAvailabilityFromDeezerID = %q/%v", availabilityJSON, err)
|
||||
}
|
||||
if availabilityJSON, err := CheckAvailabilityByPlatformID("deezer", "song", "101"); err != nil || !strings.Contains(availabilityJSON, `"tidal_url"`) {
|
||||
t.Fatalf("CheckAvailabilityByPlatformID = %q/%v", availabilityJSON, err)
|
||||
}
|
||||
if spotifyID, err := GetSpotifyIDFromDeezerTrack("101"); err != nil || spotifyID != "spotify-1" {
|
||||
t.Fatalf("GetSpotifyIDFromDeezerTrack = %q/%v", spotifyID, err)
|
||||
}
|
||||
@@ -121,19 +135,23 @@ func TestSongLinkExportWrappersWithFakeClient(t *testing.T) {
|
||||
t.Fatalf("CheckAvailabilityFromURL = %#v/%v", availability, err)
|
||||
}
|
||||
|
||||
songLinkSearchByISRC = func(ctx context.Context, isrc string) (*TrackMetadata, error) {
|
||||
return &TrackMetadata{SpotifyID: "deezer:101", ExternalURL: "https://www.deezer.com/track/101"}, nil
|
||||
}
|
||||
songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) {
|
||||
return &TrackAvailability{SpotifyID: "spotify-1", Deezer: true, DeezerID: deezerTrackID}, nil
|
||||
}
|
||||
if availabilityJSON, err := CheckAvailability("", "USRC17607839"); err != nil || !strings.Contains(availabilityJSON, `"deezer_id":"101"`) {
|
||||
t.Fatalf("CheckAvailability by ISRC = %q/%v", availabilityJSON, err)
|
||||
}
|
||||
if songLinkExtractDeezerTrackID(nil) != "" || songLinkExtractDeezerTrackID(&TrackMetadata{ExternalURL: "https://www.deezer.com/track/202"}) != "202" {
|
||||
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{
|
||||
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
body := fakeDeezerResponse(req.URL.Path, req.URL.RawQuery)
|
||||
|
||||
@@ -31,6 +31,182 @@ func TestDownloadErrorClassificationPrioritizesRateLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadErrorClassificationDetectsVerificationRequired(t *testing.T) {
|
||||
cases := []string{
|
||||
"verification_required: canonical gateway challenge",
|
||||
"signed session expired",
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := classifyDownloadErrorType(tc); got != "verification_required" {
|
||||
t.Fatalf("classifyDownloadErrorType(%q) = %q, want verification_required", tc, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
dir := t.TempDir()
|
||||
if err := InitExtensionSystem(filepath.Join(dir, "extensions"), filepath.Join(dir, "data")); err != nil {
|
||||
t.Fatalf("InitExtensionSystem: %v", err)
|
||||
}
|
||||
CleanupExtensions()
|
||||
defer CleanupExtensions()
|
||||
|
||||
ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
|
||||
ext.ID = "deezer"
|
||||
ext.Manifest.Name = "deezer"
|
||||
manager := getExtensionManager()
|
||||
manager.mu.Lock()
|
||||
manager.extensions = map[string]*loadedExtension{ext.ID: ext}
|
||||
manager.mu.Unlock()
|
||||
|
||||
jsonText, err := GetProviderMetadataJSON("deezer", "album", "201")
|
||||
if err != nil {
|
||||
t.Fatalf("GetProviderMetadataJSON deezer album: %v", err)
|
||||
}
|
||||
if !strings.Contains(jsonText, "album-track") {
|
||||
t.Fatalf("expected enabled deezer extension metadata, got %s", jsonText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionTrackExportsPreserveExplicitFlag(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := InitExtensionSystem(filepath.Join(dir, "extensions"), filepath.Join(dir, "data")); err != nil {
|
||||
t.Fatalf("InitExtensionSystem: %v", err)
|
||||
}
|
||||
|
||||
ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
|
||||
manager := getExtensionManager()
|
||||
manager.mu.Lock()
|
||||
manager.extensions = map[string]*loadedExtension{ext.ID: ext}
|
||||
manager.mu.Unlock()
|
||||
defer CleanupExtensions()
|
||||
|
||||
assertExplicit := func(name, jsonText string, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
if !strings.Contains(jsonText, `"explicit":true`) {
|
||||
t.Fatalf("%s dropped explicit flag: %s", name, jsonText)
|
||||
}
|
||||
}
|
||||
|
||||
jsonText, err := CustomSearchWithExtensionJSON(ext.ID, "needle", `{"filter":"tracks"}`)
|
||||
assertExplicit("custom search", jsonText, err)
|
||||
|
||||
for _, resourceType := range []string{"track", "album", "playlist", "artist"} {
|
||||
jsonText, err = GetProviderMetadataJSON(ext.ID, resourceType, resourceType+"-1")
|
||||
assertExplicit("provider metadata "+resourceType, jsonText, err)
|
||||
}
|
||||
|
||||
jsonText, err = HandleURLWithExtensionJSON("https://example.test/track/1")
|
||||
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) {
|
||||
dir := t.TempDir()
|
||||
dataDir := filepath.Join(dir, "data")
|
||||
@@ -53,24 +229,17 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||
manager.mu.Unlock()
|
||||
}()
|
||||
|
||||
if response, err := DownloadTrack(`{}`); err != nil || !strings.Contains(response, "retired") {
|
||||
t.Fatalf("DownloadTrack = %q/%v", response, err)
|
||||
}
|
||||
if response, err := DownloadByStrategy(`not-json`); err != nil || !strings.Contains(response, "Invalid request") {
|
||||
t.Fatalf("DownloadByStrategy invalid = %q/%v", response, err)
|
||||
}
|
||||
if response, err := DownloadByStrategy(`{"use_extensions":false}`); err != nil || !strings.Contains(response, "disabled") {
|
||||
t.Fatalf("DownloadByStrategy disabled = %q/%v", response, err)
|
||||
}
|
||||
if response, err := DownloadWithFallback(`{}`); err != nil || !strings.Contains(response, "retired") {
|
||||
t.Fatalf("DownloadWithFallback = %q/%v", response, err)
|
||||
}
|
||||
|
||||
InitItemProgress("item-1")
|
||||
FinishItemProgress("item-1")
|
||||
ClearItemProgress("item-1")
|
||||
CancelDownload("item-1")
|
||||
if GetDownloadProgress() == "" || GetAllDownloadProgress() == "" || GetAllDownloadProgressDelta(0) == "" {
|
||||
if GetAllDownloadProgress() == "" || GetAllDownloadProgressDelta(0) == "" {
|
||||
t.Fatal("expected progress JSON")
|
||||
}
|
||||
CleanupConnections()
|
||||
@@ -127,9 +296,6 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||
if err := SetDownloadDirectory(dir); err != nil {
|
||||
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") {
|
||||
t.Fatalf("CheckDuplicatesBatch = %q/%v", batchJSON, err)
|
||||
}
|
||||
@@ -145,12 +311,6 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||
t.Fatalf("SanitizeFilename = %q", got)
|
||||
}
|
||||
|
||||
if response, err := PreWarmTrackCacheJSON(`not-json`); err != nil || !strings.Contains(response, "Invalid JSON") {
|
||||
t.Fatalf("PreWarmTrackCacheJSON invalid = %q/%v", response, err)
|
||||
}
|
||||
if response, err := PreWarmTrackCacheJSON(`[{"isrc":"ISRC","track_name":"Song","artist_name":"Artist"}]`); err != nil || !strings.Contains(response, "success") {
|
||||
t.Fatalf("PreWarmTrackCacheJSON = %q/%v", response, err)
|
||||
}
|
||||
if GetTrackCacheSize() != 0 {
|
||||
t.Fatal("expected empty track cache")
|
||||
}
|
||||
@@ -181,9 +341,6 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||
if err := SetExtensionFallbackProviderIDsJSON(`["coverage-ext"]`); err != nil {
|
||||
t.Fatalf("SetExtensionFallbackProviderIDsJSON: %v", err)
|
||||
}
|
||||
if jsonText, err := GetExtensionFallbackProviderIDsJSON(); err != nil || !strings.Contains(jsonText, "coverage-ext") {
|
||||
t.Fatalf("GetExtensionFallbackProviderIDsJSON = %q/%v", jsonText, err)
|
||||
}
|
||||
if err := SetExtensionFallbackProviderIDsJSON(""); err != nil {
|
||||
t.Fatalf("reset extension fallback IDs: %v", err)
|
||||
}
|
||||
@@ -204,9 +361,6 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||
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") {
|
||||
t.Fatalf("SearchTracksWithMetadataProvidersJSON = %q/%v", jsonText, err)
|
||||
}
|
||||
@@ -304,9 +458,6 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||
if _, err := GetDeezerMetadata("bad", "1"); err == nil {
|
||||
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") {
|
||||
t.Fatalf("GetDeezerExtendedMetadata = %q/%v", jsonText, err)
|
||||
}
|
||||
@@ -327,48 +478,33 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
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") {
|
||||
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}`
|
||||
if postJSON, err := RunPostProcessingV2JSON(v2Input, `not-json`); err != nil || !strings.Contains(postJSON, "success") {
|
||||
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") {
|
||||
t.Fatalf("GetExtensionHomeFeedJSON = %q/%v", feedJSON, err)
|
||||
}
|
||||
if feedJSON, err := GetExtensionHomeFeedJSONWithRequestID(ext.ID, "req-home"); err != nil || !strings.Contains(feedJSON, "home-1") {
|
||||
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")
|
||||
|
||||
storeDir := filepath.Join(dir, "store")
|
||||
if err := InitExtensionStoreJSON(storeDir); err != nil {
|
||||
t.Fatalf("InitExtensionStoreJSON: %v", err)
|
||||
if err := InitExtensionRepoJSON(storeDir); err != nil {
|
||||
t.Fatalf("InitExtensionRepoJSON: %v", err)
|
||||
}
|
||||
if err := SetStoreRegistryURLJSON("https://registry.example.com/index.json"); err != nil {
|
||||
t.Fatalf("SetStoreRegistryURLJSON: %v", err)
|
||||
if err := SetRepoRegistryURLJSON("https://registry.example.com/index.json"); err != nil {
|
||||
t.Fatalf("SetRepoRegistryURLJSON: %v", err)
|
||||
}
|
||||
store := getExtensionStore()
|
||||
store.cache = &storeRegistry{Extensions: []storeExtension{{
|
||||
store := getExtensionRepo()
|
||||
store.cache = &repoRegistry{Extensions: []repoExtension{{
|
||||
ID: "coverage-ext",
|
||||
Name: "coverage-ext",
|
||||
Version: "1.0.0",
|
||||
@@ -378,29 +514,44 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||
DownloadURL: "https://registry.example.com/coverage.spotiflac-ext",
|
||||
}}}
|
||||
store.cacheTime = time.Now()
|
||||
if registryURL, err := GetStoreRegistryURLJSON(); err != nil || registryURL == "" {
|
||||
t.Fatalf("GetStoreRegistryURLJSON = %q/%v", registryURL, err)
|
||||
if registryURL, err := GetRepoRegistryURLJSON(); err != nil || registryURL == "" {
|
||||
t.Fatalf("GetRepoRegistryURLJSON = %q/%v", registryURL, err)
|
||||
}
|
||||
if storeJSON, err := GetStoreExtensionsJSON(false); err != nil || !strings.Contains(storeJSON, "coverage-ext") {
|
||||
t.Fatalf("GetStoreExtensionsJSON = %q/%v", storeJSON, err)
|
||||
if storeJSON, err := GetRepoExtensionsJSON(false); err != nil || !strings.Contains(storeJSON, "coverage-ext") {
|
||||
t.Fatalf("GetRepoExtensionsJSON = %q/%v", storeJSON, err)
|
||||
}
|
||||
if storeJSON, err := SearchStoreExtensionsJSON("coverage", CategoryMetadata); err != nil || !strings.Contains(storeJSON, "coverage-ext") {
|
||||
t.Fatalf("SearchStoreExtensionsJSON = %q/%v", storeJSON, err)
|
||||
if storeJSON, err := SearchRepoExtensionsJSON("coverage", CategoryMetadata); err != nil || !strings.Contains(storeJSON, "coverage-ext") {
|
||||
t.Fatalf("SearchRepoExtensionsJSON = %q/%v", storeJSON, err)
|
||||
}
|
||||
if catsJSON, err := GetStoreCategoriesJSON(); err != nil || !strings.Contains(catsJSON, "metadata") {
|
||||
t.Fatalf("GetStoreCategoriesJSON = %q/%v", catsJSON, err)
|
||||
if catsJSON, err := GetRepoCategoriesJSON(); err != nil || !strings.Contains(catsJSON, "metadata") {
|
||||
t.Fatalf("GetRepoCategoriesJSON = %q/%v", catsJSON, err)
|
||||
}
|
||||
if dest, err := buildStoreExtensionDestPath(dir, "coverage/ext"); err != nil || !strings.HasSuffix(dest, ".spotiflac-ext") {
|
||||
t.Fatalf("buildStoreExtensionDestPath = %q/%v", dest, err)
|
||||
if dest, err := buildRepoExtensionDestPath(
|
||||
dir,
|
||||
"coverage/ext",
|
||||
"https://registry.example.com/coverage.spotiflac-ext",
|
||||
); err != nil || !strings.HasSuffix(dest, ".spotiflac-ext") {
|
||||
t.Fatalf("buildRepoExtensionDestPath = %q/%v", dest, err)
|
||||
}
|
||||
if _, err := buildStoreExtensionDestPath(dir, " "); err == nil {
|
||||
if dest, err := buildRepoExtensionDestPath(
|
||||
dir,
|
||||
"coverage/ext",
|
||||
"https://registry.example.com/coverage.sflx",
|
||||
); err != nil || !strings.HasSuffix(dest, ".sflx") {
|
||||
t.Fatalf("buildRepoExtensionDestPath sflx = %q/%v", dest, err)
|
||||
}
|
||||
if _, err := buildRepoExtensionDestPath(
|
||||
dir,
|
||||
" ",
|
||||
"https://registry.example.com/coverage.sflx",
|
||||
); err == nil {
|
||||
t.Fatal("expected invalid extension id")
|
||||
}
|
||||
if err := ClearStoreCacheJSON(); err != nil {
|
||||
t.Fatalf("ClearStoreCacheJSON: %v", err)
|
||||
if err := ClearRepoCacheJSON(); err != nil {
|
||||
t.Fatalf("ClearRepoCacheJSON: %v", err)
|
||||
}
|
||||
if err := ClearStoreRegistryURLJSON(); err != nil {
|
||||
t.Fatalf("ClearStoreRegistryURLJSON: %v", err)
|
||||
if err := ClearRepoRegistryURLJSON(); err != nil {
|
||||
t.Fatalf("ClearRepoRegistryURLJSON: %v", err)
|
||||
}
|
||||
|
||||
SetLibraryCoverCacheDirJSON(filepath.Join(dir, "covers"))
|
||||
@@ -431,9 +582,6 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||
if metadataJSON, err := ReadAudioMetadataJSON(filepath.Join(libraryDir, "missing.mp3")); err != nil || metadataJSON == "" {
|
||||
t.Fatalf("ReadAudioMetadataJSON = %q/%v", metadataJSON, err)
|
||||
}
|
||||
if metadataJSON, err := ReadAudioMetadataWithHintJSON(filepath.Join(libraryDir, "missing.mp3"), "Missing"); err != nil || metadataJSON == "" {
|
||||
t.Fatalf("ReadAudioMetadataWithHintJSON = %q/%v", metadataJSON, err)
|
||||
}
|
||||
if metadataJSON, err := ReadAudioMetadataWithHintAndCoverCacheKeyJSON(filepath.Join(libraryDir, "missing.mp3"), "Missing", "key"); err != nil || metadataJSON == "" {
|
||||
t.Fatalf("ReadAudioMetadataWithHintAndCoverCacheKeyJSON = %q/%v", metadataJSON, err)
|
||||
}
|
||||
|
||||
@@ -352,7 +352,7 @@ func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMiss
|
||||
}
|
||||
|
||||
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
|
||||
AlbumName: "Resolved Album",
|
||||
AlbumName: "Original Album (Deluxe)",
|
||||
ReleaseDate: "",
|
||||
ISRC: "",
|
||||
})
|
||||
@@ -360,7 +360,7 @@ func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMiss
|
||||
if req.ReleaseDate != "2024-01-01" {
|
||||
t.Fatalf("release date = %q, want existing value preserved", req.ReleaseDate)
|
||||
}
|
||||
if req.AlbumName != "Resolved Album" {
|
||||
if req.AlbumName != "Original Album (Deluxe)" {
|
||||
t.Fatalf("album = %q, want updated album", req.AlbumName)
|
||||
}
|
||||
if req.ISRC != "REQ123" {
|
||||
@@ -368,6 +368,81 @@ func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMiss
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyReEnrichTrackMetadataKeepsReleaseIdentityOnAlbumMismatch(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
TrackName: "Afsana",
|
||||
ArtistName: "Artist Name",
|
||||
AlbumName: "Original Soundtrack",
|
||||
CoverURL: "https://covers/original.jpg",
|
||||
TrackNumber: 3,
|
||||
ReleaseDate: "2005-01-01",
|
||||
}
|
||||
|
||||
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
|
||||
Name: "Afsana",
|
||||
Artists: "Artist Name",
|
||||
AlbumName: "The Hit Machine",
|
||||
CoverURL: "https://covers/compilation.jpg",
|
||||
TrackNumber: 17,
|
||||
ReleaseDate: "2010-01-01",
|
||||
ISRC: "NEW123",
|
||||
})
|
||||
|
||||
if req.AlbumName != "Original Soundtrack" {
|
||||
t.Fatalf("album = %q, want original release kept", req.AlbumName)
|
||||
}
|
||||
if req.CoverURL != "https://covers/original.jpg" {
|
||||
t.Fatalf("cover = %q, want original release cover kept", req.CoverURL)
|
||||
}
|
||||
if req.TrackNumber != 3 {
|
||||
t.Fatalf("track number = %d, want original position kept", req.TrackNumber)
|
||||
}
|
||||
if req.ReleaseDate != "2005-01-01" {
|
||||
t.Fatalf("release date = %q, want original date kept", req.ReleaseDate)
|
||||
}
|
||||
if req.ISRC != "NEW123" {
|
||||
t.Fatalf("isrc = %q, want recording-level fields still enriched", req.ISRC)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
req := reEnrichRequest{
|
||||
TrackName: "Song Title",
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSimpleCookieJarReplacesAndExpiresCookies(t *testing.T) {
|
||||
jar, err := newSimpleCookieJar()
|
||||
if err != nil {
|
||||
t.Fatalf("newSimpleCookieJar: %v", err)
|
||||
}
|
||||
u, _ := url.Parse("https://api.example.com/path")
|
||||
|
||||
jar.SetCookies(u, []*http.Cookie{{Name: "session", Value: "old", Path: "/"}})
|
||||
jar.SetCookies(u, []*http.Cookie{{Name: "session", Value: "new", Path: "/"}})
|
||||
cookies := jar.Cookies(u)
|
||||
if len(cookies) != 1 || cookies[0].Value != "new" {
|
||||
t.Fatalf("replacement cookies = %#v", cookies)
|
||||
}
|
||||
|
||||
jar.SetCookies(u, []*http.Cookie{{
|
||||
Name: "session",
|
||||
Value: "expired",
|
||||
Path: "/",
|
||||
Expires: time.Now().Add(-time.Hour),
|
||||
MaxAge: -1,
|
||||
}})
|
||||
if cookies := jar.Cookies(u); len(cookies) != 0 {
|
||||
t.Fatalf("expired cookies = %#v", cookies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleCookieJarClearAndScope(t *testing.T) {
|
||||
jar, err := newSimpleCookieJar()
|
||||
if err != nil {
|
||||
t.Fatalf("newSimpleCookieJar: %v", err)
|
||||
}
|
||||
apiURL, _ := url.Parse("https://api.example.com/private/resource")
|
||||
otherURL, _ := url.Parse("https://other.example.com/private/resource")
|
||||
publicURL, _ := url.Parse("https://api.example.com/public")
|
||||
|
||||
jar.SetCookies(apiURL, []*http.Cookie{{Name: "session", Value: "value", Path: "/private"}})
|
||||
if len(jar.Cookies(apiURL)) != 1 {
|
||||
t.Fatal("expected cookie for matching host and path")
|
||||
}
|
||||
if len(jar.Cookies(otherURL)) != 0 || len(jar.Cookies(publicURL)) != 0 {
|
||||
t.Fatal("cookie escaped its host or path scope")
|
||||
}
|
||||
|
||||
jar.Clear()
|
||||
if len(jar.Cookies(apiURL)) != 0 {
|
||||
t.Fatal("Clear retained cookies")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,748 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// attemptExtensionDownload runs a single provider.Download attempt: builds the
|
||||
// output path, reports progress, and on success assembles the full
|
||||
// DownloadResponse (overlay, request fallbacks, optional title/artist/composer
|
||||
// fallback, metadata embed, ISRC index). On failure it writes into
|
||||
// lastErr/lastErrType/lastRetryAfterSeconds exactly as the inline code did
|
||||
// (leaving them untouched when neither branch applies) so callers can keep
|
||||
// their own verification_required/stop-fallback handling and error messages.
|
||||
// cancelledOuter true means the caller must return (nil, ErrDownloadCancelled).
|
||||
func attemptExtensionDownload(
|
||||
req DownloadRequest,
|
||||
ext *loadedExtension,
|
||||
provider *extensionProviderWrapper,
|
||||
trackID, quality, providerLabel string,
|
||||
applyTitleFallback bool,
|
||||
lastErr *error,
|
||||
lastErrType *string,
|
||||
lastRetryAfterSeconds *int,
|
||||
) (resp *DownloadResponse, cancelledOuter bool) {
|
||||
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 != "" {
|
||||
SetItemPreparingStage(req.ItemID, "resolving_stream")
|
||||
}
|
||||
|
||||
result, err := provider.Download(trackID, quality, outputPath, req.ItemID, func(percent int) {
|
||||
if req.ItemID != "" {
|
||||
normalized := float64(percent) / 100.0
|
||||
if normalized < 0 {
|
||||
normalized = 0
|
||||
}
|
||||
if normalized > 1 {
|
||||
normalized = 1
|
||||
}
|
||||
SetItemProgress(req.ItemID, normalized, 0, 0)
|
||||
}
|
||||
})
|
||||
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 {
|
||||
SetItemFinalizing(req.ItemID)
|
||||
}
|
||||
if shouldAbortCancelledFallback(req.ItemID, err) {
|
||||
return nil, true
|
||||
}
|
||||
|
||||
if downloadSucceeded {
|
||||
metadataStartedAt := time.Now()
|
||||
enrichRequestExtendedMetadata(&req)
|
||||
LogDebug(
|
||||
"DownloadPipeline",
|
||||
"item=%s provider=%s post-transfer metadataMs=%.1f",
|
||||
req.ItemID,
|
||||
providerLabel,
|
||||
extensionDurationMs(time.Since(metadataStartedAt)),
|
||||
)
|
||||
|
||||
normalizedResult, alreadyExists := normalizeExtensionDownloadResult(result)
|
||||
message := "Downloaded from " + providerLabel
|
||||
if alreadyExists {
|
||||
message = "File already exists"
|
||||
}
|
||||
|
||||
built := buildDownloadSuccessResponse(
|
||||
req,
|
||||
normalizedResult,
|
||||
providerLabel,
|
||||
message,
|
||||
normalizedResult.FilePath,
|
||||
alreadyExists,
|
||||
)
|
||||
overlayExtensionDownloadMetadata(&built, result)
|
||||
if ext.Manifest.SkipMetadataEnrichment {
|
||||
built.SkipMetadataEnrichment = true
|
||||
}
|
||||
applyExtensionRequestFallbacks(&built, req)
|
||||
|
||||
if applyTitleFallback {
|
||||
if req.TrackName != "" && built.Title == "" {
|
||||
built.Title = req.TrackName
|
||||
}
|
||||
if req.ArtistName != "" && built.Artist == "" {
|
||||
built.Artist = req.ArtistName
|
||||
}
|
||||
if req.Composer != "" && built.Composer == "" {
|
||||
built.Composer = req.Composer
|
||||
}
|
||||
}
|
||||
|
||||
embedExtensionDownloadMetadata(built, req, alreadyExists)
|
||||
|
||||
if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
|
||||
indexISRC := strings.TrimSpace(built.ISRC)
|
||||
if indexISRC == "" {
|
||||
indexISRC = strings.TrimSpace(req.ISRC)
|
||||
}
|
||||
if indexISRC != "" && strings.TrimSpace(built.FilePath) != "" {
|
||||
AddToISRCIndex(req.OutputDir, indexISRC, built.FilePath)
|
||||
}
|
||||
}
|
||||
|
||||
if req.ItemID != "" {
|
||||
CompleteItemProgress(req.ItemID)
|
||||
}
|
||||
return &built, false
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: "Download cancelled",
|
||||
ErrorType: "cancelled",
|
||||
Service: providerLabel,
|
||||
}, false
|
||||
}
|
||||
*lastErr = err
|
||||
*lastErrType = ""
|
||||
} else if result != nil && result.ErrorMessage != "" {
|
||||
*lastErr = fmt.Errorf("%s", result.ErrorMessage)
|
||||
*lastErrType = normalizeExtensionDownloadErrorType(result.ErrorType, result.ErrorMessage)
|
||||
*lastRetryAfterSeconds = result.RetryAfterSeconds
|
||||
} else if result == nil {
|
||||
*lastErr = fmt.Errorf("extension returned no download result")
|
||||
*lastErrType = "extension_error"
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// attemptVerifiedResumeBeforeMetadata gives a just-verified request one direct
|
||||
// chance against its selected provider before optional metadata providers run.
|
||||
// Download-provider sources keep their normal precedence because they may
|
||||
// dynamically lock fallback from checkAvailability.
|
||||
func attemptVerifiedResumeBeforeMetadata(
|
||||
req DownloadRequest,
|
||||
selectedProvider string,
|
||||
extManager *extensionManager,
|
||||
) (*DownloadResponse, bool) {
|
||||
selectedProvider = strings.TrimSpace(selectedProvider)
|
||||
if selectedProvider == "" || extManager == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
sourceProvider := strings.TrimSpace(req.Source)
|
||||
if sourceProvider != "" && !strings.EqualFold(sourceProvider, selectedProvider) {
|
||||
if sourceExt, err := extManager.GetExtension(sourceProvider); err == nil &&
|
||||
sourceExt != nil && sourceExt.Enabled && sourceExt.Error == "" &&
|
||||
sourceExt.Manifest != nil && sourceExt.Manifest.IsDownloadProvider() {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
ext, err := extManager.GetExtension(selectedProvider)
|
||||
if err != nil || ext == nil || !ext.Enabled || ext.Error != "" ||
|
||||
ext.Manifest == nil || !ext.Manifest.IsDownloadProvider() {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
provider := newExtensionProviderWrapper(ext)
|
||||
var availability *ExtAvailabilityResult
|
||||
trackID := ""
|
||||
if strings.EqualFold(sourceProvider, selectedProvider) {
|
||||
trackID = resolvePreferredTrackIDForExtension(ext, req, "")
|
||||
} else {
|
||||
availability, err = provider.CheckAvailabilityForItemID(
|
||||
req.ISRC,
|
||||
req.TrackName,
|
||||
req.ArtistName,
|
||||
req.SpotifyID,
|
||||
req.DeezerID,
|
||||
req.TidalID,
|
||||
req.QobuzID,
|
||||
req.DurationMS,
|
||||
req.ItemID,
|
||||
)
|
||||
if shouldAbortCancelledFallback(req.ItemID, err) {
|
||||
return nil, true
|
||||
}
|
||||
if err != nil {
|
||||
if strings.EqualFold(classifyDownloadErrorType(err.Error()), "verification_required") {
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: "Download failed: " + err.Error(),
|
||||
ErrorType: "verification_required",
|
||||
Service: selectedProvider,
|
||||
}, false
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
if availability == nil || !availability.Available {
|
||||
if shouldStopProviderFallback(availability) {
|
||||
return buildExtensionFallbackStoppedResponse(selectedProvider, availability, nil), false
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
trackID = resolvePreferredTrackIDForExtension(ext, req, availability.TrackID)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
var lastErrType string
|
||||
var lastRetryAfterSeconds int
|
||||
resp, cancelled := attemptExtensionDownload(
|
||||
req,
|
||||
ext,
|
||||
provider,
|
||||
trackID,
|
||||
req.Quality,
|
||||
selectedProvider,
|
||||
strings.EqualFold(sourceProvider, selectedProvider),
|
||||
&lastErr,
|
||||
&lastErrType,
|
||||
&lastRetryAfterSeconds,
|
||||
)
|
||||
if cancelled || resp != nil {
|
||||
return resp, cancelled
|
||||
}
|
||||
|
||||
errorType := lastErrType
|
||||
if errorType == "" && lastErr != nil {
|
||||
errorType = classifyDownloadErrorType(lastErr.Error())
|
||||
}
|
||||
if strings.EqualFold(errorType, "verification_required") {
|
||||
errorMessage := "Verification required"
|
||||
if lastErr != nil {
|
||||
errorMessage = "Download failed: " + lastErr.Error()
|
||||
}
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: errorMessage,
|
||||
ErrorType: "verification_required",
|
||||
RetryAfterSeconds: lastRetryAfterSeconds,
|
||||
Service: selectedProvider,
|
||||
}, 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
|
||||
// better provider-native ID. The normal path below retains strict/stop-
|
||||
// fallback semantics for that prepared retry.
|
||||
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) {
|
||||
pipelineStartedAt := time.Now()
|
||||
defer func() {
|
||||
LogDebug(
|
||||
"DownloadPipeline",
|
||||
"item=%s service=%s totalMs=%.1f",
|
||||
req.ItemID,
|
||||
req.Service,
|
||||
extensionDurationMs(time.Since(pipelineStartedAt)),
|
||||
)
|
||||
}()
|
||||
preparationKey := downloadPreparationKey(req)
|
||||
metadataPrepared := false
|
||||
resumedAfterVerification := false
|
||||
if prepared, preparedMetadata, ok := takePreparedDownloadRequest(preparationKey, req); ok {
|
||||
req = prepared
|
||||
metadataPrepared = preparedMetadata
|
||||
resumedAfterVerification = true
|
||||
GoLog("[DownloadWithExtensionFallback] Resuming item %s after verification (metadata prepared: %v)\n", req.ItemID, metadataPrepared)
|
||||
}
|
||||
if req.ItemID != "" {
|
||||
StartItemProgress(req.ItemID)
|
||||
SetItemPreparingStage(req.ItemID, "resolving_metadata")
|
||||
}
|
||||
|
||||
priority := GetProviderPriority()
|
||||
extManager := getExtensionManager()
|
||||
strictMode := !req.UseFallback
|
||||
selectedProvider := strings.TrimSpace(req.Service)
|
||||
|
||||
if isDownloadCancelled(req.ItemID) {
|
||||
return nil, ErrDownloadCancelled
|
||||
}
|
||||
|
||||
if strictMode {
|
||||
if selectedProvider == "" {
|
||||
selectedProvider = strings.TrimSpace(req.Source)
|
||||
}
|
||||
if selectedProvider != "" {
|
||||
priority = []string{selectedProvider}
|
||||
GoLog("[DownloadWithExtensionFallback] Strict mode enabled, provider locked to: %s\n", selectedProvider)
|
||||
}
|
||||
}
|
||||
|
||||
if !strictMode && req.Service != "" {
|
||||
found := false
|
||||
for _, p := range priority {
|
||||
if strings.EqualFold(p, req.Service) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
newPriority := []string{req.Service}
|
||||
for _, p := range priority {
|
||||
if !strings.EqualFold(p, req.Service) {
|
||||
newPriority = append(newPriority, p)
|
||||
}
|
||||
}
|
||||
priority = newPriority
|
||||
if !found {
|
||||
GoLog("[DownloadWithExtensionFallback] Extension service '%s' added to priority front\n", req.Service)
|
||||
} else {
|
||||
GoLog("[DownloadWithExtensionFallback] Extension service '%s' moved to priority front\n", req.Service)
|
||||
}
|
||||
GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
var lastErrType string
|
||||
var lastRetryAfterSeconds int
|
||||
var stopProviderFallback bool
|
||||
var sourceExtensionLocked bool
|
||||
var sourceExtensionAvailability *ExtAvailabilityResult
|
||||
var sourceExtensionTrackID string
|
||||
|
||||
if resumedAfterVerification && !metadataPrepared {
|
||||
GoLog("[DownloadWithExtensionFallback] Trying verified provider %s before optional metadata enrichment\n", selectedProvider)
|
||||
resp, cancelled := attemptVerifiedResumeBeforeMetadata(req, selectedProvider, extManager)
|
||||
if cancelled {
|
||||
return nil, ErrDownloadCancelled
|
||||
}
|
||||
if resp != nil {
|
||||
return resp, nil
|
||||
}
|
||||
if req.ItemID != "" {
|
||||
SetItemPreparingStage(req.ItemID, "resolving_metadata")
|
||||
}
|
||||
}
|
||||
|
||||
if req.Source != "" && selectedProvider != req.Source {
|
||||
ext, err := extManager.GetExtension(req.Source)
|
||||
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() {
|
||||
provider := newExtensionProviderWrapper(ext)
|
||||
availability, availErr := provider.CheckAvailabilityForItemID(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID, req.TidalID, req.QobuzID, req.DurationMS, req.ItemID)
|
||||
if shouldAbortCancelledFallback(req.ItemID, availErr) {
|
||||
return nil, ErrDownloadCancelled
|
||||
}
|
||||
if availErr != nil {
|
||||
GoLog("[DownloadWithExtensionFallback] Source extension %s preflight failed (non-fatal): %v\n", req.Source, availErr)
|
||||
} else if shouldStopProviderFallback(availability) {
|
||||
sourceExtensionLocked = true
|
||||
sourceExtensionAvailability = availability
|
||||
sourceExtensionTrackID = strings.TrimSpace(availability.TrackID)
|
||||
selectedProvider = req.Source
|
||||
GoLog("[DownloadWithExtensionFallback] Source extension %s requested skip_fallback (available=%v), locking download to source extension\n", req.Source, availability.Available)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !metadataPrepared {
|
||||
if req.Source != "" {
|
||||
ext, err := extManager.GetExtension(req.Source)
|
||||
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() {
|
||||
GoLog("[DownloadWithExtensionFallback] Enriching track from extension '%s'...\n", req.Source)
|
||||
|
||||
provider := newExtensionProviderWrapper(ext)
|
||||
trackMeta := &ExtTrackMetadata{
|
||||
ID: req.SpotifyID,
|
||||
Name: req.TrackName,
|
||||
Artists: req.ArtistName,
|
||||
AlbumName: req.AlbumName,
|
||||
DurationMS: req.DurationMS,
|
||||
ISRC: req.ISRC,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
TrackNumber: req.TrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: req.DiscNumber,
|
||||
TotalDiscs: req.TotalDiscs,
|
||||
ProviderID: req.Source,
|
||||
Composer: req.Composer,
|
||||
}
|
||||
|
||||
enrichedTrack, err := provider.EnrichTrackForItemID(trackMeta, req.ItemID)
|
||||
if shouldAbortCancelledFallback(req.ItemID, err) {
|
||||
return nil, ErrDownloadCancelled
|
||||
}
|
||||
if err == nil && enrichedTrack != nil {
|
||||
if enrichedTrack.ISRC != "" && enrichedTrack.ISRC != req.ISRC {
|
||||
GoLog("[DownloadWithExtensionFallback] ISRC enriched: %s -> %s\n", req.ISRC, enrichedTrack.ISRC)
|
||||
req.ISRC = enrichedTrack.ISRC
|
||||
}
|
||||
if enrichedTrack.TidalID != "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Tidal ID from Odesli: %s\n", enrichedTrack.TidalID)
|
||||
req.TidalID = enrichedTrack.TidalID
|
||||
}
|
||||
if enrichedTrack.QobuzID != "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Qobuz ID from Odesli: %s\n", enrichedTrack.QobuzID)
|
||||
req.QobuzID = enrichedTrack.QobuzID
|
||||
}
|
||||
if enrichedTrack.DeezerID != "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Deezer ID from Odesli: %s\n", enrichedTrack.DeezerID)
|
||||
req.DeezerID = enrichedTrack.DeezerID
|
||||
}
|
||||
if enrichedTrack.Name != "" {
|
||||
req.TrackName = enrichedTrack.Name
|
||||
}
|
||||
if enrichedTrack.Artists != "" {
|
||||
req.ArtistName = enrichedTrack.Artists
|
||||
}
|
||||
overlayStr(&req.AlbumName, enrichedTrack.AlbumName, "AlbumName")
|
||||
overlayStr(&req.AlbumArtist, enrichedTrack.AlbumArtist, "")
|
||||
overlayInt(&req.DurationMS, enrichedTrack.DurationMS, "DurationMS")
|
||||
overlayStr(&req.CoverURL, enrichedTrack.CoverURL, "")
|
||||
overlayStr(&req.SpotifyID, enrichedTrack.ID, "Track ID")
|
||||
overlayStr(&req.Label, enrichedTrack.Label, "Label")
|
||||
overlayStr(&req.Copyright, enrichedTrack.Copyright, "Copyright")
|
||||
overlayStr(&req.Genre, enrichedTrack.Genre, "Genre")
|
||||
overlayStr(&req.ReleaseDate, enrichedTrack.ReleaseDate, "ReleaseDate")
|
||||
overlayInt(&req.TrackNumber, enrichedTrack.TrackNumber, "TrackNumber")
|
||||
overlayInt(&req.TotalTracks, enrichedTrack.TotalTracks, "TotalTracks")
|
||||
overlayInt(&req.DiscNumber, enrichedTrack.DiscNumber, "DiscNumber")
|
||||
overlayInt(&req.TotalDiscs, enrichedTrack.TotalDiscs, "TotalDiscs")
|
||||
overlayStr(&req.Composer, enrichedTrack.Composer, "Composer")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if req.Source != "" &&
|
||||
req.TrackName != "" && req.ArtistName != "" &&
|
||||
(req.AlbumName == "" || req.ReleaseDate == "" || req.ISRC == "") {
|
||||
|
||||
searchQuery := req.TrackName + " " + req.ArtistName
|
||||
GoLog("[DownloadWithExtensionFallback] Metadata incomplete, searching providers for: %s\n", searchQuery)
|
||||
|
||||
// Only the first match is consumed below. Asking for five made the manager
|
||||
// continue through additional providers even after it already had a usable
|
||||
// match, multiplying the per-provider timeout on slow networks.
|
||||
tracks, searchErr := extManager.SearchTracksWithMetadataProvidersForItemID(searchQuery, 1, true, req.ItemID)
|
||||
if shouldAbortCancelledFallback(req.ItemID, searchErr) {
|
||||
return nil, ErrDownloadCancelled
|
||||
}
|
||||
if searchErr == nil && len(tracks) > 0 {
|
||||
track := tracks[0]
|
||||
GoLog("[DownloadWithExtensionFallback] Metadata match (%s): %s - %s (album: %s, date: %s, isrc: %s)\n",
|
||||
track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate, track.ISRC)
|
||||
|
||||
overlayStr(&req.AlbumName, track.AlbumName, "")
|
||||
overlayStr(&req.AlbumArtist, track.AlbumArtist, "")
|
||||
overlayStr(&req.ReleaseDate, track.ReleaseDate, "")
|
||||
overlayStr(&req.ISRC, track.ISRC, "")
|
||||
overlayInt(&req.TrackNumber, track.TrackNumber, "")
|
||||
overlayInt(&req.TotalTracks, track.TotalTracks, "")
|
||||
overlayInt(&req.DiscNumber, track.DiscNumber, "")
|
||||
overlayInt(&req.TotalDiscs, track.TotalDiscs, "")
|
||||
overlayStr(&req.Composer, track.Composer, "")
|
||||
overlayStr(&req.CoverURL, track.CoverURL, "")
|
||||
overlayStr(&req.Genre, track.Genre, "")
|
||||
overlayStr(&req.Label, track.Label, "")
|
||||
overlayStr(&req.Copyright, track.Copyright, "")
|
||||
} else if searchErr != nil {
|
||||
GoLog("[DownloadWithExtensionFallback] Metadata provider search failed (non-fatal): %v\n", searchErr)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
if req.Source != "" && selectedProvider == req.Source {
|
||||
if isDownloadCancelled(req.ItemID) {
|
||||
return nil, ErrDownloadCancelled
|
||||
}
|
||||
|
||||
if sourceExtensionLocked && (sourceExtensionAvailability == nil || !sourceExtensionAvailability.Available) {
|
||||
GoLog("[DownloadWithExtensionFallback] Source extension %s stopped fallback before download (reason: %s)\n", req.Source, resolveExtensionAvailabilityReason(sourceExtensionAvailability, nil))
|
||||
return buildExtensionFallbackStoppedResponse(req.Source, sourceExtensionAvailability, nil), nil
|
||||
}
|
||||
|
||||
GoLog("[DownloadWithExtensionFallback] Track source is extension '%s' matching selected provider, trying it first\n", req.Source)
|
||||
|
||||
ext, err := extManager.GetExtension(req.Source)
|
||||
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() {
|
||||
stopProviderFallback = ext.Manifest.StopsProviderFallback()
|
||||
|
||||
provider := newExtensionProviderWrapper(ext)
|
||||
|
||||
trackID := resolvePreferredTrackIDForExtension(ext, req, sourceExtensionTrackID)
|
||||
|
||||
GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (stopProviderFallback: %v)\n", trackID, stopProviderFallback)
|
||||
|
||||
resp, cancelledOuter := attemptExtensionDownload(req, ext, provider, trackID, req.Quality, req.Source, true, &lastErr, &lastErrType, &lastRetryAfterSeconds)
|
||||
if cancelledOuter {
|
||||
return nil, ErrDownloadCancelled
|
||||
}
|
||||
if resp != nil {
|
||||
return resp, nil
|
||||
}
|
||||
GoLog("[DownloadWithExtensionFallback] Source extension %s failed: %v\n", req.Source, lastErr)
|
||||
|
||||
sourceErrType := lastErrType
|
||||
if sourceErrType == "" && lastErr != nil {
|
||||
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") {
|
||||
GoLog("[DownloadWithExtensionFallback] Source extension %s requires verification, not trying other providers\n", req.Source)
|
||||
cachePreparedDownloadRequest(preparationKey, req)
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: "Download failed: " + lastErr.Error(),
|
||||
ErrorType: "verification_required",
|
||||
Service: req.Source,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if stopProviderFallback || sourceExtensionLocked {
|
||||
if sourceExtensionLocked {
|
||||
GoLog("[DownloadWithExtensionFallback] Source extension %s requested skip_fallback, not trying other providers\n", req.Source)
|
||||
return buildExtensionFallbackStoppedResponse(req.Source, sourceExtensionAvailability, lastErr), nil
|
||||
}
|
||||
GoLog("[DownloadWithExtensionFallback] stopProviderFallback is true, not trying other providers\n")
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: "Download failed: " + lastErr.Error(),
|
||||
ErrorType: firstNonEmptyTrimmed(lastErrType, "extension_error"),
|
||||
RetryAfterSeconds: lastRetryAfterSeconds,
|
||||
Service: req.Source,
|
||||
}, nil
|
||||
}
|
||||
} else {
|
||||
GoLog("[DownloadWithExtensionFallback] Source extension %s not available or not a download provider\n", req.Source)
|
||||
}
|
||||
}
|
||||
|
||||
healthProtectedProvider := selectedProvider
|
||||
if strings.TrimSpace(healthProtectedProvider) == "" {
|
||||
healthProtectedProvider = req.Source
|
||||
}
|
||||
priority = prioritizeFallbackProvidersByHealth(priority, extManager, healthProtectedProvider)
|
||||
priority = moveProviderToFront(priority, selectedProvider)
|
||||
|
||||
for _, providerID := range priority {
|
||||
if isDownloadCancelled(req.ItemID) {
|
||||
return nil, ErrDownloadCancelled
|
||||
}
|
||||
|
||||
providerID = strings.TrimSpace(providerID)
|
||||
if providerID == "" {
|
||||
continue
|
||||
}
|
||||
// Skip the origin extension only when it differs from the explicitly
|
||||
// selected provider; otherwise it must still be attempted here.
|
||||
if providerID == req.Source && req.Source != selectedProvider {
|
||||
continue
|
||||
}
|
||||
|
||||
if providerID != selectedProvider && !isExtensionFallbackAllowed(providerID) {
|
||||
GoLog("[DownloadWithExtensionFallback] Skipping extension provider %s (not enabled for fallback)\n", providerID)
|
||||
continue
|
||||
}
|
||||
|
||||
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
|
||||
|
||||
{
|
||||
ext, err := extManager.GetExtension(providerID)
|
||||
if err != nil || !ext.Enabled || ext.Error != "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Extension %s not available\n", providerID)
|
||||
continue
|
||||
}
|
||||
|
||||
if !ext.Manifest.IsDownloadProvider() {
|
||||
continue
|
||||
}
|
||||
|
||||
provider := newExtensionProviderWrapper(ext)
|
||||
|
||||
availability, err := provider.CheckAvailabilityForItemID(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID, req.TidalID, req.QobuzID, req.DurationMS, req.ItemID)
|
||||
if shouldAbortCancelledFallback(req.ItemID, err) {
|
||||
return nil, ErrDownloadCancelled
|
||||
}
|
||||
terminalAvailability := shouldStopProviderFallback(availability)
|
||||
if err != nil || !availability.Available {
|
||||
GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
if strings.EqualFold(classifyDownloadErrorType(err.Error()), "verification_required") {
|
||||
GoLog("[DownloadWithExtensionFallback] %s requires verification (availability); pausing fallback to open the challenge\n", providerID)
|
||||
cachePreparedDownloadRequest(preparationKey, req)
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: "Download failed: " + err.Error(),
|
||||
ErrorType: "verification_required",
|
||||
Service: providerID,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
if terminalAvailability {
|
||||
GoLog("[DownloadWithExtensionFallback] %s requested skip_fallback after availability check\n", providerID)
|
||||
return buildExtensionFallbackStoppedResponse(providerID, availability, err), nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
req.OutputExt = ""
|
||||
|
||||
// Honor the requested quality when this provider recognizes it
|
||||
// (e.g. an explicit user selection). Only when the token is not
|
||||
// one of this provider's own options do we fall back to its
|
||||
// highest quality, since a source provider's token may not map.
|
||||
fallbackQuality := req.Quality
|
||||
if len(ext.Manifest.QualityOptions) > 0 {
|
||||
requested := strings.TrimSpace(req.Quality)
|
||||
recognized := false
|
||||
if requested != "" {
|
||||
for _, opt := range ext.Manifest.QualityOptions {
|
||||
if strings.EqualFold(strings.TrimSpace(opt.ID), requested) {
|
||||
recognized = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !recognized {
|
||||
if best := strings.TrimSpace(ext.Manifest.QualityOptions[0].ID); best != "" {
|
||||
fallbackQuality = best
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resp, cancelledOuter := attemptExtensionDownload(req, ext, provider, availability.TrackID, fallbackQuality, providerID, false, &lastErr, &lastErrType, &lastRetryAfterSeconds)
|
||||
if cancelledOuter {
|
||||
return nil, ErrDownloadCancelled
|
||||
}
|
||||
if resp != nil {
|
||||
return resp, nil
|
||||
}
|
||||
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, lastErr)
|
||||
|
||||
if lastErr != nil {
|
||||
effType := lastErrType
|
||||
if effType == "" {
|
||||
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") {
|
||||
GoLog("[DownloadWithExtensionFallback] %s requires verification; pausing fallback to open the challenge\n", providerID)
|
||||
cachePreparedDownloadRequest(preparationKey, req)
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: "Download failed: " + lastErr.Error(),
|
||||
ErrorType: "verification_required",
|
||||
RetryAfterSeconds: lastRetryAfterSeconds,
|
||||
Service: providerID,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if terminalAvailability {
|
||||
GoLog("[DownloadWithExtensionFallback] %s requested skip_fallback after download failure\n", providerID)
|
||||
return buildExtensionFallbackStoppedResponse(providerID, availability, lastErr), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
errorType := firstNonEmptyTrimmed(lastErrType, classifyDownloadErrorType(lastErr.Error()))
|
||||
if errorType == "unknown" {
|
||||
errorType = "not_found"
|
||||
}
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: "All providers failed. Last error: " + lastErr.Error(),
|
||||
ErrorType: errorType,
|
||||
RetryAfterSeconds: lastRetryAfterSeconds,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: "No extension download providers available",
|
||||
ErrorType: "not_found",
|
||||
}, nil
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVerifiedDownloadResumeTriesSelectedProviderBeforeMetadata(t *testing.T) {
|
||||
resetPreparedDownloadRequestCacheForTest()
|
||||
t.Cleanup(resetPreparedDownloadRequestCacheForTest)
|
||||
|
||||
metadataExt := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
|
||||
metadataExt.ID = "resume-metadata"
|
||||
metadataExt.Manifest.Name = metadataExt.ID
|
||||
downloadExt := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
|
||||
downloadExt.ID = "resume-download"
|
||||
downloadExt.Manifest.Name = downloadExt.ID
|
||||
|
||||
manager := getExtensionManager()
|
||||
manager.mu.Lock()
|
||||
previousExtensions := manager.extensions
|
||||
manager.extensions = map[string]*loadedExtension{
|
||||
metadataExt.ID: metadataExt,
|
||||
downloadExt.ID: downloadExt,
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
teardownExtension(metadataExt)
|
||||
teardownExtension(downloadExt)
|
||||
manager.mu.Lock()
|
||||
manager.extensions = previousExtensions
|
||||
manager.mu.Unlock()
|
||||
})
|
||||
|
||||
req := DownloadRequest{
|
||||
ItemID: "resume-item",
|
||||
Service: downloadExt.ID,
|
||||
Source: metadataExt.ID,
|
||||
SpotifyID: "spotify:track:1",
|
||||
TrackName: "Original Song",
|
||||
ArtistName: "Artist",
|
||||
AlbumName: "Album",
|
||||
ReleaseDate: "2026-05-04",
|
||||
OutputDir: t.TempDir(),
|
||||
OutputExt: ".flac",
|
||||
FilenameFormat: "{title}",
|
||||
Quality: "LOSSLESS",
|
||||
UseFallback: false,
|
||||
}
|
||||
key := downloadPreparationKey(req)
|
||||
cacheUnpreparedDownloadRequest(key, req)
|
||||
|
||||
resp, err := DownloadWithExtensionFallback(req)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadWithExtensionFallback: %v", err)
|
||||
}
|
||||
if resp == nil || !resp.Success {
|
||||
t.Fatalf("resume response = %#v", resp)
|
||||
}
|
||||
if got := filepath.Base(resp.FilePath); got != "Original Song.flac" {
|
||||
t.Fatalf("resume ran metadata enrichment before download: file = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifiedDownloadResumeReusesPreparedMetadata(t *testing.T) {
|
||||
resetPreparedDownloadRequestCacheForTest()
|
||||
t.Cleanup(resetPreparedDownloadRequestCacheForTest)
|
||||
|
||||
metadataExt := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
|
||||
metadataExt.ID = "prepared-metadata"
|
||||
metadataExt.Manifest.Name = metadataExt.ID
|
||||
downloadExt := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
|
||||
downloadExt.ID = "prepared-download"
|
||||
downloadExt.Manifest.Name = downloadExt.ID
|
||||
|
||||
manager := getExtensionManager()
|
||||
manager.mu.Lock()
|
||||
previousExtensions := manager.extensions
|
||||
manager.extensions = map[string]*loadedExtension{
|
||||
metadataExt.ID: metadataExt,
|
||||
downloadExt.ID: downloadExt,
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
teardownExtension(metadataExt)
|
||||
teardownExtension(downloadExt)
|
||||
manager.mu.Lock()
|
||||
manager.extensions = previousExtensions
|
||||
manager.mu.Unlock()
|
||||
})
|
||||
|
||||
req := DownloadRequest{
|
||||
ItemID: "prepared-item",
|
||||
Service: downloadExt.ID,
|
||||
Source: metadataExt.ID,
|
||||
SpotifyID: "spotify:track:2",
|
||||
TrackName: "Original Song",
|
||||
ArtistName: "Artist",
|
||||
AlbumName: "Album",
|
||||
ReleaseDate: "2026-05-04",
|
||||
OutputDir: t.TempDir(),
|
||||
OutputExt: ".flac",
|
||||
FilenameFormat: "{title}",
|
||||
Quality: "LOSSLESS",
|
||||
UseFallback: false,
|
||||
}
|
||||
key := downloadPreparationKey(req)
|
||||
prepared := req
|
||||
prepared.TrackName = "Prepared Song"
|
||||
cachePreparedDownloadRequest(key, prepared)
|
||||
|
||||
resp, err := DownloadWithExtensionFallback(req)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadWithExtensionFallback: %v", err)
|
||||
}
|
||||
if resp == nil || !resp.Success {
|
||||
t.Fatalf("prepared response = %#v", resp)
|
||||
}
|
||||
if got := filepath.Base(resp.FilePath); got != "Prepared Song.flac" {
|
||||
t.Fatalf("prepared metadata was not reused: file = %q", got)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user