Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3f9388ba8 | ||
|
|
c04050459a | ||
|
|
e761dd604f | ||
|
|
bad48ea0fd | ||
|
|
f7a0c8e993 | ||
|
|
7972fd64a4 | ||
|
|
7f4ace6d1b | ||
|
|
d4b617f49a | ||
|
|
ed60cafb90 | ||
|
|
19753ca005 | ||
|
|
611f7377f6 | ||
|
|
645a324a5f | ||
|
|
6a3a85f7c9 | ||
|
|
b3bbe93c1e | ||
|
|
15b1b757fd | ||
|
|
dc58353c07 | ||
|
|
3e41aa0eee | ||
|
|
d85c8f6a17 | ||
|
|
5357e14b06 | ||
|
|
49d0bd6a99 | ||
|
|
5228b0030b | ||
|
|
66928e259d | ||
|
|
113ad7ed9b | ||
|
|
177a176218 | ||
|
|
169a811162 | ||
|
|
76f7b9c025 | ||
|
|
12f2b12570 | ||
|
|
04a8700a65 | ||
|
|
43a8d08851 | ||
|
|
6f94aae0cc | ||
|
|
d4bed9c643 | ||
|
|
24b9d7ba80 | ||
|
|
cc93c5020d | ||
|
|
af28bbd492 | ||
|
|
1458b74d5b | ||
|
|
6e60f33fd0 | ||
|
|
4ee7558bca | ||
|
|
78670a6ea3 | ||
|
|
7230dd3df8 | ||
|
|
eed48a5fec | ||
|
|
1443be6a4f | ||
|
|
dc4c75363c | ||
|
|
309777cc71 | ||
|
|
d765320f10 | ||
|
|
40034d184f | ||
|
|
dbdbf0ca41 | ||
|
|
fa0c7b20b5 | ||
|
|
c6f7b22e43 | ||
|
|
834a8dde89 | ||
|
|
1e973d5ecb | ||
|
|
5b45ef80bf | ||
|
|
e7fbaf7b75 | ||
|
|
1f55523b64 | ||
|
|
452d9a76cb | ||
|
|
8dcf7fa998 | ||
|
|
662558e3e6 | ||
|
|
b1c18b95e7 | ||
|
|
1bf61884cd | ||
|
|
d7710e2a38 | ||
|
|
b2e4548204 | ||
|
|
7eb0ed2fdb | ||
|
|
1390376f09 | ||
|
|
650c360eb9 | ||
|
|
d1db2cdcc5 | ||
|
|
6c89a1e177 | ||
|
|
fb538e9517 | ||
|
|
209eb5f10e | ||
|
|
385afe290a | ||
|
|
b4f239c692 | ||
|
|
7bf6f32802 | ||
|
|
6368d1feee | ||
|
|
7f277f7a88 | ||
|
|
4c33f112ad | ||
|
|
80d9d87870 | ||
|
|
26570792a9 | ||
|
|
ad08b95c0f | ||
|
|
454ef53977 | ||
|
|
e4cd52f29f | ||
|
|
af6ca12e85 | ||
|
|
9b4a960324 | ||
|
|
1cdf4b4547 | ||
|
|
73846c40ca | ||
|
|
856e2cbb9e | ||
|
|
3b901461f6 | ||
|
|
ef3e019a3a | ||
|
|
eaafeef1bb | ||
|
|
50375b751e | ||
|
|
2a1c9e0904 | ||
|
|
0278fad278 | ||
|
|
efe703d60d | ||
|
|
55be8fc827 | ||
|
|
c42a1be76c | ||
|
|
ac56e76b62 | ||
|
|
9da3607932 | ||
|
|
e8a3fc58c7 | ||
|
|
52b1147275 | ||
|
|
8fe2662c0a | ||
|
|
23f18ab276 | ||
|
|
c9b1898cc0 | ||
|
|
e8290a5eb0 | ||
|
|
d0dca1a101 | ||
|
|
5ee7f733cf | ||
|
|
6424647472 | ||
|
|
4539b3fd67 | ||
|
|
bd2bd85c9e | ||
|
|
1b861f9ae8 | ||
|
|
8a95f684d2 | ||
|
|
358154c39e | ||
|
|
bfd44738e2 | ||
|
|
0b053d59b1 | ||
|
|
21bc31e0b6 | ||
|
|
ac90e7f517 | ||
|
|
4ab6388e18 | ||
|
|
5412037ac8 | ||
|
|
ea172ba17b | ||
|
|
b81bd5e8aa | ||
|
|
00d03de2b1 | ||
|
|
49bfefb750 | ||
|
|
9dc44ecd88 | ||
|
|
2dbf8f6f9b | ||
|
|
2ce793d68f | ||
|
|
cbee637501 | ||
|
|
1cda88cafc | ||
|
|
6fcfb67af2 | ||
|
|
183744c41e | ||
|
|
97b87472fe | ||
|
|
fa7ef84b39 | ||
|
|
42c266eafc | ||
|
|
74615a60b5 | ||
|
|
8d077ae2da | ||
|
|
11fb4365a2 | ||
|
|
424ca5d77e | ||
|
|
69830f6d46 | ||
|
|
cb8064a317 | ||
|
|
a21d5bcb33 | ||
|
|
50c8f0e454 | ||
|
|
97758908ca | ||
|
|
04a71d0211 | ||
|
|
d4f0b84615 | ||
|
|
901fa34d80 | ||
|
|
d38a368f63 | ||
|
|
984beb92e8 | ||
|
|
5ca04a8534 | ||
|
|
b31f20d2bb | ||
|
|
d67510fb36 | ||
|
|
d3ab52d65d | ||
|
|
3f576562ef | ||
|
|
c497449549 | ||
|
|
54dcdb92dd | ||
|
|
c9470ef751 | ||
|
|
48f01ba146 | ||
|
|
5f5186a23e | ||
|
|
b93ebc1e8d | ||
|
|
c4abbf8743 | ||
|
|
9b35c6dc42 | ||
|
|
1c95109b7b | ||
|
|
08d648cfa8 | ||
|
|
40dd9bb68f | ||
|
|
aa0eecb27e | ||
|
|
b956c57652 | ||
|
|
fbe0674b04 | ||
|
|
1705fb2a81 | ||
|
|
a5f2760023 | ||
|
|
20081b14bd | ||
|
|
d4c38b7550 | ||
|
|
c681b891eb | ||
|
|
c19477a4f4 | ||
|
|
1cfda41ef2 | ||
|
|
19787ed461 | ||
|
|
a7069a8f91 | ||
|
|
bb127d1cc2 | ||
|
|
927ca6e4ca | ||
|
|
66cf998fb2 | ||
|
|
93512fc3f7 | ||
|
|
555f0e8117 | ||
|
|
cbd8fd0342 | ||
|
|
60a74a7e0e | ||
|
|
37db310157 | ||
|
|
13fe8b4593 | ||
|
|
a5f96b57e6 | ||
|
|
f0a7a043ec | ||
|
|
58249d6a13 | ||
|
|
d9dc7ddf3a | ||
|
|
cc1ad1552c | ||
|
|
a3feb39e20 | ||
|
|
7676579659 | ||
|
|
bcb3729fa9 | ||
|
|
49a824c171 | ||
|
|
2a050f113b | ||
|
|
e4b99dd44b | ||
|
|
18584ab81f | ||
|
|
a4f7360336 | ||
|
|
eabafecaeb | ||
|
|
01c08322c1 | ||
|
|
a41074d271 | ||
|
|
452a15b652 | ||
|
|
4713be02de | ||
|
|
89dcef8ffd | ||
|
|
0d8bc7b269 | ||
|
|
fa432266f3 | ||
|
|
0cbe51ad19 | ||
|
|
5856729e9a | ||
|
|
c35b11a155 | ||
|
|
3f487c03cc | ||
|
|
5481f8e8cf | ||
|
|
cdbb0d6bd7 | ||
|
|
c9114fef01 | ||
|
|
480a96bd0e | ||
|
|
685392f073 | ||
|
|
0c558fb33b | ||
|
|
ac7840c4e5 | ||
|
|
f0988070bf | ||
|
|
87376ee73b | ||
|
|
b2726db84a | ||
|
|
4d77a0b178 | ||
|
|
bd6e2b4472 | ||
|
|
16b44d2d3a | ||
|
|
71c08501e7 | ||
|
|
297cddf31d | ||
|
|
57eef4311f | ||
|
|
b5e5fb5cd2 | ||
|
|
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 | ||
|
|
9a9152f7f8 | ||
|
|
d9ceb8ac78 | ||
|
|
2d52f3251e | ||
|
|
de32b8b5f6 | ||
|
|
e1af1c8dc9 | ||
|
|
85949eef8c | ||
|
|
62015a4e2d | ||
|
|
44de61a06a | ||
|
|
4b9853eef7 | ||
|
|
4e0cae9c20 | ||
|
|
aa2cdef1d2 | ||
|
|
d6726f976a | ||
|
|
cea6a58c18 | ||
|
|
59a5ca622d | ||
|
|
05415d96d5 | ||
|
|
fca3039208 | ||
|
|
b12502c8f6 | ||
|
|
764f978897 | ||
|
|
8c2d2d5289 | ||
|
|
9580fafe4f | ||
|
|
49ecfe261a | ||
|
|
89e5f4bde9 | ||
|
|
2eab955f5f | ||
|
|
2bd671d305 | ||
|
|
e5a1ee2ac3 | ||
|
|
8f997314b0 | ||
|
|
2192e68684 | ||
|
|
21aeaafced | ||
|
|
bbfc724ceb | ||
|
|
7d3a6f9780 | ||
|
|
19fccde3a3 | ||
|
|
2cc2ac6efd | ||
|
|
270496b3d5 | ||
|
|
db477f970c | ||
|
|
d1ede223cf | ||
|
|
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 | ||
|
|
e2b2e2aac6 | ||
|
|
5fe644b079 | ||
|
|
9e299c52cd | ||
|
|
4b69278bec | ||
|
|
0f3a16c79a | ||
|
|
25f1bd3f10 | ||
|
|
aba72ca6b6 | ||
|
|
6ec6f79b83 | ||
|
|
0266ad5ea5 | ||
|
|
91c141b7ee | ||
|
|
4577ce5ca7 | ||
|
|
7d843ca02a | ||
|
|
89de08e08c | ||
|
|
02213d1edf | ||
|
|
9dedb66800 | ||
|
|
24408e0cba | ||
|
|
4d251a2a0d | ||
|
|
52972eefef | ||
|
|
471b412dc5 | ||
|
|
4c85a8f05e | ||
|
|
0c441b2c86 | ||
|
|
f6ddd62b13 | ||
|
|
5fd67e53c4 | ||
|
|
1206c57d6c | ||
|
|
38815fa0aa | ||
|
|
959206b0be | ||
|
|
7b7ee523fd | ||
|
|
b1220b4c19 | ||
|
|
62b894a7ba | ||
|
|
45188ef87d | ||
|
|
2185ccf2c9 | ||
|
|
e8d95178c7 | ||
|
|
a3ebe27b76 | ||
|
|
b39d4fa15a | ||
|
|
f86b2b816b | ||
|
|
fe72286273 | ||
|
|
2b8ec744dd | ||
|
|
5f11f5b114 | ||
|
|
61f62363b3 | ||
|
|
3278e32711 | ||
|
|
0be6455d46 | ||
|
|
0bf5a39a92 | ||
|
|
5424648158 | ||
|
|
dcfd95f276 | ||
|
|
4d6f7d8b08 | ||
|
|
2c2cf8cdf8 | ||
|
|
08c738dc69 | ||
|
|
308a75037e | ||
|
|
bda41a0c1a | ||
|
|
ec5e7ed1cd | ||
|
|
6528d445d3 | ||
|
|
aacf1bef9c | ||
|
|
4262553036 | ||
|
|
a3d6c6f916 | ||
|
|
bce05eb071 | ||
|
|
e35ea061d5 | ||
|
|
8c7c168fdf | ||
|
|
7a811ad55e | ||
|
|
8d29be2a6d | ||
|
|
36567f40a1 | ||
|
|
76e0de7d20 | ||
|
|
74a41a2d49 | ||
|
|
1ba82df9e3 | ||
|
|
ccf995ec11 | ||
|
|
159b7c2ab6 | ||
|
|
dc60ad1137 | ||
|
|
1c46708303 | ||
|
|
532042e39e | ||
|
|
5cb6faa2d6 | ||
|
|
91b88a40ea | ||
|
|
72a9691c55 | ||
|
|
00e8280d22 | ||
|
|
ed2c7606c3 | ||
|
|
c1a6262d96 | ||
|
|
59b953d577 | ||
|
|
0854174140 | ||
|
|
3802df0abd | ||
|
|
57f09cc50c | ||
|
|
5257a035d7 | ||
|
|
eb36b0bb7b | ||
|
|
3fd14e21eb | ||
|
|
408895b607 | ||
|
|
1a01147a95 | ||
|
|
8950907428 | ||
|
|
0cab036718 | ||
|
|
4fabd8cfd0 | ||
|
|
6b7f63d784 | ||
|
|
95b56c80c5 | ||
|
|
f2020b7653 | ||
|
|
97bb4f6641 | ||
|
|
adeed5ae13 | ||
|
|
eb40a88437 | ||
|
|
7f82049beb | ||
|
|
5e9cce4b8c | ||
|
|
c0c1d745f3 | ||
|
|
c2b38a7c5a | ||
|
|
ae8638a4b2 | ||
|
|
b864fafa82 | ||
|
|
ee5ab1a751 | ||
|
|
64b884e27a | ||
|
|
dc8bb2cbc2 | ||
|
|
d882fc292c | ||
|
|
5dc0980ced | ||
|
|
1cd668c869 | ||
|
|
22a8fab1c7 | ||
|
|
a827ebf6f4 | ||
|
|
f6a0cc9fad | ||
|
|
765f0d63b6 | ||
|
|
c193e88990 | ||
|
|
6398d8f7eb | ||
|
|
030b8c5459 | ||
|
|
469af1ac5d | ||
|
|
6c635dfd01 | ||
|
|
299e93ffe3 | ||
|
|
4dc5b2ee30 | ||
|
|
6613461eea | ||
|
|
9de442b0c8 | ||
|
|
6bc65fe559 | ||
|
|
3917ae02e2 | ||
|
|
bd14c7dc63 | ||
|
|
e0e28aee38 | ||
|
|
1550eedc12 | ||
|
|
b2074dfd02 | ||
|
|
e9171d6f21 | ||
|
|
7da423ee1b | ||
|
|
c6ccefbd72 | ||
|
|
72d25f576c | ||
|
|
ef60bba2e1 | ||
|
|
12fb942f16 | ||
|
|
3a2481e8b2 | ||
|
|
bede5ae8d7 | ||
|
|
445b186e3b | ||
|
|
7ab9481fab | ||
|
|
f494e03a6e | ||
|
|
a90dce95a3 | ||
|
|
a35f6ad939 | ||
|
|
80e704fd26 | ||
|
|
88fc958729 | ||
|
|
354fe61b85 | ||
|
|
95f5ae610e | ||
|
|
2e806a28b9 | ||
|
|
2ab0350733 | ||
|
|
ce813bc216 | ||
|
|
12d6940a6e | ||
|
|
34bf726cc1 | ||
|
|
21fe047e00 | ||
|
|
8558450378 | ||
|
|
f9e68b628d | ||
|
|
68af217b7b | ||
|
|
50509d0a16 | ||
|
|
c1c0494912 | ||
|
|
58e615462c | ||
|
|
f0bf769f0d | ||
|
|
5eda1c4cb0 | ||
|
|
423d50cfb5 | ||
|
|
2f4a62e03c | ||
|
|
e64bea41e6 | ||
|
|
f5c934f744 | ||
|
|
89c71f6b16 | ||
|
|
f0acda0f01 | ||
|
|
af4e4561ec | ||
|
|
1787059f42 | ||
|
|
b2705cb2ae | ||
|
|
4523bc5532 | ||
|
|
f236d72a19 | ||
|
|
cf270a36ff | ||
|
|
6d932386b0 | ||
|
|
b9c66e0c7b | ||
|
|
f26c7cfe02 | ||
|
|
9c054b9e3a | ||
|
|
d9f0007a2d | ||
|
|
ee35f52baf | ||
|
|
3bbe29c2e8 | ||
|
|
e0cc1f7cb2 | ||
|
|
9faa1b7961 | ||
|
|
142d7e639b | ||
|
|
9e115902b7 | ||
|
|
d2ec68808c | ||
|
|
352186eb40 | ||
|
|
68e742b670 | ||
|
|
d7c4586358 | ||
|
|
84f784e538 | ||
|
|
21347420f3 | ||
|
|
26987459f3 | ||
|
|
5f9822f726 | ||
|
|
41a1b94811 | ||
|
|
e4b1d39c4e | ||
|
|
136de95290 | ||
|
|
897388853b | ||
|
|
1afe66cb66 | ||
|
|
bdf1626273 | ||
|
|
87131ad633 | ||
|
|
439418e419 | ||
|
|
0eb4e15a8f | ||
|
|
5f3928399c | ||
|
|
0c0f2d54ad | ||
|
|
2f54fe6cf6 | ||
|
|
e827a26458 | ||
|
|
c886d55317 | ||
|
|
0189f576c7 | ||
|
|
13c46d0f5e | ||
|
|
60175108df | ||
|
|
7c2b87f49a | ||
|
|
f33ee40779 | ||
|
|
b9142bc40c | ||
|
|
680e0e0976 | ||
|
|
ef52332b8b | ||
|
|
1489378ffd | ||
|
|
ccc93f881a | ||
|
|
ded8b68098 | ||
|
|
983be8b37a | ||
|
|
7b22bbf25f | ||
|
|
06f2b9ec97 | ||
|
|
7fee4cea4f | ||
|
|
526897b23b | ||
|
|
c10c2a290c | ||
|
|
fb5204b0a6 | ||
|
|
9db4048bc0 | ||
|
|
63c68b4d4d | ||
|
|
953ef37882 | ||
|
|
da85a2dcc2 | ||
|
|
49869792cf | ||
|
|
fb2dda1ed1 | ||
|
|
fad4c4ea36 | ||
|
|
6b5345a6e5 | ||
|
|
ca413a16fa | ||
|
|
b8b670642c | ||
|
|
2a2e2924eb | ||
|
|
adea3de737 | ||
|
|
7d300a39c9 | ||
|
|
688a5f2add | ||
|
|
d736e5aafe | ||
|
|
3a536ad348 | ||
|
|
5dedeb4971 | ||
|
|
7624e24ea6 | ||
|
|
7b248d8ab4 | ||
|
|
fdb2009856 | ||
|
|
8419a75b04 | ||
|
|
5d474d6fe8 | ||
|
|
e597505a1c | ||
|
|
8675d263e7 | ||
|
|
1ce66b9e03 | ||
|
|
cfda124995 | ||
|
|
212f1cacca | ||
|
|
dd89de7cad | ||
|
|
8b4372dc7f | ||
|
|
2a25557632 | ||
|
|
0cbb339948 | ||
|
|
1496f51e30 | ||
|
|
d1c5fe0605 | ||
|
|
56786f60ff | ||
|
|
af5d36f69f | ||
|
|
e40da71ef8 | ||
|
|
26b8bf422c | ||
|
|
0a545706bd | ||
|
|
9ebac610c7 | ||
|
|
5fc8a6af2a | ||
|
|
8e68af79aa | ||
|
|
6246e6e821 | ||
|
|
421d5ffdc8 | ||
|
|
b82dabe316 | ||
|
|
ffdaf14ba5 | ||
|
|
f52527a41b | ||
|
|
56a89c5fc6 | ||
|
|
4f5163be01 | ||
|
|
822c094c8c | ||
|
|
1623f443bb | ||
|
|
aa47bc4499 | ||
|
|
f461322842 | ||
|
|
cce05a0077 | ||
|
|
98dc868f47 | ||
|
|
821a41c10e | ||
|
|
853ccd657a | ||
|
|
680fc81db2 | ||
|
|
36470eda24 | ||
|
|
a37dd6c8cb | ||
|
|
588f742871 | ||
|
|
ff25a10e5b | ||
|
|
499457f66a | ||
|
|
6d15050009 | ||
|
|
5ba30031c3 | ||
|
|
82c0eef504 | ||
|
|
616267e997 | ||
|
|
161b0c8c21 | ||
|
|
facd185d6c | ||
|
|
42858bf336 | ||
|
|
716be88caf | ||
|
|
b296726a9d | ||
|
|
092f18d7a5 | ||
|
|
f1ef33e319 | ||
|
|
fc9bc95418 | ||
|
|
c61e64f332 | ||
|
|
70ebb8ef1a | ||
|
|
a4c6a92478 | ||
|
|
76b453e535 | ||
|
|
19acdd87f5 | ||
|
|
492e1335ef | ||
|
|
23cde7add3 | ||
|
|
a20c28db25 | ||
|
|
f07d46c49e | ||
|
|
e9781a24a6 | ||
|
|
15be15ba58 | ||
|
|
0952b76e11 | ||
|
|
8011d41e53 | ||
|
|
5412f23d26 | ||
|
|
0c39ff47f2 | ||
|
|
537af905f6 | ||
|
|
6b4f70bde3 | ||
|
|
be2b6d2c1f | ||
|
|
0c1a6d8f19 | ||
|
|
2821997260 | ||
|
|
0546a33b10 | ||
|
|
deb98d8dfb | ||
|
|
72c658eda7 | ||
|
|
df17f10c8a | ||
|
|
9cacf2dc8e | ||
|
|
c7bc9f5b1c | ||
|
|
49ba8ae0d2 | ||
|
|
7291dbd9e2 | ||
|
|
fb4cd75cb2 | ||
|
|
8b7cecc1c5 | ||
|
|
3a62442ed0 | ||
|
|
012dcdc2dd | ||
|
|
3a1b92f9c4 | ||
|
|
629eb66595 | ||
|
|
36749a40d3 | ||
|
|
4336e6dc78 | ||
|
|
3e3e87e73e | ||
|
|
1b8d6ce7fa | ||
|
|
60f1df1488 | ||
|
|
ff86869c33 | ||
|
|
30f97394ec | ||
|
|
592308c1c6 | ||
|
|
2a2d817314 | ||
|
|
8bcfc63da0 | ||
|
|
a9cfff2692 | ||
|
|
9e7ff56113 | ||
|
|
9071143bbd | ||
|
|
7845ac8be5 | ||
|
|
40770aff15 | ||
|
|
81547013f9 | ||
|
|
8e605cbd0f | ||
|
|
d664d46ca4 | ||
|
|
b4031936a0 | ||
|
|
f84a33bbf2 | ||
|
|
8f5c59683a | ||
|
|
4b7146afe4 | ||
|
|
2bc5ef34ee | ||
|
|
939407675b | ||
|
|
6b9a3d95cd | ||
|
|
20ac6b2cd4 | ||
|
|
904b45e8f6 | ||
|
|
1bd54c530b | ||
|
|
4fe51cef96 | ||
|
|
d005e2e2e7 | ||
|
|
672ce024f8 | ||
|
|
8224e93447 | ||
|
|
1ba810fffb | ||
|
|
1a725d0d31 | ||
|
|
51c5b42a78 | ||
|
|
2908827018 | ||
|
|
b985cbf694 | ||
|
|
1293d92896 | ||
|
|
705d41931d | ||
|
|
29de69d323 | ||
|
|
28727d89f6 | ||
|
|
4704bcf52f | ||
|
|
13c148fb6c | ||
|
|
e6079452f9 | ||
|
|
b68b7d5c9b | ||
|
|
741fcdb4d9 | ||
|
|
642f8c5398 | ||
|
|
1c15d5e7d3 | ||
|
|
e71090338c | ||
|
|
7c0feaaae0 | ||
|
|
5aa3ff4bb5 | ||
|
|
d4c83db428 | ||
|
|
9f2d51fd4d | ||
|
|
36137e8970 | ||
|
|
823e56926f | ||
|
|
dd8a54dd43 | ||
|
|
1ff33b96fa | ||
|
|
4be9273768 | ||
|
|
f458ac2162 | ||
|
|
b5ea2bb4c1 | ||
|
|
284d257921 | ||
|
|
30bf6b7f9a | ||
|
|
4941b6bd23 | ||
|
|
33d99817ec | ||
|
|
37e1af50ad | ||
|
|
8a6efb1303 | ||
|
|
7823b19b89 | ||
|
|
2a9aa544a9 | ||
|
|
f387c8ff85 | ||
|
|
7e537aec0b | ||
|
|
66cd465565 | ||
|
|
83afa40423 | ||
|
|
486e7eb101 | ||
|
|
05eb9e60d3 | ||
|
|
dde7095644 | ||
|
|
f1e9a2915d | ||
|
|
ae3495d373 | ||
|
|
6fb2c1b688 | ||
|
|
1526c558e7 | ||
|
|
324e0f053b | ||
|
|
25cb33c78e | ||
|
|
942b6d9569 | ||
|
|
cd46c79383 | ||
|
|
0bdcdcc229 | ||
|
|
1a5863a7fb | ||
|
|
701015ad55 | ||
|
|
63cfac626a | ||
|
|
e6c5a21bfc | ||
|
|
2d80739141 | ||
|
|
6494102e15 | ||
|
|
0e6aa2efd9 | ||
|
|
f412c216c5 | ||
|
|
af15e3d914 | ||
|
|
b00ff3f3f0 | ||
|
|
1607e6830e | ||
|
|
817e0bf2bd | ||
|
|
0f12fbce6a | ||
|
|
953a09d75f | ||
|
|
5098989614 | ||
|
|
5828bcffdd | ||
|
|
ae87a7d58f | ||
|
|
32ab78a213 | ||
|
|
69583d172c | ||
|
|
38367c1c77 | ||
|
|
2f6bf91a1c | ||
|
|
60b062bbaf | ||
|
|
30e8b604a9 | ||
|
|
7c3ab92e17 | ||
|
|
37b101c70f | ||
|
|
b7be46e6ae | ||
|
|
bf1f79866b | ||
|
|
a6460426a2 | ||
|
|
304ba14d20 | ||
|
|
db47233d92 | ||
|
|
74eeb98be8 | ||
|
|
331da0f897 | ||
|
|
73964ee648 | ||
|
|
a5e8402141 | ||
|
|
c5e7fcf29b | ||
|
|
d3cf6d30a7 | ||
|
|
74e14f7a43 | ||
|
|
02e347adb0 | ||
|
|
56983cb85b | ||
|
|
7917c656b0 | ||
|
|
fc34c1e548 | ||
|
|
f32aeaa0ff | ||
|
|
86097a932c | ||
|
|
f74f24c41f | ||
|
|
8e99e7b07e | ||
|
|
e06aab6e87 | ||
|
|
a81e56fb26 | ||
|
|
9a09b119c5 | ||
|
|
4b28ca1055 | ||
|
|
d684d9f8d1 |
@@ -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,11 +290,28 @@ 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
|
||||
|
||||
- name: Normalize ffmpeg plugin shell scripts (strip CRLF)
|
||||
run: |
|
||||
find "$HOME/.pub-cache/hosted" -path "*ffmpeg_kit_flutter_new_full*/scripts/*.sh" -type f -print0 |
|
||||
while IFS= read -r -d '' f; do
|
||||
perl -pi -e 's/\r$//' "$f"
|
||||
chmod +x "$f"
|
||||
echo "Normalized line endings: $f"
|
||||
done
|
||||
|
||||
- name: Generate app icons
|
||||
run: dart run flutter_launcher_icons
|
||||
|
||||
@@ -379,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:"
|
||||
@@ -390,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
|
||||
@@ -438,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
|
||||
@@ -556,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
|
||||
@@ -565,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
|
||||
@@ -575,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!"
|
||||
|
||||
@@ -1,83 +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/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/app/libs/gobackend-sources.jar
|
||||
# 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
|
||||
# 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
|
||||
network_requests.txt
|
||||
|
||||
# Log files
|
||||
NUL
|
||||
/network_requests.txt
|
||||
/AndroidManifest.xml
|
||||
*.bak
|
||||
*.orig
|
||||
*.swp
|
||||
*.tmp
|
||||
*.log
|
||||
hs_err_*.log
|
||||
flutter_*.log
|
||||
|
||||
# Development tools
|
||||
tool/
|
||||
.claude/settings.local.json
|
||||
.playwright-mcp/
|
||||
|
||||
# FVM Version Cache
|
||||
.fvm/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.38.1)**
|
||||
```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.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/images/banner-readme-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="assets/images/banner-readme-light.png">
|
||||
<img alt="SpotiFLAC Mobile" src="assets/images/banner-readme-light.png" width="650" height="auto">
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/readme/banner-readme-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="assets/readme/banner-readme-light.png">
|
||||
<img alt="SpotiFLAC Mobile" src="assets/readme/banner-readme-light.png" width="650" height="auto">
|
||||
</picture>
|
||||
|
||||
<p align="center">
|
||||
@@ -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)
|
||||
@@ -28,10 +27,10 @@
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/images/1.jpg?v=2" width="200" />
|
||||
<img src="assets/images/2.jpg?v=2" width="200" />
|
||||
<img src="assets/images/3.jpg?v=2" width="200" />
|
||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||
<img src="assets/readme/1.jpg?v=2" width="200" />
|
||||
<img src="assets/readme/2.jpg?v=2" width="200" />
|
||||
<img src="assets/readme/3.jpg?v=2" width="200" />
|
||||
<img src="assets/readme/4.jpg?v=2" width="200" />
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -52,14 +51,29 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
## Related Projects
|
||||
|
||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music available for Windows, macOS & Linux.
|
||||
Download music in true lossless FLAC from extension-provided sources on Windows, macOS & Linux.
|
||||
|
||||
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
|
||||
Python library for SpotiFLAC integration, maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu).
|
||||
@@ -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>
|
||||
|
||||
@@ -80,7 +97,7 @@ Starting from version 3.8.0, SpotiFLAC uses a decentralized extension repository
|
||||
<summary><b>Why is my download failing with "Song not found"?</b></summary>
|
||||
<br>
|
||||
|
||||
The track may not be available on the streaming services. Try enabling more providers under **Settings > Download > Provider Priority**, or install additional extensions like Amazon Music from the Store.
|
||||
The track may not be available from your enabled providers. Try enabling more providers under **Settings > Extensions > Provider Priority**, or install additional download extensions from the Store.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -88,10 +105,7 @@ The track may not be available on the streaming services. Try enabling more prov
|
||||
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
|
||||
<br>
|
||||
|
||||
Quality depends on what's available from the streaming service and its extensions. Built-in providers:
|
||||
- **Tidal** up to 24-bit/192kHz
|
||||
- **Qobuz** up to 24-bit/192kHz
|
||||
- **Deezer** up to 16-bit/44.1kHz
|
||||
Quality depends on what's available from the source and the installed download extension. Check each extension's quality options and service notes in the app.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -107,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>
|
||||
|
||||
@@ -115,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>
|
||||
|
||||
@@ -128,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.
|
||||
@@ -142,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)
|
||||
|
||||
@@ -152,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.
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
plugins:
|
||||
riverpod_lint: 3.1.4-dev.3
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
@@ -19,9 +22,6 @@ analyzer:
|
||||
strict-casts: true
|
||||
strict-inference: true
|
||||
strict-raw-types: true
|
||||
plugins:
|
||||
- custom_lint
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
@@ -43,10 +43,9 @@ linter:
|
||||
type_annotate_public_apis: true
|
||||
cancel_subscriptions: true
|
||||
close_sinks: true
|
||||
|
||||
custom_lint:
|
||||
rules:
|
||||
- avoid_public_notifier_properties
|
||||
# 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
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
plugins {
|
||||
id "com.android.application"
|
||||
id "kotlin-android"
|
||||
id "dev.flutter.flutter-gradle-plugin"
|
||||
}
|
||||
|
||||
def localProperties = new Properties()
|
||||
def localPropertiesFile = rootProject.file('local.properties')
|
||||
if (localPropertiesFile.exists()) {
|
||||
localPropertiesFile.withReader('UTF-8') { reader ->
|
||||
localProperties.load(reader)
|
||||
}
|
||||
}
|
||||
|
||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||
if (flutterVersionCode == null) {
|
||||
flutterVersionCode = '1'
|
||||
}
|
||||
|
||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||
if (flutterVersionName == null) {
|
||||
flutterVersionName = '1.0'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "com.zarz.spotiflac"
|
||||
compileSdk flutter.compileSdkVersion
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.zarz.spotiflac"
|
||||
minSdkVersion flutter.minSdkVersion
|
||||
targetSdk flutter.targetSdkVersion
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.debug
|
||||
minifyEnabled false
|
||||
shrinkResources false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source '../..'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Go backend library (gomobile generated)
|
||||
implementation fileTree(dir: 'libs', include: ['*.aar'])
|
||||
|
||||
// Kotlin coroutines for async Go backend calls
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||
}
|
||||
@@ -17,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"
|
||||
}
|
||||
@@ -120,9 +122,14 @@ dependencies {
|
||||
// Include all AAR and JAR files from libs folder
|
||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0")
|
||||
implementation("androidx.documentfile:documentfile:1.1.0")
|
||||
implementation("androidx.activity:activity-ktx:1.13.0")
|
||||
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"
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
package com.example.temp_project
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
@@ -15,6 +17,45 @@ import java.util.Locale
|
||||
object SafDownloadHandler {
|
||||
private val safDirLock = Any()
|
||||
private const val MAX_SAF_DISPLAY_NAME_UTF8_BYTES = 180
|
||||
private const val STAGED_SAF_MIME_TYPE = "application/octet-stream"
|
||||
|
||||
// Serializes the exists-check/create/write/publish sequence per target
|
||||
// file so concurrent downloads that sanitize to the same display name
|
||||
// cannot interleave writes into one document. Different names keep
|
||||
// downloading in parallel; the second same-name caller blocks, then hits
|
||||
// the exists check and reports already_exists.
|
||||
private val safNameLocks = java.util.concurrent.ConcurrentHashMap<String, Any>()
|
||||
|
||||
data class UniqueWriteResult(val uri: String, val fileName: String)
|
||||
data class ExistingAwareWriteResult(
|
||||
val uri: String,
|
||||
val fileName: String,
|
||||
val alreadyExists: Boolean,
|
||||
)
|
||||
|
||||
private fun <T> withSafNameLock(
|
||||
treeUriStr: String,
|
||||
relativeDir: String,
|
||||
fileName: String,
|
||||
block: () -> T
|
||||
): T {
|
||||
val key = "$treeUriStr|$relativeDir|${fileName.lowercase(Locale.ROOT)}"
|
||||
val lock = safNameLocks.computeIfAbsent(key) { Any() }
|
||||
return synchronized(lock) { block() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes and fsyncs a SAF output stream so the copied bytes are durable
|
||||
* before the staged document is renamed to its final name; without this a
|
||||
* power loss right after the rename can leave a truncated "complete" file.
|
||||
*/
|
||||
fun syncOutputStream(output: OutputStream) {
|
||||
try {
|
||||
output.flush()
|
||||
(output as? FileOutputStream)?.fd?.sync()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
fun handle(context: Context, requestJson: String, downloader: (String) -> String): String {
|
||||
val req = JSONObject(requestJson)
|
||||
@@ -24,23 +65,42 @@ 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
|
||||
val stagedFileName = if (useStagedOutput) buildStagedSafFileName(fileName, outputExt) else fileName
|
||||
val staleStagedFileName = buildStagedSafFileName(fileName, outputExt)
|
||||
// Downloads are always written under a staged ".partial" name so a
|
||||
// killed process can never leave a half-written file under the final
|
||||
// name (which the exists check would then accept as complete forever).
|
||||
// Callers that set stage_saf_output own the promotion to the final
|
||||
// name (the native finalizer); for everyone else — the foreground
|
||||
// Dart queue — this handler promotes right after a successful write.
|
||||
val finalizerPromotesStaged = req.optBoolean("stage_saf_output", false) && !deferSafPublish
|
||||
val useStagedOutput = !deferSafPublish
|
||||
val stagedFileName = if (useStagedOutput) buildStagedSafFileName(fileName) else fileName
|
||||
val stagedMimeType = if (useStagedOutput) STAGED_SAF_MIME_TYPE else mimeType
|
||||
|
||||
val existingDir = findDocumentDir(context, treeUri, relativeDir)
|
||||
if (existingDir != null) {
|
||||
val existing = existingDir.findFile(fileName)
|
||||
if (existing != null && existing.isFile && existing.length() > 0) {
|
||||
if (useStagedOutput || deferSafPublish) {
|
||||
existingDir.findFile(staleStagedFileName)?.delete()
|
||||
}
|
||||
deleteStaleStagedFiles(existingDir, fileName, outputExt)
|
||||
val obj = JSONObject()
|
||||
obj.put("success", true)
|
||||
obj.put("message", "File already exists")
|
||||
@@ -55,7 +115,7 @@ object SafDownloadHandler {
|
||||
?: return errorJson("Failed to access SAF directory")
|
||||
|
||||
if (deferSafPublish) {
|
||||
targetDir.findFile(staleStagedFileName)?.delete()
|
||||
deleteStaleStagedFiles(targetDir, fileName, outputExt)
|
||||
val workingExt = outputExt.ifBlank { ".tmp" }
|
||||
val workingFile = File.createTempFile("native_saf_work_", workingExt, context.cacheDir)
|
||||
Log.i("SpotiFLAC", "SAF deferred native output: target=$fileName working=${workingFile.name}")
|
||||
@@ -89,7 +149,12 @@ object SafDownloadHandler {
|
||||
}
|
||||
}
|
||||
|
||||
var document = createOrReuseDocumentFile(targetDir, mimeType, stagedFileName)
|
||||
// Remove any stale partial from a previous killed attempt before
|
||||
// creating the staged document: reusing it would let a shorter new
|
||||
// write leave the old tail bytes in place (fd truncation is
|
||||
// best-effort on some providers).
|
||||
deleteStaleStagedFiles(targetDir, fileName, outputExt)
|
||||
var document = createOrReuseDocumentFile(targetDir, stagedMimeType, stagedFileName)
|
||||
?: return errorJson("Failed to create SAF file")
|
||||
|
||||
val pfd = context.contentResolver.openFileDescriptor(document.uri, "rw")
|
||||
@@ -104,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://") &&
|
||||
@@ -121,14 +187,14 @@ object SafDownloadHandler {
|
||||
if (actualExt.isNotBlank() && actualExt != outputExt) {
|
||||
val actualFileName = buildSafFileName(req, actualExt)
|
||||
val actualStagedFileName = if (useStagedOutput) {
|
||||
buildStagedSafFileName(actualFileName, actualExt)
|
||||
buildStagedSafFileName(actualFileName)
|
||||
} else {
|
||||
actualFileName
|
||||
}
|
||||
val actualMimeType = mimeTypeForExt(actualExt)
|
||||
val replacement = createOrReuseDocumentFile(
|
||||
targetDir,
|
||||
actualMimeType,
|
||||
if (useStagedOutput) STAGED_SAF_MIME_TYPE else actualMimeType,
|
||||
actualStagedFileName
|
||||
) ?: throw IllegalStateException(
|
||||
"failed to create SAF output with actual extension"
|
||||
@@ -137,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) {
|
||||
@@ -155,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()
|
||||
@@ -176,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)
|
||||
@@ -205,15 +320,201 @@ 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, ext)
|
||||
val document = createOrReuseDocumentFile(targetDir, mimeType, stagedName)
|
||||
val stagedName = buildStagedSafFileName(finalName)
|
||||
deleteStaleStagedFiles(targetDir, finalName, ext)
|
||||
val document = createOrReuseDocumentFile(targetDir, STAGED_SAF_MIME_TYPE, stagedName)
|
||||
?: return null
|
||||
stagedDocument = document
|
||||
val outputStream = context.contentResolver.openOutputStream(document.uri, "wt")
|
||||
@@ -226,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}")
|
||||
@@ -253,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"
|
||||
}
|
||||
@@ -288,13 +586,17 @@ object SafDownloadHandler {
|
||||
return safeName + normalizedExt
|
||||
}
|
||||
|
||||
private fun buildStagedSafFileName(fileName: String, outputExt: String): String {
|
||||
private fun buildStagedSafFileName(fileName: String): String {
|
||||
val safeName = sanitizeFilename(fileName)
|
||||
return "$safeName.partial"
|
||||
}
|
||||
|
||||
private fun buildLegacyStagedSafFileName(fileName: String, outputExt: String): String {
|
||||
val safeName = sanitizeFilename(fileName)
|
||||
val ext = normalizeExt(outputExt)
|
||||
if (ext.isNotBlank() && safeName.lowercase(Locale.ROOT).endsWith(ext)) {
|
||||
return safeName.dropLast(ext.length).trimEnd('.', ' ') + ".partial$ext"
|
||||
}
|
||||
|
||||
val dot = safeName.lastIndexOf('.')
|
||||
if (dot > 0 && dot < safeName.lastIndex) {
|
||||
return safeName.substring(0, dot).trimEnd('.', ' ') +
|
||||
@@ -304,7 +606,21 @@ object SafDownloadHandler {
|
||||
return "$safeName.partial"
|
||||
}
|
||||
|
||||
private fun sanitizeFilename(name: String): String {
|
||||
private fun deleteStaleStagedFiles(parent: DocumentFile, fileName: String, outputExt: String) {
|
||||
val stagedNames = linkedSetOf(
|
||||
buildStagedSafFileName(fileName),
|
||||
buildLegacyStagedSafFileName(fileName, outputExt),
|
||||
buildReplacedSafFileName(fileName)
|
||||
)
|
||||
for (stagedName in stagedNames) {
|
||||
try {
|
||||
parent.findFile(stagedName)?.delete()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun sanitizeFilename(name: String): String {
|
||||
var sanitized = name
|
||||
.replace("/", " ")
|
||||
.replace(Regex("[\\\\:*?\"<>|]"), " ")
|
||||
@@ -363,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("/")
|
||||
@@ -372,7 +688,7 @@ object SafDownloadHandler {
|
||||
.joinToString("/")
|
||||
}
|
||||
|
||||
private fun ensureDocumentDir(
|
||||
internal fun ensureDocumentDir(
|
||||
context: Context,
|
||||
treeUri: Uri,
|
||||
relativeDir: String
|
||||
@@ -404,7 +720,7 @@ object SafDownloadHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private fun findDocumentDir(
|
||||
internal fun findDocumentDir(
|
||||
context: Context,
|
||||
treeUri: Uri,
|
||||
relativeDir: String
|
||||
@@ -422,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,2 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
|
||||
@@ -1,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 "8.13.2" 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.5.0",
|
||||
"versionDate": "2026-05-06",
|
||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.5.0/SpotiFLAC-v4.5.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": 37191956
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 539 KiB After Width: | Height: | Size: 539 KiB |
|
Before Width: | Height: | Size: 811 KiB After Width: | Height: | Size: 811 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
@@ -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,22 +3,19 @@ files:
|
||||
translation: /lib/l10n/arb/app_%locale%.arb
|
||||
languages_mapping:
|
||||
locale:
|
||||
# Short codes for single-variant languages
|
||||
# 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).
|
||||
# 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: es_ES
|
||||
fr: fr
|
||||
hi: hi
|
||||
id: id
|
||||
ja: ja
|
||||
ko: ko
|
||||
nl: nl
|
||||
pt: pt
|
||||
pt-PT: pt_PT
|
||||
ru: ru
|
||||
tr: tr
|
||||
uk: uk
|
||||
zh: zh
|
||||
# Full codes for Chinese variants
|
||||
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,439 +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
|
||||
|
||||
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")
|
||||
}
|
||||
@@ -308,16 +299,20 @@ func TestM4AMetadataAtomHelpers(t *testing.T) {
|
||||
t.Fatalf("ReplayGain fields = %#v", fields)
|
||||
}
|
||||
|
||||
qualityPath := filepath.Join(dir, "quality.m4a")
|
||||
qualityPath := filepath.Join(dir, "quality-alac.m4a")
|
||||
mvhd := make([]byte, 20)
|
||||
binary.BigEndian.PutUint32(mvhd[12:16], 1000)
|
||||
binary.BigEndian.PutUint32(mvhd[16:20], 180000)
|
||||
sampleEntry := make([]byte, 32)
|
||||
copy(sampleEntry[0:4], "mp4a")
|
||||
copy(sampleEntry[0:4], "alac")
|
||||
binary.BigEndian.PutUint16(sampleEntry[22:24], 24)
|
||||
sampleEntry[28] = 0xAC
|
||||
sampleEntry[29] = 0x44
|
||||
qualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), sampleEntry...))...)
|
||||
alacConfig := make([]byte, 24)
|
||||
alacConfig[5] = 24
|
||||
binary.BigEndian.PutUint32(alacConfig[20:24], 44100)
|
||||
alacEntryPayload := append(append([]byte{}, sampleEntry[4:]...), buildM4AAtom("alac", alacConfig)...)
|
||||
qualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), buildM4AAtom("alac", alacEntryPayload)...))...)
|
||||
if err := os.WriteFile(qualityPath, qualityFile, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -327,6 +322,37 @@ func TestM4AMetadataAtomHelpers(t *testing.T) {
|
||||
if quality, err := GetAudioQuality(qualityPath); err != nil || quality.SampleRate != 44100 {
|
||||
t.Fatalf("GetAudioQuality M4A = %#v/%v", quality, err)
|
||||
}
|
||||
aacQualityPath := filepath.Join(dir, "quality-aac.m4a")
|
||||
copy(sampleEntry[0:4], "mp4a")
|
||||
aacQualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), sampleEntry...))...)
|
||||
if err := os.WriteFile(aacQualityPath, aacQualityFile, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if quality, err := GetM4AQuality(aacQualityPath); err != nil || quality.BitDepth != 0 || quality.SampleRate != 44100 || quality.Duration != 180 {
|
||||
t.Fatalf("GetM4AQuality AAC = %#v/%v", quality, err)
|
||||
}
|
||||
eac3QualityPath := filepath.Join(dir, "quality-eac3.m4a")
|
||||
zeroMvhd := make([]byte, 20)
|
||||
eac3SampleEntry := make([]byte, 32)
|
||||
copy(eac3SampleEntry[0:4], "ec-3")
|
||||
eac3SampleEntry[28] = 0xBB
|
||||
eac3SampleEntry[29] = 0x80
|
||||
mdhd := make([]byte, 20)
|
||||
binary.BigEndian.PutUint32(mdhd[12:16], 48000)
|
||||
binary.BigEndian.PutUint32(mdhd[16:20], 48000*123)
|
||||
eac3QualityFile := append(
|
||||
buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")),
|
||||
buildM4AAtom("moov", append(
|
||||
append(buildM4AAtom("mvhd", zeroMvhd), buildM4AAtom("trak", buildM4AAtom("mdia", buildM4AAtom("mdhd", mdhd)))...),
|
||||
eac3SampleEntry...,
|
||||
))...,
|
||||
)
|
||||
if err := os.WriteFile(eac3QualityPath, eac3QualityFile, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if quality, err := GetM4AQuality(eac3QualityPath); err != nil || quality.Codec != "eac3" || quality.Duration != 123 {
|
||||
t.Fatalf("GetM4AQuality EAC3 mdhd fallback = %#v/%v", quality, err)
|
||||
}
|
||||
if _, _, ok := parseALACSpecificConfig(make([]byte, 4)); ok {
|
||||
t.Fatal("short ALAC config should not parse")
|
||||
}
|
||||
@@ -395,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)
|
||||
@@ -409,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")
|
||||
}
|
||||
@@ -480,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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type CrossExtensionShareResult struct {
|
||||
ExtensionID string `json:"extension_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Found bool `json:"found"`
|
||||
URL string `json:"url,omitempty"`
|
||||
ItemName string `json:"item_name,omitempty"`
|
||||
ItemArtists string `json:"item_artists,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
var crossExtensionShareResultCache = struct {
|
||||
sync.RWMutex
|
||||
entries map[string]string
|
||||
order []string
|
||||
}{
|
||||
entries: make(map[string]string),
|
||||
}
|
||||
|
||||
const crossExtensionShareResultCacheLimit = 128
|
||||
|
||||
func FindCollectionAcrossExtensionsJSON(requestJSON string) (string, error) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Artists string `json:"artists"`
|
||||
Type string `json:"type"`
|
||||
SourceExtensionID string `json:"source_extension_id"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
req.Artists = strings.TrimSpace(req.Artists)
|
||||
req.Type = strings.ToLower(strings.TrimSpace(req.Type))
|
||||
req.SourceExtensionID = strings.TrimSpace(req.SourceExtensionID)
|
||||
if req.Name == "" {
|
||||
return "[]", nil
|
||||
}
|
||||
if req.Type == "" {
|
||||
req.Type = "album"
|
||||
}
|
||||
|
||||
providers := getExtensionManager().GetMetadataProviders()
|
||||
work := make([]*extensionProviderWrapper, 0, len(providers))
|
||||
for _, provider := range providers {
|
||||
if provider == nil || provider.extension == nil {
|
||||
continue
|
||||
}
|
||||
if provider.extension.ID == req.SourceExtensionID {
|
||||
continue
|
||||
}
|
||||
work = append(work, provider)
|
||||
}
|
||||
cacheKey := crossExtensionShareCacheKey(req.Name, req.Artists, req.Type, req.SourceExtensionID, work)
|
||||
if cached := getCrossExtensionShareCache(cacheKey); cached != "" {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
query := req.Name
|
||||
if req.Artists != "" {
|
||||
query += " " + req.Artists
|
||||
}
|
||||
|
||||
results := make([]CrossExtensionShareResult, len(work))
|
||||
var wg sync.WaitGroup
|
||||
for i, provider := range work {
|
||||
wg.Add(1)
|
||||
go func(index int, p *extensionProviderWrapper) {
|
||||
defer wg.Done()
|
||||
results[index] = findCollectionForExtension(
|
||||
p,
|
||||
req.Type,
|
||||
req.Name,
|
||||
req.Artists,
|
||||
query,
|
||||
)
|
||||
}(i, provider)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
data, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
return "[]", err
|
||||
}
|
||||
response := string(data)
|
||||
if crossExtensionShareResultsCacheable(results) {
|
||||
setCrossExtensionShareCache(cacheKey, response)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func crossExtensionShareCacheKey(name string, artists string, itemType string, sourceExtensionID string, providers []*extensionProviderWrapper) string {
|
||||
providerKeys := make([]string, 0, len(providers))
|
||||
for _, provider := range providers {
|
||||
if provider == nil || provider.extension == nil {
|
||||
continue
|
||||
}
|
||||
ext := provider.extension
|
||||
displayName := ""
|
||||
if ext.Manifest != nil {
|
||||
displayName = ext.Manifest.DisplayName
|
||||
}
|
||||
providerKeys = append(providerKeys, strings.Join([]string{
|
||||
strings.TrimSpace(ext.ID),
|
||||
strings.TrimSpace(displayName),
|
||||
strings.TrimSpace(ext.SourceDir),
|
||||
}, "\x1f"))
|
||||
}
|
||||
sort.Strings(providerKeys)
|
||||
|
||||
return strings.Join([]string{
|
||||
normalizeLooseTitle(itemType),
|
||||
normalizeLooseTitle(name),
|
||||
normalizeLooseArtistName(artists),
|
||||
strings.TrimSpace(sourceExtensionID),
|
||||
strings.Join(providerKeys, "\x1e"),
|
||||
}, "\x1d")
|
||||
}
|
||||
|
||||
func getCrossExtensionShareCache(key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
crossExtensionShareResultCache.RLock()
|
||||
defer crossExtensionShareResultCache.RUnlock()
|
||||
return crossExtensionShareResultCache.entries[key]
|
||||
}
|
||||
|
||||
func setCrossExtensionShareCache(key string, value string) {
|
||||
if key == "" || value == "" {
|
||||
return
|
||||
}
|
||||
crossExtensionShareResultCache.Lock()
|
||||
defer crossExtensionShareResultCache.Unlock()
|
||||
|
||||
if _, exists := crossExtensionShareResultCache.entries[key]; !exists {
|
||||
crossExtensionShareResultCache.order = append(crossExtensionShareResultCache.order, key)
|
||||
}
|
||||
crossExtensionShareResultCache.entries[key] = value
|
||||
|
||||
for len(crossExtensionShareResultCache.order) > crossExtensionShareResultCacheLimit {
|
||||
oldest := crossExtensionShareResultCache.order[0]
|
||||
crossExtensionShareResultCache.order = crossExtensionShareResultCache.order[1:]
|
||||
delete(crossExtensionShareResultCache.entries, oldest)
|
||||
}
|
||||
}
|
||||
|
||||
func crossExtensionShareResultsCacheable(results []CrossExtensionShareResult) bool {
|
||||
for _, result := range results {
|
||||
if result.Found {
|
||||
continue
|
||||
}
|
||||
errText := strings.ToLower(strings.TrimSpace(result.Error))
|
||||
if errText == "" ||
|
||||
errText == "no results" ||
|
||||
errText == "unsupported collection type" ||
|
||||
strings.HasSuffix(errText, " not found") ||
|
||||
strings.Contains(errText, "found without shareable link") {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func findCollectionForExtension(
|
||||
provider *extensionProviderWrapper,
|
||||
itemType string,
|
||||
name string,
|
||||
artists string,
|
||||
query string,
|
||||
) CrossExtensionShareResult {
|
||||
result := CrossExtensionShareResult{
|
||||
ExtensionID: provider.extension.ID,
|
||||
}
|
||||
if provider.extension.Manifest != nil {
|
||||
result.DisplayName = provider.extension.Manifest.DisplayName
|
||||
}
|
||||
if result.DisplayName == "" {
|
||||
result.DisplayName = provider.extension.ID
|
||||
}
|
||||
|
||||
searchResult, err := searchCollectionCandidates(provider, itemType, query)
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
return result
|
||||
}
|
||||
if searchResult == nil || len(searchResult.Tracks) == 0 {
|
||||
result.Error = "no results"
|
||||
return result
|
||||
}
|
||||
|
||||
var best *ExtTrackMetadata
|
||||
switch itemType {
|
||||
case "artist":
|
||||
best = bestArtistTrack(searchResult.Tracks, name)
|
||||
case "album":
|
||||
best = bestAlbumTrack(searchResult.Tracks, name, artists)
|
||||
default:
|
||||
result.Error = "unsupported collection type"
|
||||
return result
|
||||
}
|
||||
if best == nil {
|
||||
result.Error = itemType + " not found"
|
||||
return result
|
||||
}
|
||||
|
||||
url := resolveCollectionShareURL(provider.extension, itemType, best)
|
||||
if url == "" {
|
||||
result.Error = itemType + " found without shareable link"
|
||||
return result
|
||||
}
|
||||
|
||||
result.Found = true
|
||||
result.URL = url
|
||||
if itemType == "artist" {
|
||||
result.ItemName = collectionArtistName(*best)
|
||||
} else {
|
||||
result.ItemName = collectionAlbumName(*best)
|
||||
result.ItemArtists = best.Artists
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func searchCollectionCandidates(provider *extensionProviderWrapper, itemType string, query string) (*ExtSearchResult, error) {
|
||||
filter := ""
|
||||
switch itemType {
|
||||
case "album":
|
||||
filter = "albums"
|
||||
case "artist":
|
||||
filter = "artists"
|
||||
}
|
||||
|
||||
if filter != "" {
|
||||
tracks, err := provider.CustomSearch(query, map[string]any{
|
||||
"filter": filter,
|
||||
"limit": 10,
|
||||
})
|
||||
if err == nil && len(tracks) > 0 {
|
||||
return &ExtSearchResult{Tracks: tracks, Total: len(tracks)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return provider.SearchTracks(query, 10)
|
||||
}
|
||||
|
||||
func bestAlbumTrack(tracks []ExtTrackMetadata, albumName string, artists string) *ExtTrackMetadata {
|
||||
targetAlbum := normalizeLooseTitle(albumName)
|
||||
targetArtists := normalizeLooseArtistName(artists)
|
||||
bestScore := 0
|
||||
bestIndex := -1
|
||||
|
||||
for i := range tracks {
|
||||
track := tracks[i]
|
||||
album := normalizeLooseTitle(collectionAlbumName(track))
|
||||
trackArtists := normalizeLooseArtistName(track.Artists + " " + track.AlbumArtist)
|
||||
|
||||
score := 0
|
||||
if isCollectionItemType(track, "album") {
|
||||
score += 25
|
||||
}
|
||||
if album == targetAlbum {
|
||||
score += 100
|
||||
} else if album != "" && targetAlbum != "" && (strings.Contains(album, targetAlbum) || strings.Contains(targetAlbum, album)) {
|
||||
score += 50
|
||||
}
|
||||
if targetArtists != "" && (strings.Contains(trackArtists, targetArtists) || strings.Contains(targetArtists, trackArtists)) {
|
||||
score += 30
|
||||
}
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
if bestIndex < 0 || bestScore < 50 {
|
||||
return nil
|
||||
}
|
||||
return &tracks[bestIndex]
|
||||
}
|
||||
|
||||
func bestArtistTrack(tracks []ExtTrackMetadata, artistName string) *ExtTrackMetadata {
|
||||
targetArtist := normalizeLooseArtistName(artistName)
|
||||
bestScore := 0
|
||||
bestIndex := -1
|
||||
|
||||
for i := range tracks {
|
||||
artist := normalizeLooseArtistName(collectionArtistName(tracks[i]))
|
||||
score := 0
|
||||
if isCollectionItemType(tracks[i], "artist") {
|
||||
score += 25
|
||||
}
|
||||
if artist == targetArtist {
|
||||
score += 100
|
||||
} else if artist != "" && targetArtist != "" && (strings.Contains(artist, targetArtist) || strings.Contains(targetArtist, artist)) {
|
||||
score += 60
|
||||
}
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
if bestIndex < 0 || bestScore < 60 {
|
||||
return nil
|
||||
}
|
||||
return &tracks[bestIndex]
|
||||
}
|
||||
|
||||
func resolveCollectionShareURL(ext *loadedExtension, itemType string, track *ExtTrackMetadata) string {
|
||||
if track == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if itemType == "album" {
|
||||
if isCollectionItemType(*track, "album") {
|
||||
if url := normalizeShareURL(track.ExternalURL); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
if url := normalizeShareURL(track.AlbumURL); url != "" {
|
||||
return url
|
||||
}
|
||||
if url := urlFromExternalLinks(track.ExternalLinks, "album"); url != "" {
|
||||
return url
|
||||
}
|
||||
if url := templateShareURL(ext, "album", firstNonEmptyTrimmed(track.AlbumID, collectionID(*track, "album"), track.AlbumURL)); url != "" {
|
||||
return url
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
if isCollectionItemType(*track, "artist") {
|
||||
if url := normalizeShareURL(track.ExternalURL); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
if url := normalizeShareURL(track.ArtistURL); url != "" {
|
||||
return url
|
||||
}
|
||||
if url := urlFromExternalLinks(track.ExternalLinks, "artist"); url != "" {
|
||||
return url
|
||||
}
|
||||
if url := templateShareURL(ext, "artist", firstNonEmptyTrimmed(track.ArtistID, collectionID(*track, "artist"))); url != "" {
|
||||
return url
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func collectionAlbumName(track ExtTrackMetadata) string {
|
||||
if isCollectionItemType(track, "album") {
|
||||
return track.Name
|
||||
}
|
||||
return track.AlbumName
|
||||
}
|
||||
|
||||
func collectionArtistName(track ExtTrackMetadata) string {
|
||||
if isCollectionItemType(track, "artist") {
|
||||
return track.Name
|
||||
}
|
||||
return track.Artists
|
||||
}
|
||||
|
||||
func collectionID(track ExtTrackMetadata, itemType string) string {
|
||||
if isCollectionItemType(track, itemType) {
|
||||
return track.ID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isCollectionItemType(track ExtTrackMetadata, itemType string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(track.ItemType), itemType)
|
||||
}
|
||||
|
||||
func normalizeShareURL(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
|
||||
return trimmed
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func urlFromExternalLinks(links map[string]string, preferredKey string) string {
|
||||
for key, value := range links {
|
||||
if strings.Contains(strings.ToLower(key), preferredKey) {
|
||||
if url := normalizeShareURL(value); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func templateShareURL(ext *loadedExtension, itemType string, id string) string {
|
||||
if ext == nil || ext.Manifest == nil || ext.Manifest.Capabilities == nil {
|
||||
return ""
|
||||
}
|
||||
id = stripProviderPrefix(strings.TrimSpace(id))
|
||||
if id == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
templates, ok := ext.Manifest.Capabilities["shareUrlTemplates"].(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
rawTemplate, ok := templates[itemType].(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
rawTemplate = strings.TrimSpace(rawTemplate)
|
||||
if rawTemplate == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ReplaceAll(rawTemplate, "{id}", id)
|
||||
}
|
||||
|
||||
func stripProviderPrefix(id string) string {
|
||||
if index := strings.Index(id, ":"); index > 0 && index < len(id)-1 {
|
||||
return id[index+1:]
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCrossExtensionShareUsesAlbumCollectionItems(t *testing.T) {
|
||||
ext := &loadedExtension{
|
||||
Manifest: &ExtensionManifest{
|
||||
Capabilities: map[string]any{
|
||||
"shareUrlTemplates": map[string]any{
|
||||
"album": "https://music.apple.com/us/album/{id}",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
tracks := []ExtTrackMetadata{
|
||||
{
|
||||
ID: "1440783617",
|
||||
Name: "Nevermind",
|
||||
Artists: "Nirvana",
|
||||
ItemType: "album",
|
||||
},
|
||||
}
|
||||
|
||||
best := bestAlbumTrack(tracks, "Nevermind", "Nirvana")
|
||||
if best == nil {
|
||||
t.Fatal("expected album collection item to match")
|
||||
}
|
||||
if url := resolveCollectionShareURL(ext, "album", best); url != "https://music.apple.com/us/album/1440783617" {
|
||||
t.Fatalf("album share URL = %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossExtensionShareUsesArtistCollectionItems(t *testing.T) {
|
||||
ext := &loadedExtension{
|
||||
Manifest: &ExtensionManifest{
|
||||
Capabilities: map[string]any{
|
||||
"shareUrlTemplates": map[string]any{
|
||||
"artist": "https://music.youtube.com/browse/{id}",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
tracks := []ExtTrackMetadata{
|
||||
{
|
||||
ID: "UCrPe3hLA51968GwxHSZ1llw",
|
||||
Name: "Nirvana",
|
||||
ItemType: "artist",
|
||||
},
|
||||
}
|
||||
|
||||
best := bestArtistTrack(tracks, "Nirvana")
|
||||
if best == nil {
|
||||
t.Fatal("expected artist collection item to match")
|
||||
}
|
||||
if url := resolveCollectionShareURL(ext, "artist", best); url != "https://music.youtube.com/browse/UCrPe3hLA51968GwxHSZ1llw" {
|
||||
t.Fatalf("artist share URL = %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossExtensionShareCacheKeyIsProviderOrderStable(t *testing.T) {
|
||||
apple := &extensionProviderWrapper{
|
||||
extension: &loadedExtension{
|
||||
ID: "apple",
|
||||
SourceDir: "/extensions/apple",
|
||||
Manifest: &ExtensionManifest{DisplayName: "Apple Music"},
|
||||
},
|
||||
}
|
||||
qobuz := &extensionProviderWrapper{
|
||||
extension: &loadedExtension{
|
||||
ID: "qobuz",
|
||||
SourceDir: "/extensions/qobuz",
|
||||
Manifest: &ExtensionManifest{DisplayName: "Qobuz"},
|
||||
},
|
||||
}
|
||||
|
||||
first := crossExtensionShareCacheKey("Nevermind", "Nirvana", "album", "spotify", []*extensionProviderWrapper{apple, qobuz})
|
||||
second := crossExtensionShareCacheKey("Nevermind", "Nirvana", "album", "spotify", []*extensionProviderWrapper{qobuz, apple})
|
||||
if first != second {
|
||||
t.Fatalf("cache key should not depend on provider order:\n%s\n%s", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossExtensionShareCacheableSkipsTransientErrors(t *testing.T) {
|
||||
cacheable := []CrossExtensionShareResult{
|
||||
{ExtensionID: "apple", Found: true, URL: "https://music.apple.com/us/album/1"},
|
||||
{ExtensionID: "qobuz", Error: "album not found"},
|
||||
{ExtensionID: "tidal", Error: "no results"},
|
||||
}
|
||||
if !crossExtensionShareResultsCacheable(cacheable) {
|
||||
t.Fatal("expected found and deterministic not-found results to be cacheable")
|
||||
}
|
||||
|
||||
transient := []CrossExtensionShareResult{
|
||||
{ExtensionID: "apple", Found: true, URL: "https://music.apple.com/us/album/1"},
|
||||
{ExtensionID: "qobuz", Error: "request failed: timeout"},
|
||||
}
|
||||
if crossExtensionShareResultsCacheable(transient) {
|
||||
t.Fatal("expected transient extension errors to skip cache")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -264,7 +264,7 @@ func ResolveCueAudioPath(cuePath string, cueFileName string) string {
|
||||
}
|
||||
|
||||
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
|
||||
commonExts := []string{".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
|
||||
commonExts := []string{".flac", ".wav", ".aiff", ".aif", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
|
||||
for _, ext := range commonExts {
|
||||
candidate = filepath.Join(cueDir, baseName+ext)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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+";")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||