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 | ||
|
|
fb5d8826a2 | ||
|
|
4bc28704ff | ||
|
|
ed7171133f | ||
|
|
67885e17ed | ||
|
|
fd4da1b7c4 | ||
|
|
242a57b7eb | ||
|
|
18467c54d6 | ||
|
|
8238e2fe68 | ||
|
|
672ce024f8 | ||
|
|
8224e93447 | ||
|
|
1ba810fffb | ||
|
|
1a725d0d31 | ||
|
|
51c5b42a78 | ||
|
|
2908827018 | ||
|
|
b985cbf694 | ||
|
|
13c2360b7e | ||
|
|
f1138ec7af | ||
|
|
1293d92896 | ||
|
|
705d41931d | ||
|
|
29de69d323 | ||
|
|
28727d89f6 | ||
|
|
4704bcf52f | ||
|
|
13c148fb6c | ||
|
|
e6079452f9 | ||
|
|
b68b7d5c9b | ||
|
|
741fcdb4d9 | ||
|
|
642f8c5398 | ||
|
|
1c15d5e7d3 | ||
|
|
e71090338c | ||
|
|
7c0feaaae0 | ||
|
|
5aa3ff4bb5 | ||
|
|
0e00660e2e | ||
|
|
aad72226c5 | ||
|
|
d4c83db428 | ||
|
|
9f2d51fd4d | ||
|
|
83d7106e35 | ||
|
|
30a7cba02a | ||
|
|
01a5b43613 | ||
|
|
149cdc782d | ||
|
|
36137e8970 | ||
|
|
d24435dbc2 | ||
|
|
823e56926f | ||
|
|
bb06ab7e12 | ||
|
|
2143de3aa7 | ||
|
|
dd8a54dd43 | ||
|
|
1ff33b96fa | ||
|
|
b5973c45a2 | ||
|
|
9a78798854 | ||
|
|
101ab3f521 | ||
|
|
cfc8e699f3 | ||
|
|
6b342aeac6 | ||
|
|
b306056995 | ||
|
|
82e317c4a8 | ||
|
|
a4dc776bfb | ||
|
|
5bdaa35ced | ||
|
|
e187ac461d | ||
|
|
1b4a6cd042 | ||
|
|
dcfb22c3f4 | ||
|
|
501158df03 | ||
|
|
e17a4fad4e | ||
|
|
34894faabf | ||
|
|
b329acd710 | ||
|
|
4be9273768 | ||
|
|
f458ac2162 | ||
|
|
b5ea2bb4c1 | ||
|
|
284d257921 | ||
|
|
30bf6b7f9a | ||
|
|
4941b6bd23 | ||
|
|
33d99817ec | ||
|
|
37e1af50ad | ||
|
|
8a6efb1303 | ||
|
|
7823b19b89 | ||
|
|
2a9aa544a9 | ||
|
|
f387c8ff85 | ||
|
|
7e537aec0b | ||
|
|
66cd465565 | ||
|
|
87dc8eb5ea | ||
|
|
397669965d | ||
|
|
60bd0e619e | ||
|
|
2c7621c1a5 | ||
|
|
83afa40423 | ||
|
|
486e7eb101 | ||
|
|
05eb9e60d3 | ||
|
|
dde7095644 | ||
|
|
f1e9a2915d | ||
|
|
ae3495d373 | ||
|
|
6fb2c1b688 | ||
|
|
1526c558e7 | ||
|
|
324e0f053b | ||
|
|
25cb33c78e | ||
|
|
942b6d9569 | ||
|
|
cd46c79383 | ||
|
|
0bdcdcc229 | ||
|
|
1a5863a7fb | ||
|
|
b55be00fab | ||
|
|
f8b7812943 | ||
|
|
8f14ff169a | ||
|
|
ca3abeb1cf | ||
|
|
bb0cc23461 | ||
|
|
45fa33e1ec | ||
|
|
64dbf4441c | ||
|
|
148e5c1231 | ||
|
|
3a7419ec9f | ||
|
|
01c7c9cc3a | ||
|
|
701015ad55 | ||
|
|
3f56b88fa5 | ||
|
|
bdd3f4aef5 | ||
|
|
611abdc6ae | ||
|
|
6e9fa45915 | ||
|
|
63cfac626a | ||
|
|
e6c5a21bfc | ||
|
|
7dafbc1063 | ||
|
|
ad8ac3bd2b | ||
|
|
2d80739141 | ||
|
|
6494102e15 | ||
|
|
0e6aa2efd9 | ||
|
|
cd2c2a9854 | ||
|
|
f412c216c5 | ||
|
|
af15e3d914 | ||
|
|
b00ff3f3f0 | ||
|
|
1607e6830e | ||
|
|
817e0bf2bd | ||
|
|
0f12fbce6a | ||
|
|
953a09d75f | ||
|
|
5098989614 | ||
|
|
5828bcffdd | ||
|
|
ae87a7d58f | ||
|
|
bb7c86c29e | ||
|
|
32ab78a213 | ||
|
|
69583d172c | ||
|
|
38367c1c77 | ||
|
|
2f6bf91a1c | ||
|
|
60b062bbaf | ||
|
|
30e8b604a9 | ||
|
|
7c3ab92e17 | ||
|
|
37b101c70f | ||
|
|
b7be46e6ae | ||
|
|
df96cc4a1d | ||
|
|
6c3d92cee4 | ||
|
|
bf1f79866b | ||
|
|
a6460426a2 | ||
|
|
304ba14d20 | ||
|
|
db47233d92 | ||
|
|
74eeb98be8 | ||
|
|
331da0f897 | ||
|
|
73964ee648 | ||
|
|
a5e8402141 | ||
|
|
c5e7fcf29b | ||
|
|
d3cf6d30a7 | ||
|
|
803cd2de96 | ||
|
|
8f2ca33e87 | ||
|
|
d87e0d7e01 | ||
|
|
86b8709ea1 | ||
|
|
702b917929 | ||
|
|
74e14f7a43 | ||
|
|
02e347adb0 | ||
|
|
56983cb85b | ||
|
|
7917c656b0 | ||
|
|
fc34c1e548 | ||
|
|
f32aeaa0ff | ||
|
|
86097a932c | ||
|
|
f74f24c41f | ||
|
|
8e99e7b07e | ||
|
|
e06aab6e87 | ||
|
|
a81e56fb26 | ||
|
|
9a09b119c5 | ||
|
|
4b28ca1055 | ||
|
|
d684d9f8d1 | ||
|
|
16ce6089fb | ||
|
|
6895e45f2c | ||
|
|
e87f7a1177 | ||
|
|
bcd8a05352 | ||
|
|
4b219ad18e | ||
|
|
57051bd649 | ||
|
|
d6fca6ca55 | ||
|
|
153ec2d9e5 | ||
|
|
be90e85d94 | ||
|
|
4af089f56c | ||
|
|
62519d2d1c | ||
|
|
27c0880e87 | ||
|
|
f312b74b30 | ||
|
|
bd49e307ef | ||
|
|
e904a836c1 | ||
|
|
763c9478f1 | ||
|
|
427bdf74dc | ||
|
|
373a276c54 | ||
|
|
dccadf1f87 | ||
|
|
d9933fe038 | ||
|
|
d47ac0934d | ||
|
|
dbba4d6630 | ||
|
|
7405855e01 | ||
|
|
ed020c9303 | ||
|
|
378742e37a | ||
|
|
c79bee534e | ||
|
|
1d6df75829 | ||
|
|
b7f51b5f14 | ||
|
|
1c8e9df727 | ||
|
|
01540fe3fc | ||
|
|
071db2f109 | ||
|
|
e097d3f605 | ||
|
|
277f783f62 | ||
|
|
7637aaf168 | ||
|
|
c4878470bf | ||
|
|
a3725e8c48 | ||
|
|
917ba842f5 | ||
|
|
dac17ead33 | ||
|
|
6845ebe04c | ||
|
|
eff709480d | ||
|
|
67833424cc | ||
|
|
5c48e1b476 | ||
|
|
5e17c9f238 | ||
|
|
7d330fb2ec | ||
|
|
cd6a4594fa | ||
|
|
bcf727f4ec | ||
|
|
4c4553913f | ||
|
|
f0013fac16 | ||
|
|
ce4be0ba97 | ||
|
|
4bac38ef2a | ||
|
|
4b213f47d9 | ||
|
|
a1010f72f2 | ||
|
|
21077a26d0 | ||
|
|
b50eec5a47 | ||
|
|
38a8b715f8 | ||
|
|
2b47537bb5 | ||
|
|
a5cf241846 | ||
|
|
53a4773480 | ||
|
|
89603af1f1 | ||
|
|
2143084d3c | ||
|
|
0e265193b8 | ||
|
|
c7e9749ce4 | ||
|
|
e21cffff0b | ||
|
|
d9e20040be | ||
|
|
6689173525 | ||
|
|
f37e4704a6 | ||
|
|
65dbd5c8e4 | ||
|
|
d034144e9c | ||
|
|
7c4309955e | ||
|
|
63e90d13d4 | ||
|
|
bfb0cad603 | ||
|
|
cc10a917dc | ||
|
|
5e833c1f75 | ||
|
|
8c576ac7e4 | ||
|
|
92160537c0 | ||
|
|
120ecaa0e5 | ||
|
|
fd3a34303e | ||
|
|
d89b70e155 | ||
|
|
e3b63c1d27 | ||
|
|
96301c0dbf | ||
|
|
a2458c1292 | ||
|
|
1737e12dd2 | ||
|
|
b770d7d9ca | ||
|
|
b712b9f509 | ||
|
|
51496cd34e | ||
|
|
2b2c2bc90a | ||
|
|
e2a489ec92 | ||
|
|
4f46dd947d | ||
|
|
fbb8d30db0 | ||
|
|
c0637006af | ||
|
|
3fc371b8c4 | ||
|
|
ee5b3824e9 | ||
|
|
be6a856773 | ||
|
|
e41c299d49 | ||
|
|
981786b4a2 | ||
|
|
eeb6f11808 | ||
|
|
8e361e14b4 | ||
|
|
d58d46eb1f | ||
|
|
562a17f7ae | ||
|
|
b035e66540 | ||
|
|
38792a753e | ||
|
|
d5b34b4f15 | ||
|
|
2a45c8dcdb | ||
|
|
e7a2166a4f | ||
|
|
bd73eb292d | ||
|
|
8ee2919934 | ||
|
|
f29177216d | ||
|
|
18d3612674 | ||
|
|
f7c0e417d7 | ||
|
|
3fd13e9930 | ||
|
|
0b20cb895e | ||
|
|
8979210804 | ||
|
|
e9b24712c5 | ||
|
|
3d6e5615fa | ||
|
|
fc7220b572 | ||
|
|
198ed5ce6f | ||
|
|
b48462a945 | ||
|
|
0f327cd1f6 | ||
|
|
f54597e655 | ||
|
|
4f2e677e8b | ||
|
|
79a69f8f70 | ||
|
|
bf0f4bdf3e | ||
|
|
5e1cc3ecb5 | ||
|
|
d4b37edc2f | ||
|
|
9483614bc7 | ||
|
|
a73f2e1a13 | ||
|
|
091e3fadd9 | ||
|
|
5340ca7b16 | ||
|
|
85d3e58a26 | ||
|
|
1125c757fe | ||
|
|
66d714d368 | ||
|
|
49c2501fbc | ||
|
|
e487817f21 | ||
|
|
d8bbeb1e67 | ||
|
|
9693616645 | ||
|
|
0423e36d34 | ||
|
|
c8d605fdee | ||
|
|
03fd734048 | ||
|
|
da9d64ccfd | ||
|
|
02e64b7a3c | ||
|
|
a435009d4d | ||
|
|
9ca73a99a6 | ||
|
|
4974284760 | ||
|
|
a0306bd345 | ||
|
|
ea7e594c68 | ||
|
|
d00a84f1b9 | ||
|
|
58b6203681 | ||
|
|
d299144c47 | ||
|
|
40b224e5a1 | ||
|
|
7021e5493f | ||
|
|
68bbc8a259 | ||
|
|
be94a59441 | ||
|
|
3a73aee1b7 | ||
|
|
c91154ea3e | ||
|
|
4f365ca7fe | ||
|
|
98fdc0ed7c | ||
|
|
12be560cb8 | ||
|
|
4cf885a52e | ||
|
|
c57c8a4267 | ||
|
|
2ca6c737c0 | ||
|
|
2a451ec2a3 | ||
|
|
346e79b247 | ||
|
|
497ba342c0 | ||
|
|
aca0bbb819 | ||
|
|
2df8fd6282 | ||
|
|
999317eba1 | ||
|
|
16991476ed | ||
|
|
ba33639818 | ||
|
|
23cab16471 | ||
|
|
0a892011de | ||
|
|
acb1d957d3 | ||
|
|
4a492aeefc | ||
|
|
eb143a41fc | ||
|
|
75db2f162b | ||
|
|
855d0e3ffc | ||
|
|
5ccd06cc68 | ||
|
|
b2873378fc | ||
|
|
66a89d9e8e | ||
|
|
814deca19d | ||
|
|
3bb6754d9c | ||
|
|
7d11d67cd2 | ||
|
|
c0bd10cfca | ||
|
|
e003b15ffd | ||
|
|
ac1c7d31c9 | ||
|
|
6fc9ffeb23 | ||
|
|
9bebed506b | ||
|
|
bffeb55a7a | ||
|
|
c66d13c9fd | ||
|
|
8529985a0e | ||
|
|
a8a3973225 | ||
|
|
6710f90e1e | ||
|
|
929c5f3249 | ||
|
|
f170ead7b9 | ||
|
|
e63e366228 | ||
|
|
95e755e54e | ||
|
|
c719406425 | ||
|
|
9627ef66cf | ||
|
|
15f977d98d | ||
|
|
5b5f043624 | ||
|
|
6ecb69feae | ||
|
|
feff985439 | ||
|
|
2e8fe34824 | ||
|
|
f58005f406 | ||
|
|
75abc03a4f | ||
|
|
84381d142a | ||
|
|
3747ffff64 | ||
|
|
ed47efed17 | ||
|
|
c0d72e89d7 | ||
|
|
a4313cfe0f | ||
|
|
c7bef03ee3 | ||
|
|
ce5a9e0cff | ||
|
|
859b823e77 | ||
|
|
7d8cf5f7ca | ||
|
|
4adaed8da0 | ||
|
|
554fe08fcd | ||
|
|
b8af75bf6e | ||
|
|
35f2f119db | ||
|
|
f36096e0ac | ||
|
|
1665e4cd57 | ||
|
|
42f0267277 | ||
|
|
82f59d32b9 | ||
|
|
941347b007 | ||
|
|
739c89569f | ||
|
|
7bb808cba5 | ||
|
|
bb342c01e2 | ||
|
|
8a5dc0edfe | ||
|
|
20f789f8e0 | ||
|
|
3e89326c95 | ||
|
|
a7ea4de25a | ||
|
|
aabfbf062e | ||
|
|
7b9ed3ec8e | ||
|
|
6dad66d62d | ||
|
|
31018230ee | ||
|
|
54ddc1f59c | ||
|
|
c6856bd1a1 | ||
|
|
cbfa147a12 | ||
|
|
5b8c953ae6 | ||
|
|
37a4dc096b | ||
|
|
b3808645fb | ||
|
|
24aa804bf2 | ||
|
|
941ffb2bb7 | ||
|
|
59737d6f2b | ||
|
|
c8ad93ee9b | ||
|
|
8cb0c037c2 | ||
|
|
e30b69397b | ||
|
|
d6e837fd61 | ||
|
|
5c97d202b9 | ||
|
|
0f6cfa75bb | ||
|
|
91bd6d1572 | ||
|
|
dd05061829 | ||
|
|
8f6b99c550 | ||
|
|
f54ee86591 | ||
|
|
42e0ec2663 | ||
|
|
0456a97b35 | ||
|
|
07c609cc3a | ||
|
|
de5d26403f | ||
|
|
73c2d0efac | ||
|
|
d3c1c440cc | ||
|
|
94195c636f | ||
|
|
9abf492362 | ||
|
|
defc84c216 | ||
|
|
3c9ae39145 | ||
|
|
581f43f4c1 | ||
|
|
221d7e4829 | ||
|
|
706528f04b | ||
|
|
f95a96dd1f | ||
|
|
d85c16ce0f | ||
|
|
35afdf4be4 | ||
|
|
eb5ed86019 | ||
|
|
0cfa6f56be | ||
|
|
5af88ead33 | ||
|
|
8ec63ee610 | ||
|
|
c8247bf7a0 | ||
|
|
2f3270c7ff | ||
|
|
960d60f0bc |
@@ -0,0 +1,24 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.{dart,json,yaml,yml}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.{kt,kts,swift}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.go]
|
||||||
|
indent_style = tab
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.{bat,cmd,ps1}]
|
||||||
|
end_of_line = crlf
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
* text=auto eol=lf
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Scripts executed on Unix runners must keep LF line endings.
|
||||||
|
*.sh text eol=lf
|
||||||
|
gradlew text eol=lf
|
||||||
|
|
||||||
# Windows scripts
|
# Windows scripts
|
||||||
*.bat text eol=crlf
|
*.bat text eol=crlf
|
||||||
*.cmd text eol=crlf
|
*.cmd text eol=crlf
|
||||||
|
|||||||
@@ -1,30 +1,29 @@
|
|||||||
name: Bug Report
|
name: Bug Report
|
||||||
description: Report a bug or unexpected behavior
|
description: Report a reproducible bug or unexpected behavior
|
||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels: ["bug"]
|
labels: ["bug"]
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Thanks for taking the time to report a bug! Please fill out the form below.
|
Thanks for reporting a SpotiFLAC Mobile bug. Please provide enough detail for another person to reproduce it.
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: checklist
|
id: checklist
|
||||||
attributes:
|
attributes:
|
||||||
label: Checklist
|
label: Checklist
|
||||||
description: Please confirm the following before submitting
|
|
||||||
options:
|
options:
|
||||||
- label: I have searched existing issues and this bug hasn't been reported yet
|
- label: I searched existing issues and did not find this bug.
|
||||||
required: true
|
required: true
|
||||||
- label: I am using the latest version of SpotiFLAC (Stable Version)
|
- label: I can reproduce this on the release channel selected below.
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: description
|
id: description
|
||||||
attributes:
|
attributes:
|
||||||
label: Bug Description
|
label: Description
|
||||||
description: A clear and concise description of what the bug is
|
description: What happened, and what did you expect instead?
|
||||||
placeholder: Describe the bug...
|
placeholder: Include the visible error and any relevant context.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
@@ -32,29 +31,26 @@ body:
|
|||||||
id: steps
|
id: steps
|
||||||
attributes:
|
attributes:
|
||||||
label: Steps to Reproduce
|
label: Steps to Reproduce
|
||||||
description: Steps to reproduce the behavior
|
|
||||||
placeholder: |
|
placeholder: |
|
||||||
1. Go to '...'
|
1. Open ...
|
||||||
2. Click on '...'
|
2. Enable ...
|
||||||
3. See error
|
3. Tap ...
|
||||||
|
4. Observe ...
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: dropdown
|
||||||
id: expected
|
id: trigger
|
||||||
attributes:
|
attributes:
|
||||||
label: Expected Behavior
|
label: Area
|
||||||
description: What did you expect to happen?
|
options:
|
||||||
placeholder: Describe what you expected...
|
- App startup
|
||||||
validations:
|
- Search or navigation
|
||||||
required: true
|
- Download or verification
|
||||||
|
- Library or metadata
|
||||||
- type: textarea
|
- Playback or background service
|
||||||
id: actual
|
- Audio analysis
|
||||||
attributes:
|
- Other
|
||||||
label: Actual Behavior
|
|
||||||
description: What actually happened?
|
|
||||||
placeholder: Describe what actually happened...
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
@@ -62,62 +58,73 @@ body:
|
|||||||
id: version
|
id: version
|
||||||
attributes:
|
attributes:
|
||||||
label: App Version
|
label: App Version
|
||||||
description: Which version of SpotiFLAC are you using? (Check in Settings > About)
|
description: Find this in Settings > About.
|
||||||
placeholder: "e.g., v2.2.0"
|
placeholder: "e.g. 4.8.1 (139)"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: platform
|
id: channel
|
||||||
attributes:
|
attributes:
|
||||||
label: Platform
|
label: Release Channel
|
||||||
description: Which platform are you using?
|
|
||||||
options:
|
options:
|
||||||
- Android
|
- Stable
|
||||||
- iOS
|
- Beta
|
||||||
|
- Local or development build
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: device
|
id: device
|
||||||
attributes:
|
attributes:
|
||||||
label: Device & OS Version
|
label: Device and OS
|
||||||
description: What device and OS version are you using?
|
placeholder: "e.g. Samsung Galaxy S24, Android 16"
|
||||||
placeholder: "e.g., Samsung Galaxy S24, Android 14"
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: download-service
|
id: storage
|
||||||
attributes:
|
attributes:
|
||||||
label: Download Service
|
label: Download Storage
|
||||||
description: Which download service were you using when the bug occurred?
|
|
||||||
options:
|
options:
|
||||||
- Tidal
|
- SAF folder
|
||||||
- Qobuz
|
- App-specific folder
|
||||||
- Amazon Music
|
|
||||||
- Deezer (search only)
|
|
||||||
- Not applicable
|
- Not applicable
|
||||||
|
- Not sure
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: worker
|
||||||
|
attributes:
|
||||||
|
label: Download Worker
|
||||||
|
options:
|
||||||
|
- Native worker
|
||||||
|
- Dart worker
|
||||||
|
- Not applicable
|
||||||
|
- Not sure
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: provider
|
||||||
|
attributes:
|
||||||
|
label: Extension or Provider
|
||||||
|
description: If relevant, include the extension/provider name and version.
|
||||||
|
placeholder: "Name, version, and selected priority"
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: logs
|
id: logs
|
||||||
attributes:
|
attributes:
|
||||||
label: Logs / Screenshots
|
label: Logs and Media
|
||||||
description: |
|
description: |
|
||||||
If applicable, add logs or screenshots to help explain your problem.
|
Enable detailed logging, reproduce the problem, then export logs from Settings > Logs. For an Android cold-start crash, attach `adb logcat -b crash -d`. Add screenshots or a short recording when useful. Remove credentials and personal data first.
|
||||||
|
placeholder: Paste logs or drag files here.
|
||||||
**To get logs:**
|
validations:
|
||||||
1. Go to Settings > Options > Detailed Logging (turn ON)
|
required: true
|
||||||
2. Reproduce the bug
|
|
||||||
3. Go to Settings > Logs
|
|
||||||
4. Tap Share button to export logs
|
|
||||||
placeholder: Paste logs or drag & drop screenshots here...
|
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: additional
|
id: additional
|
||||||
attributes:
|
attributes:
|
||||||
label: Additional Context
|
label: Additional Context
|
||||||
description: Any other context about the problem
|
placeholder: Settings, frequency, regression version, or anything else that may help.
|
||||||
placeholder: Add any other context...
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: README
|
- name: README
|
||||||
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
|
url: https://github.com/spotiflacapp/SpotiFLAC-Mobile#readme
|
||||||
about: Check the README for setup instructions and FAQ
|
about: Check the README for setup instructions and FAQ
|
||||||
- name: Extension Development Guide
|
- name: Extension Development Guide
|
||||||
url: https://zarz.moe/docs
|
url: https://spotiflac.zarz.moe/docs
|
||||||
about: Documentation for building SpotiFLAC extensions
|
about: Documentation for building SpotiFLAC Mobile extensions
|
||||||
|
|||||||
@@ -1,111 +1,124 @@
|
|||||||
name: Download Issue
|
name: Download Issue
|
||||||
description: Report issues with downloading specific tracks or albums
|
description: Report a wrong, missing, failed, or low-quality download
|
||||||
title: "[Download]: "
|
title: "[Download]: "
|
||||||
labels: ["download-issue"]
|
labels: ["download-issue"]
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Having trouble downloading a specific track or album? Please provide details below.
|
Download matching is extension-driven. Include the source item, provider order, and logs so the exact decision path can be inspected.
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: checklist
|
id: checklist
|
||||||
attributes:
|
attributes:
|
||||||
label: Checklist
|
label: Checklist
|
||||||
description: Please confirm the following before submitting
|
|
||||||
options:
|
options:
|
||||||
- label: I have tried downloading with a different service (Tidal/Qobuz/Amazon)
|
- label: I searched existing issues and did not find this problem.
|
||||||
required: true
|
required: true
|
||||||
- label: I am using the latest version of SpotiFLAC (Stable Version)
|
- label: I updated the affected extension and retried once.
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: issue-type
|
id: issue-type
|
||||||
attributes:
|
attributes:
|
||||||
label: Issue Type
|
label: Issue Type
|
||||||
description: What kind of download issue are you experiencing?
|
|
||||||
options:
|
options:
|
||||||
- Track not found on service
|
- Track or album not found
|
||||||
- Wrong track downloaded
|
- Wrong track downloaded
|
||||||
- Download fails/errors
|
- Download fails or stalls
|
||||||
- Metadata incorrect
|
- Metadata or cover is incorrect
|
||||||
- Audio quality issue
|
- Audio format or quality is incorrect
|
||||||
|
- Duplicate or replacement behavior
|
||||||
- Other
|
- Other
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: spotify-url
|
id: source-url
|
||||||
attributes:
|
attributes:
|
||||||
label: Spotify URL
|
label: Source URL
|
||||||
description: The Spotify URL of the track/album you're trying to download
|
description: URL pasted or opened in SpotiFLAC Mobile, if available.
|
||||||
placeholder: "https://open.spotify.com/track/..."
|
placeholder: "https://..."
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: track-info
|
id: track-info
|
||||||
attributes:
|
attributes:
|
||||||
label: Track Info
|
label: Track or Album
|
||||||
description: Artist name and track title
|
placeholder: "Artist - Track or album title"
|
||||||
placeholder: "Artist - Track Title"
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: metadata-source
|
||||||
|
attributes:
|
||||||
|
label: Search or Metadata Extension
|
||||||
|
placeholder: "Extension name and version"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: download-provider
|
||||||
|
attributes:
|
||||||
|
label: Selected Download Extension
|
||||||
|
placeholder: "Extension name and version"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: priority
|
||||||
|
attributes:
|
||||||
|
label: Provider Priority and Requested Quality
|
||||||
|
placeholder: |
|
||||||
|
Provider order: first, second, third
|
||||||
|
Requested quality: best / 24-bit / ...
|
||||||
|
Automatic fallback: on/off
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: download-service
|
id: storage
|
||||||
attributes:
|
attributes:
|
||||||
label: Download Service
|
label: Download Storage
|
||||||
description: Which service did you try to download from?
|
|
||||||
options:
|
options:
|
||||||
- Tidal
|
- SAF folder
|
||||||
- Qobuz
|
- App-specific folder
|
||||||
- Amazon Music
|
- Not sure
|
||||||
- All services
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: search-service
|
id: worker
|
||||||
attributes:
|
attributes:
|
||||||
label: Search Service
|
label: Download Worker
|
||||||
description: Which search service are you using?
|
|
||||||
options:
|
options:
|
||||||
- Spotify
|
- Native worker
|
||||||
- Deezer
|
- Dart worker
|
||||||
|
- Not sure
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: description
|
id: description
|
||||||
attributes:
|
attributes:
|
||||||
label: Description
|
label: What Happened?
|
||||||
description: Describe the issue in detail
|
description: For a wrong match, identify both the expected and downloaded recording.
|
||||||
placeholder: |
|
placeholder: Describe the result and the expected result.
|
||||||
What happened? What did you expect?
|
|
||||||
If wrong track was downloaded, what track was downloaded instead?
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: version
|
id: environment
|
||||||
attributes:
|
attributes:
|
||||||
label: App Version
|
label: App Version, Device, and OS
|
||||||
description: Which version of SpotiFLAC are you using?
|
placeholder: "4.8.1 (139), device model, Android/iOS version"
|
||||||
placeholder: "e.g., v2.2.0"
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: screenshots
|
id: logs
|
||||||
attributes:
|
attributes:
|
||||||
label: Screenshots / Logs
|
label: Logs and Screenshots
|
||||||
description: |
|
description: Enable detailed logging, retry once, and export Settings > Logs. Remove credentials and personal data.
|
||||||
If applicable, add screenshots or logs.
|
placeholder: Paste logs or drag files here.
|
||||||
|
validations:
|
||||||
**To get logs:**
|
required: true
|
||||||
1. Go to Settings > Options > Detailed Logging (turn ON)
|
|
||||||
2. Try downloading the track again
|
|
||||||
3. Go to Settings > Logs
|
|
||||||
4. Tap Share button to export logs
|
|
||||||
placeholder: Drag & drop screenshots or paste logs here...
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ body:
|
|||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Thanks for helping improve the SpotiFLAC Extension API!
|
Thanks for helping improve the SpotiFLAC Mobile Extension API!
|
||||||
This form is for extension developers who need new features or capabilities that don't exist yet.
|
This form is for extension developers who need new features or capabilities that don't exist yet.
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
@@ -15,7 +15,7 @@ body:
|
|||||||
label: Checklist
|
label: Checklist
|
||||||
description: Please confirm the following before submitting
|
description: Please confirm the following before submitting
|
||||||
options:
|
options:
|
||||||
- label: I have read the [Extension Development Guide](https://github.com/zarzet/SpotiFLAC-Mobile/blob/main/docs/EXTENSION_DEVELOPMENT.md)
|
- label: I have read the [Extension Development Guide](https://github.com/spotiflacapp/SpotiFLAC-Mobile/blob/main/docs/EXTENSION_DEVELOPMENT.md)
|
||||||
required: true
|
required: true
|
||||||
- label: I have searched existing issues and this API feature hasn't been requested yet
|
- label: I have searched existing issues and this API feature hasn't been requested yet
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ body:
|
|||||||
options:
|
options:
|
||||||
- UI/UX Improvement
|
- UI/UX Improvement
|
||||||
- Download Feature
|
- Download Feature
|
||||||
- New Service Integration
|
- Extension System/API
|
||||||
- Metadata/Tagging
|
- Metadata/Tagging
|
||||||
- Performance
|
- Performance
|
||||||
- Settings/Configuration
|
- Settings/Configuration
|
||||||
|
|||||||
@@ -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:
|
steps:
|
||||||
- name: Free disk space
|
- name: Free disk space
|
||||||
run: |
|
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 /usr/share/dotnet
|
||||||
sudo rm -rf /opt/ghc
|
sudo rm -rf /opt/ghc
|
||||||
sudo rm -rf /opt/hostedtoolcache/CodeQL
|
sudo rm -rf /opt/hostedtoolcache/CodeQL
|
||||||
sudo rm -rf /usr/local/share/boost
|
sudo rm -rf /usr/local/share/boost
|
||||||
sudo rm -rf /usr/share/swift
|
sudo rm -rf /usr/share/swift
|
||||||
sudo rm -rf /usr/local/.ghcup
|
sudo rm -rf /usr/local/.ghcup
|
||||||
# Clean docker images
|
|
||||||
sudo docker image prune --all --force
|
|
||||||
# Show available space
|
# Show available space
|
||||||
df -h
|
df -h
|
||||||
|
|
||||||
@@ -66,12 +65,12 @@ jobs:
|
|||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: "temurin"
|
distribution: "temurin"
|
||||||
java-version: "17"
|
java-version: "25"
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "1.25.7"
|
go-version-file: go_backend/go.mod
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
# Cache Gradle for faster builds
|
# Cache Gradle for faster builds
|
||||||
@@ -84,6 +83,12 @@ jobs:
|
|||||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||||
restore-keys: gradle-${{ runner.os }}-
|
restore-keys: gradle-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Cache Android NDK
|
||||||
|
uses: actions/cache@v5
|
||||||
|
with:
|
||||||
|
path: /usr/local/lib/android/sdk/ndk/29.0.14206865
|
||||||
|
key: ndk-29.0.14206865
|
||||||
|
|
||||||
- name: Install Android SDK & NDK
|
- name: Install Android SDK & NDK
|
||||||
run: |
|
run: |
|
||||||
# Use pre-installed Android SDK on GitHub runners
|
# Use pre-installed Android SDK on GitHub runners
|
||||||
@@ -93,23 +98,30 @@ jobs:
|
|||||||
# Accept licenses
|
# Accept licenses
|
||||||
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
||||||
|
|
||||||
# Install NDK r27d LTS (required for 16KB page size support on Android 15+)
|
# Install NDK r29 (supports 16KB page size for Android 15+)
|
||||||
# Platform android-36 and build-tools 36.0.0 for targetSdk 36 (Android 16)
|
# Keep the installed platform aligned with compileSdk/targetSdk.
|
||||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;27.3.13750724" "platforms;android-36" "build-tools;36.0.0"
|
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;29.0.14206865" "platforms;android-37.0" "build-tools;37.0.0"
|
||||||
|
|
||||||
# Set NDK path
|
# Set NDK path
|
||||||
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/27.3.13750724" >> $GITHUB_ENV
|
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Install gomobile
|
- name: Install gomobile
|
||||||
|
working-directory: go_backend
|
||||||
run: |
|
run: |
|
||||||
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
|
gomobile init
|
||||||
|
|
||||||
- name: Build Go backend for Android
|
- name: Build Go backend for Android
|
||||||
working-directory: go_backend
|
working-directory: go_backend
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ../android/app/libs
|
mkdir -p ../android/app/libs
|
||||||
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:
|
env:
|
||||||
CGO_ENABLED: 1
|
CGO_ENABLED: 1
|
||||||
|
|
||||||
@@ -117,8 +129,16 @@ jobs:
|
|||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
|
flutter-version-file: .fvmrc
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
|
- name: Cache pub dependencies
|
||||||
|
uses: actions/cache@v5
|
||||||
|
with:
|
||||||
|
path: ~/.pub-cache
|
||||||
|
key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }}
|
||||||
|
restore-keys: pub-${{ runner.os }}-
|
||||||
|
|
||||||
- name: Get Flutter dependencies
|
- name: Get Flutter dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|
||||||
@@ -127,13 +147,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Build APK (Release - unsigned)
|
- name: Build APK (Release - unsigned)
|
||||||
run: |
|
run: |
|
||||||
flutter build apk --release --split-per-abi || true
|
flutter build apk --release --split-per-abi \
|
||||||
# Verify APKs were created
|
--target-platform android-arm,android-arm64
|
||||||
ls -la build/app/outputs/flutter-apk/
|
ls -la build/app/outputs/flutter-apk/
|
||||||
if [ ! -f "build/app/outputs/flutter-apk/app-arm64-v8a-release.apk" ]; then
|
test -f build/app/outputs/flutter-apk/app-arm64-v8a-release.apk
|
||||||
echo "ERROR: APK not found!"
|
test -f build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Sign APKs
|
- name: Sign APKs
|
||||||
uses: r0adkll/sign-android-release@v1
|
uses: r0adkll/sign-android-release@v1
|
||||||
@@ -149,12 +167,29 @@ jobs:
|
|||||||
|
|
||||||
- name: Rename APKs
|
- name: Rename APKs
|
||||||
run: |
|
run: |
|
||||||
VERSION=${{ needs.get-version.outputs.version }}
|
VERSION="${{ needs.get-version.outputs.version }}"
|
||||||
cd build/app/outputs/flutter-apk
|
cd build/app/outputs/flutter-apk
|
||||||
# Signed files have -signed suffix
|
|
||||||
mv app-arm64-v8a-release-signed.apk SpotiFLAC-${VERSION}-arm64.apk || mv app-arm64-v8a-release.apk SpotiFLAC-${VERSION}-arm64.apk || true
|
rename_required_apk() {
|
||||||
mv app-armeabi-v7a-release-signed.apk SpotiFLAC-${VERSION}-arm32.apk || mv app-armeabi-v7a-release.apk SpotiFLAC-${VERSION}-arm32.apk || true
|
unsigned="$1"
|
||||||
mv app-release-signed.apk SpotiFLAC-${VERSION}-universal.apk || mv app-release.apk SpotiFLAC-${VERSION}-universal.apk || true
|
destination="$2"
|
||||||
|
signed="${unsigned%.apk}-signed.apk"
|
||||||
|
if [ -f "$signed" ]; then
|
||||||
|
mv "$signed" "$destination"
|
||||||
|
elif [ -f "$unsigned" ]; then
|
||||||
|
mv "$unsigned" "$destination"
|
||||||
|
else
|
||||||
|
echo "ERROR: Missing APK: $unsigned"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
rename_required_apk \
|
||||||
|
app-arm64-v8a-release.apk \
|
||||||
|
"SpotiFLAC-${VERSION}-arm64.apk"
|
||||||
|
rename_required_apk \
|
||||||
|
app-armeabi-v7a-release.apk \
|
||||||
|
"SpotiFLAC-${VERSION}-arm32.apk"
|
||||||
ls -la
|
ls -la
|
||||||
|
|
||||||
- name: Upload APK artifact
|
- name: Upload APK artifact
|
||||||
@@ -164,17 +199,22 @@ jobs:
|
|||||||
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
|
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
|
||||||
|
|
||||||
build-ios:
|
build-ios:
|
||||||
runs-on: macos-latest
|
runs-on: macos-15
|
||||||
needs: get-version # Only depends on version, NOT android build!
|
needs: get-version # Only depends on version, NOT android build!
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Select Xcode 26.1.1
|
||||||
|
run: |
|
||||||
|
sudo xcode-select -s /Applications/Xcode_26.1.1.app
|
||||||
|
xcodebuild -version
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "1.25.7"
|
go-version-file: go_backend/go.mod
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
# Cache CocoaPods
|
# Cache CocoaPods
|
||||||
@@ -186,8 +226,10 @@ jobs:
|
|||||||
restore-keys: pods-${{ runner.os }}-
|
restore-keys: pods-${{ runner.os }}-
|
||||||
|
|
||||||
- name: Install gomobile
|
- name: Install gomobile
|
||||||
|
working-directory: go_backend
|
||||||
run: |
|
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
|
gomobile init
|
||||||
|
|
||||||
- name: Build Go backend for iOS
|
- name: Build Go backend for iOS
|
||||||
@@ -205,8 +247,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Add XCFramework to Xcode project
|
- name: Add XCFramework to Xcode project
|
||||||
run: |
|
run: |
|
||||||
# Install xcodeproj gem for modifying Xcode project
|
# xcodeproj usually ships with the preinstalled CocoaPods; only
|
||||||
sudo gem install xcodeproj
|
# install it when missing.
|
||||||
|
gem list -i xcodeproj >/dev/null || sudo gem install xcodeproj
|
||||||
|
|
||||||
# Create Ruby script to add framework
|
# Create Ruby script to add framework
|
||||||
cat > add_framework.rb << 'EOF'
|
cat > add_framework.rb << 'EOF'
|
||||||
@@ -247,11 +290,28 @@ jobs:
|
|||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
|
flutter-version-file: .fvmrc
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
|
- name: Cache pub dependencies
|
||||||
|
uses: actions/cache@v5
|
||||||
|
with:
|
||||||
|
path: ~/.pub-cache
|
||||||
|
key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }}
|
||||||
|
restore-keys: pub-${{ runner.os }}-
|
||||||
|
|
||||||
- name: Get Flutter dependencies
|
- name: Get Flutter dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Normalize ffmpeg plugin shell scripts (strip CRLF)
|
||||||
|
run: |
|
||||||
|
find "$HOME/.pub-cache/hosted" -path "*ffmpeg_kit_flutter_new_full*/scripts/*.sh" -type f -print0 |
|
||||||
|
while IFS= read -r -d '' f; do
|
||||||
|
perl -pi -e 's/\r$//' "$f"
|
||||||
|
chmod +x "$f"
|
||||||
|
echo "Normalized line endings: $f"
|
||||||
|
done
|
||||||
|
|
||||||
- name: Generate app icons
|
- name: Generate app icons
|
||||||
run: dart run flutter_launcher_icons
|
run: dart run flutter_launcher_icons
|
||||||
|
|
||||||
@@ -374,8 +434,6 @@ jobs:
|
|||||||
### Installation
|
### Installation
|
||||||
**Android**: Enable "Install from unknown sources" and install the APK
|
**Android**: Enable "Install from unknown sources" and install the APK
|
||||||
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
||||||
|
|
||||||
  
|
|
||||||
FOOTER
|
FOOTER
|
||||||
|
|
||||||
echo "Release body:"
|
echo "Release body:"
|
||||||
@@ -385,7 +443,7 @@ jobs:
|
|||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ needs.get-version.outputs.version }}
|
tag_name: ${{ needs.get-version.outputs.version }}
|
||||||
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
name: SpotiFLAC-Mobile ${{ needs.get-version.outputs.version }}
|
||||||
body_path: /tmp/release_body.txt
|
body_path: /tmp/release_body.txt
|
||||||
files: ./release/*
|
files: ./release/*
|
||||||
draft: false
|
draft: false
|
||||||
@@ -433,7 +491,7 @@ jobs:
|
|||||||
|
|
||||||
jq --arg ver "$VERSION_NUM" \
|
jq --arg ver "$VERSION_NUM" \
|
||||||
--arg date "$DATE" \
|
--arg date "$DATE" \
|
||||||
--arg url "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa" \
|
--arg url "https://github.com/${{ github.repository }}/releases/download/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa" \
|
||||||
--argjson size "$IPA_SIZE" \
|
--argjson size "$IPA_SIZE" \
|
||||||
'.apps[0].version = $ver | .apps[0].versionDate = $date | .apps[0].downloadURL = $url | .apps[0].size = $size' \
|
'.apps[0].version = $ver | .apps[0].versionDate = $date | .apps[0].downloadURL = $url | .apps[0].size = $size' \
|
||||||
apps.json > apps.json.tmp && mv apps.json.tmp apps.json
|
apps.json > apps.json.tmp && mv apps.json.tmp apps.json
|
||||||
@@ -551,7 +609,7 @@ jobs:
|
|||||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||||
-F document=@"${ARM64_APK}" \
|
-F document=@"${ARM64_APK}" \
|
||||||
-F caption="SpotiFLAC ${VERSION} - arm64 (recommended)"
|
-F caption="SpotiFLAC Mobile ${VERSION} - arm64 (recommended)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Upload arm32 APK to channel
|
# Upload arm32 APK to channel
|
||||||
@@ -560,7 +618,7 @@ jobs:
|
|||||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||||
-F document=@"${ARM32_APK}" \
|
-F document=@"${ARM32_APK}" \
|
||||||
-F caption="SpotiFLAC ${VERSION} - arm32"
|
-F caption="SpotiFLAC Mobile ${VERSION} - arm32"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Upload iOS IPA to channel
|
# Upload iOS IPA to channel
|
||||||
@@ -570,7 +628,7 @@ jobs:
|
|||||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||||
-F document=@"${IOS_IPA}" \
|
-F document=@"${IOS_IPA}" \
|
||||||
-F caption="SpotiFLAC ${VERSION} - iOS (unsigned, sideload required)"
|
-F caption="SpotiFLAC Mobile ${VERSION} - iOS (unsigned, sideload required)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Telegram notification sent!"
|
echo "Telegram notification sent!"
|
||||||
|
|||||||
@@ -1,82 +1,98 @@
|
|||||||
# OS files
|
# Operating-system files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
# IDE
|
# Editors and local assistants
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.iml
|
*.iml
|
||||||
.cursorignore
|
.cursorignore
|
||||||
.cursorrules
|
.cursorrules
|
||||||
|
|
||||||
# Kiro specs (development only)
|
|
||||||
.kiro/
|
.kiro/
|
||||||
|
.playwright-mcp/
|
||||||
|
.rtk/
|
||||||
|
.claude/settings.local.json
|
||||||
|
CLAUDE.md
|
||||||
|
AGENTS.md
|
||||||
|
|
||||||
# Design assets (banners, mockups)
|
# Flutter and Dart
|
||||||
design/
|
|
||||||
|
|
||||||
# Reference folder (development only)
|
|
||||||
referensi/
|
|
||||||
|
|
||||||
# Documentation (development only, published separately)
|
|
||||||
docs/
|
|
||||||
|
|
||||||
# Old spotiflac_android folder (moved to root)
|
|
||||||
spotiflac_android/
|
|
||||||
|
|
||||||
# Flutter/Dart
|
|
||||||
.dart_tool/
|
.dart_tool/
|
||||||
|
.fvm/
|
||||||
.packages
|
.packages
|
||||||
build/
|
|
||||||
*.lock
|
|
||||||
!pubspec.lock
|
|
||||||
.flutter-plugins
|
.flutter-plugins
|
||||||
.flutter-plugins-dependencies
|
.flutter-plugins-dependencies
|
||||||
.metadata
|
.metadata
|
||||||
|
/build/
|
||||||
|
/coverage/
|
||||||
*.apk
|
*.apk
|
||||||
|
*.aab
|
||||||
|
*.ipa
|
||||||
|
|
||||||
# Go backend build artifacts
|
# Local JavaScript tooling (the app itself has no Node dependency)
|
||||||
go_backend/*.aar
|
/node_modules/
|
||||||
go_backend/*.jar
|
/bun.lock
|
||||||
go_backend/*.exe
|
/package-lock.json
|
||||||
go_backend/*.xcframework/
|
|
||||||
|
|
||||||
# Android
|
# Go backend build outputs
|
||||||
android/.gradle/
|
/go_backend/*.aar
|
||||||
android/app/libs/gobackend.aar
|
/go_backend/*.jar
|
||||||
android/local.properties
|
/go_backend/*.exe
|
||||||
android/*.iml
|
/go_backend/*.xcframework/
|
||||||
android/key.properties
|
|
||||||
android/*.jks
|
|
||||||
android/*.keystore
|
|
||||||
android/app/*.jks
|
|
||||||
|
|
||||||
# iOS
|
# Android build state and signing material
|
||||||
ios/Frameworks/
|
/android/.gradle/
|
||||||
ios/Pods/
|
/android/.kotlin/
|
||||||
ios/.symlinks/
|
/android/app/build/
|
||||||
ios/Flutter/Flutter.framework/
|
/android/app/libs/*.aar
|
||||||
ios/Flutter/Flutter.podspec
|
/android/app/libs/*-sources.jar
|
||||||
android/app/libs/gobackend-sources.jar
|
/android/local.properties
|
||||||
|
/android/*.iml
|
||||||
|
/android/key.properties
|
||||||
|
/android/*.jks
|
||||||
|
/android/*.keystore
|
||||||
|
/android/app/*.jks
|
||||||
|
|
||||||
# Extension folder
|
# iOS generated state
|
||||||
extension/
|
/ios/Frameworks/
|
||||||
|
/ios/Pods/
|
||||||
|
/ios/.symlinks/
|
||||||
|
/ios/Flutter/ephemeral/
|
||||||
|
/ios/Flutter/Flutter.framework/
|
||||||
|
/ios/Flutter/Flutter.podspec
|
||||||
|
*.xcarchive
|
||||||
|
DerivedData/
|
||||||
|
|
||||||
# Agent instructions
|
# Credentials and machine-local configuration
|
||||||
AGENTS.md
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.mobileprovision
|
||||||
|
*.p12
|
||||||
|
*.pem
|
||||||
|
|
||||||
# Temp/misc
|
# Local research and design material
|
||||||
|
/design/
|
||||||
|
/referensi/
|
||||||
|
/extension/
|
||||||
|
/tool/
|
||||||
|
/spotiflac_android/
|
||||||
|
|
||||||
|
# Documentation published separately. Keep the in-repo extension contract
|
||||||
|
# available because contribution templates link to it.
|
||||||
|
/docs/*
|
||||||
|
!/docs/EXTENSION_DEVELOPMENT.md
|
||||||
|
|
||||||
|
# Temporary files and logs
|
||||||
|
/.tmp/
|
||||||
nul
|
nul
|
||||||
network_requests.txt
|
NUL
|
||||||
|
/network_requests.txt
|
||||||
# Log files
|
/AndroidManifest.xml
|
||||||
|
*.bak
|
||||||
|
*.orig
|
||||||
|
*.swp
|
||||||
|
*.tmp
|
||||||
*.log
|
*.log
|
||||||
hs_err_*.log
|
hs_err_*.log
|
||||||
flutter_*.log
|
flutter_*.log
|
||||||
|
|
||||||
# Development tools
|
|
||||||
tool/
|
|
||||||
.claude/settings.local.json
|
|
||||||
|
|
||||||
# FVM Version Cache
|
|
||||||
.fvm/
|
|
||||||
|
|||||||
@@ -1,5 +1,513 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [4.8.5] - 2026-08-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Searchable Settings**: Search both top-level pages and controls inside them. Opening a nested result now scrolls to the matching control and briefly highlights it.
|
||||||
|
- **Album Multi-Select Download**: Select several tracks in an album and choose the provider and quality once for the whole selection.
|
||||||
|
- **Player & Queue Controls**: Repeat Off/All/One modes, swipe up from Now Playing to open the queue, move queued tracks up or down, `Download next`, and one-tap retry when connectivity returns.
|
||||||
|
- **Playback Session Restore**: Restore the previous queue, track, technical quality, and playback position after the app process is restarted.
|
||||||
|
- **Track Navigation**: `Go to Album` is available from track actions, including sparse search results, and `Open on...` exposes supported platform links.
|
||||||
|
- **Library Views**: Playlist filtering, a configurable default Library view, a missing-ISRC filter, and clearer horizontally scrollable filter chips.
|
||||||
|
- **Quality Labels**: Choose between measured bitrate labels and classic bit-depth/sample-rate labels. High-bitrate tracks receive a distinct badge color, and lossless average bitrate is shown in Track Metadata.
|
||||||
|
- **Duplicate Review**: Detect ISRC duplicates beyond FLAC, review matching files, and keep the preferred copy.
|
||||||
|
- **M3U Playlists**: Import M3U/M3U8 playlists and export Library collections as M3U8.
|
||||||
|
- **Metadata Sources**: Choose a specific extension for online auto-fill, use extension-defined custom search, or manually fetch metadata from MusicBrainz.
|
||||||
|
- **Cover Tools**: View the current embedded-cover dimensions, preview the dimensions of a fetched cover, resize embedded artwork, and select the best available online cover candidate.
|
||||||
|
- **Android Download Widget**: Monitor the active download queue from the Android home screen.
|
||||||
|
- **Appearance Controls**: Adaptive detail-page colors derived from cover art and an optional force-backdrop-blur setting for lower device tiers.
|
||||||
|
- **Audio Processing**: Very-high-quality SoXR resampling settings for supported lossless conversions.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Provider Matching**: Honor the explicitly selected provider and reject mismatched provider results that could download a cover or unrelated song with the original album metadata.
|
||||||
|
- **Verification & Sessions**: Coordinate one verification flow across sequential and parallel album downloads, follow canonical gateway/provider error contracts, block challenged session generations, and prevent stale requests from invalidating a newer session.
|
||||||
|
- **Verification Alerts**: Notify the user when downloads pause for verification and recover signed-session bootstrap failures more reliably.
|
||||||
|
- **Download Storage**: Recover from an unwritable selected folder, retain published files when bookkeeping fails, and keep cancelled items retryable after app restarts.
|
||||||
|
- **Quality Variants**: Complete alternate-quality downloads reliably and append technical quality to filenames only when a real filename collision exists.
|
||||||
|
- **Library Stability**: Keep rows and cached history visible during active downloads and refreshes, retain records when deletion fails, and backfill/persist local bitrate metadata without opening every track.
|
||||||
|
- **Playback & Lyrics**: Refresh lyrics after automatic track changes and cold starts, reject empty embedded lyric payloads, preserve technical quality between tracks, and restore playback position reliably.
|
||||||
|
- **WAV & AIFF Metadata**: Preserve supported tags and artwork through conversion and metadata editing.
|
||||||
|
- **Audio Analysis**: Wait for complete FFmpeg metrics, isolate analysis from active downloads, detect effective/full-band spectral cutoff more accurately, and avoid unsupported AC-4 spectrogram analysis.
|
||||||
|
- **Startup Compatibility**: Stop forcing the highest Android display mode during every cold start, avoiding OEM-specific crashes and unnecessary GPU load.
|
||||||
|
- **User-Facing Errors**: Replace raw `PlatformException` and backend details with concise, sanitized messages.
|
||||||
|
- **Metadata UI**: Keep Track Metadata headers readable in light themes and make the online source picker, preview, apply, and feedback flow consistent with the rest of the app.
|
||||||
|
- **Selection & Sheets**: Keep selection bars and modal sheets above the app navbar and make download-picker drag dismissal feel natural.
|
||||||
|
- **Database Upgrade**: Make playback-session migration safe when an older installation already contains the destination table.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- **Now Playing**: Smoother track transitions, consistent custom action sheets, better synced-lyrics positioning, and aligned play/preview controls and hitboxes.
|
||||||
|
- **Responsive UI**: More consistent search fields, settings groups, page headers, modal sheets, empty/error states, and collection selection behavior, with improved tablet widths and short-screen setup/tutorial layouts.
|
||||||
|
- **Download Performance**: Faster duplicate checks for large playlists, fewer native-widget updates, reduced progress/session write churn, and safer HTTP Range recovery.
|
||||||
|
- **Library Performance**: Batched history/index updates, fewer rebuilds during scans, lazy duplicate loading, cached MusicBrainz lookups, and quality probes that avoid unnecessary SAF copies.
|
||||||
|
- **Extension Security**: Verify decentralized package integrity, isolate bad registry checksums, anchor URL handlers to their declared hosts, and enforce `minAppVersion` and required runtime features.
|
||||||
|
- **Dependencies**: Updated Flutter, Go, FFmpeg, Android notification, and supporting native dependencies.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [4.8.0] - 2026-07-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Tablet & Landscape UI**: Navigation rail, landscape Now Playing, clamped layouts for track lists, dialogs, sheets, settings, queue lists, and the setup wizard, with adaptive selection-card columns.
|
||||||
|
- **Playback Volume Normalization**: ReplayGain/R128 volume normalization for the built-in player (Library > Playback, off by default).
|
||||||
|
- **Concurrent Downloads**: Support up to 3 simultaneous downloads.
|
||||||
|
- **Download Resume**: Interrupted downloads resume via HTTP Range/If-Range instead of restarting from zero.
|
||||||
|
- **Lost Folder Access Recovery**: Detects a lost SAF grant and offers folder re-selection.
|
||||||
|
- **Native Tag Editors**: New native MP3 ID3v2, Ogg/Opus Vorbis comment, and M4A ilst tag editors — metadata edits for these formats no longer need FFmpeg.
|
||||||
|
- **Shared-Element Transitions**: Hero covers fly from search/recents to detail screens, the player expands from the mini player with drag-to-dismiss, and skeletons crossfade into content.
|
||||||
|
- **Player Gestures**: Swipe the mini player away to stop playback.
|
||||||
|
- **Convert Options**: Optionally retain original files after conversion, and support same-format lossless re-encoding.
|
||||||
|
- **Quality Variants**: Downloads preserve quality variants, named by measured quality.
|
||||||
|
- **Explicit Badge**: Explicit tracks show an [E] badge.
|
||||||
|
- **iOS Verification**: Extension verification and OAuth run through ASWebAuthenticationSession.
|
||||||
|
- **Forced Update**: Force update when 3 or more stable releases behind.
|
||||||
|
- **Display**: Requests the highest display refresh rate on Android; fluid bouncing scroll physics on capable devices; shimmer skeletons for home feed and artist screens.
|
||||||
|
- **Hero Animation Toggle**: Setting to disable Hero animations.
|
||||||
|
- **Network Hardening**: DNS-over-HTTPS fallback and download retry hardening.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Safer File Writes**: All SAF and direct-mode downloads are staged then atomically promoted with fsync; FLAC, M4A, and AC-4 tags are written atomically; fixed the endless FLAC-write failures on misnamed files.
|
||||||
|
- **Native Worker Reliability**: Recovers from dead runs, keeps batches and retries intact across races, never deletes a published download over bookkeeping failures, and flushes queue persistence when the app is backgrounded.
|
||||||
|
- **Download History**: Persisted and reconciled reliably; completed downloads bridge into the library; library records persist before download completion.
|
||||||
|
- **Playlist Import Order**: Imported playlists keep their track order (#482).
|
||||||
|
- **Playback**: No auto-resume after an audio interruption while backgrounded; stale playback and SAF temp files cleaned up.
|
||||||
|
- **M4A Metadata**: stco/co64 chunk offsets shift correctly when the ilst tag resizes.
|
||||||
|
- **Extensions**: Data directory removed on uninstall, storage/credential writes serialized across runtimes, stale pending auth challenges expire, package and runtime lifecycle hardened, bounded file reads and cookie state.
|
||||||
|
- **Localization**: Deezer requests send Accept-Language so names follow the app language.
|
||||||
|
- **arm32 Stability**: Built with Go 1.26.5 for aligned cgo frames.
|
||||||
|
- **CSV Import**: Use State's context instead of a stale parameter (#460) — thanks @A831ARD0.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- **Broad Performance Pass**: Streaming tag rewrites for Ogg and M4A, streamed PCM analysis, SAF scan results spill to disk, Go heap and image caches released on memory pressure, per-extension goja runtime pool, incremental queue persistence and ISRC index, byte-capped cover disk cache, TTL/ETag-cached update checks, gzip and TLS session resumption, backdrop blur gated by device capability, incremental DB auto-vacuum.
|
||||||
|
- **Codebase Health**: Large-scale deduplication across screens, providers, and the Go backend; dead code paths removed.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Graduated from Beta**: Native download worker and Backup & Restore are now stable.
|
||||||
|
- **Terminology**: Extension "store" renamed to "repo" across all layers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [4.7.1] - 2026-07-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Full Extension Metadata Embedding**: Full metadata and cover are embedded after extension downloads.
|
||||||
|
- **Lossless Conversion Options**: Dither and resampler options.
|
||||||
|
- **Verification Browser Preference**: Choose in-app or external browser for extension verification, with manual verification help when browser launch fails.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Convert Sheets**: Draggable and scroll-controlled bottom sheets.
|
||||||
|
- **Verification**: Defaults to the in-app browser first; native worker verification hands off to the interactive queue; signed-session auth bootstraps and clears pending state correctly.
|
||||||
|
- **AC-4**: Truncated sample entries are rejected safely.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [4.7.0] - 2026-07-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **New Playback Experience**: Rebuilt built-in player with media integration, reorderable up-next queue, and playback stability fixes.
|
||||||
|
- **Track Previews**: Play short preview snippets from track lists.
|
||||||
|
- **Motion Artwork**: HLS motion-artwork header banners (Apple Music style) and audio-quality badges.
|
||||||
|
- **Backup & Restore (Beta)**: Settings, download history, library, and installed extensions.
|
||||||
|
- **Lossless Conversion Caps**: Cap bit depth/sample rate on lossless conversion, including album batch convert.
|
||||||
|
- **ReplayGain**: Opus R128 gain tags.
|
||||||
|
- **AC-4 Passthrough**: Download support for AC-4 streams.
|
||||||
|
- **Queue**: Tap a failed download to view its error details.
|
||||||
|
- **Filename Placeholders**: `{playlist_position}` placeholder.
|
||||||
|
- **Playlist Folders**: Rename action.
|
||||||
|
- **Network Setting**: Opt-in allow local/private network access.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Verification**: Retries tracked per service; signed session files scoped by endpoint and app context; early abort in fallback.
|
||||||
|
- **Lyrics**: Provider priority synced to backend, improved fallback and health handling.
|
||||||
|
- **Metadata**: Oversized cover art capped, QuickTime/MP4 tag support, native M4A ISRC/label writes, all edited fields read back from file.
|
||||||
|
- **Navigation**: Tapping an artist name navigates to the correct artist.
|
||||||
|
- **Compatibility**: Impeller disabled on Sony audio players and Vivante GPUs.
|
||||||
|
- **Fallback Quality**: Actual output format detected from audio codec; requested quality honored when the fallback provider recognizes it.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- **Performance**: Optimized queue/library pagination, counts, scan, and extension runtime.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [4.6.0] - 2026-06-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **WAV & AIFF Support**: Full metadata support with a settings-style metadata menu.
|
||||||
|
- **LyricsPlus Provider** and **ReplayGain batch scanning**.
|
||||||
|
- **French & German Locales**.
|
||||||
|
- **UI Polish**: Blurred backdrop playlist headers, frosted translucent bottom navbar, active downloads shown inside the library grid, actual lyrics source shown in metadata.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Downloads**: Selected provider honored when it equals the track source; fallback provider requests its own highest quality; iOS background task handling.
|
||||||
|
- **UI**: High-res cover in the track metadata header, centered modals on large screens, modernized edit-metadata and convert sheets, predictive-back page transition override.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- **Concurrent Download Option**: Removed during the playback rework (returned in 4.8.0 with proper support).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [4.5.6] - 2026-06-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Cross-Extension Sharing**: Rebuilt sharing and queue controls; polished search empty state.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Sharing & Fallback Routing**: Cross-service sharing aligned with fallback routing.
|
||||||
|
- **Metadata**: Stricter matching, `embedLyrics` setting respected, improved Apple Music lyrics; album-only autofill and placeholder re-enrich regressions resolved.
|
||||||
|
- **Downloads**: Hardened error handling and re-enrich sidecars.
|
||||||
|
- **iOS**: Deployment target aligned for the file picker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [4.5.5] - 2026-05-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **5 New Lyrics Providers**.
|
||||||
|
- **Enhanced Audio Analysis**: Loudness, clipping, and spectral cutoff metrics, with rescan support after a cached result.
|
||||||
|
- **AAC Support**: AAC conversion and lossy download target.
|
||||||
|
- **Apple Music Word-Synced Lyrics**: Toggle for eLRC word sync.
|
||||||
|
- **Launch Announcements**: Remote-config announcements shown on app start.
|
||||||
|
- **History**: Codec format and bitrate persisted in download history.
|
||||||
|
- **MP4 Codec Detection**: FLAC/ALAC/EAC3/AC3/AC4 detected inside MP4 containers.
|
||||||
|
- **Library**: Scroll-to-top and scroll-to-bottom quick buttons.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **No Fake Lossless**: Codec probes prevent lossy-to-lossless upscale conversions in every branch.
|
||||||
|
- **MP3 Lyrics**: Embedded via ID3v2.3 USLT frame.
|
||||||
|
- **FLAC**: Native muxer forced when decrypting to .flac output; native handling fixes.
|
||||||
|
- **SAF**: Extension-agnostic `.partial` staged filename.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [4.5.1] - 2026-05-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Native Worker**: Avoids the Android binder payload limit.
|
||||||
|
- **Extensions**: Missing-extension state shown for returning users.
|
||||||
|
- **Downloads**: `dataSync` foreground service type declared; default quality settings restored.
|
||||||
|
- **UI**: Settings editor white screens fixed; library search stabilized.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [4.5.0] - 2026-05-06
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **All Built-in Download Providers Retired**: Tidal, Qobuz, and Deezer now live as extensions with isolated runtimes. Install them from the Store to keep downloading.
|
||||||
|
- **Settings Reorganized**: Split into focused pages — thanks @Amonoman.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Android Native Download Worker (Experimental)**: Downloads continue reliably in the background.
|
||||||
|
- **Favorite Artists Collection**.
|
||||||
|
- **Extension Service Health**: Health indicators for extension services.
|
||||||
|
- **Download Deduplication Setting** — thanks @Amonoman.
|
||||||
|
- **New Look**: Google Sans Flex font and monochrome icon support.
|
||||||
|
- **Extension APIs**: `skip_fallback` capability, download dedup/ISRC/lyrics APIs, generic provider resolution, progress phases.
|
||||||
|
- **Metadata**: MusicBrainz album artist fallback; instrumental lyrics heuristic; audio duration exposed.
|
||||||
|
- **Home Feed**: Can be disabled.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **iOS**: Security-scoped bookmarks for download directory persistence.
|
||||||
|
- **SAF**: Filenames and directory segments truncate safely at UTF-8 boundaries.
|
||||||
|
- **Native Worker**: Metadata embedding, notification progress, and history schema sync.
|
||||||
|
- **Localization**: Missing Crowdin locale variants mapped; unsupported locales fall back to English.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- **Database-Backed Pagination**: Queue tab and local library migrated from in-memory lists to paginated database queries — much lower memory use on large libraries.
|
||||||
|
- **Performance**: Delta-mode native worker snapshots, reduced bridge/UI churn, improved queue resilience.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [4.3.1] - 2026-04-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Extension Output Control**: `preserveNativeOutputExtensions` capability; M4A converts to FLAC when the extension doesn't prefer native M4A output; extension download metadata carried through the host pipeline.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Container Handling**: `.mp4` treated as alias for `.m4a` throughout the download pipeline; `OutputExt` reset on extension→extension and extension→built-in fallback.
|
||||||
|
- **Provider Choice**: User's provider choice respected over source extension priority.
|
||||||
|
- **Tidal Metadata**: Copyright and album artist improved; DATE/YEAR tag sync fixed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [4.3.0] - 2026-04-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Artist Search Filter** and normalized search filter handling.
|
||||||
|
- **Default Search Tab Preference**.
|
||||||
|
- **Native M4A ReplayGain** tag writing and SAF picker error handling.
|
||||||
|
- **Download Cancel**: Propagates to extension HTTP requests.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Deezer Search via Extension**: Search flow moved out of the core app.
|
||||||
|
- **Extension Manifest**: `author` field removed.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **iOS**: Extension OAuth callback handled.
|
||||||
|
- **M4A**: Existing metadata preserved during embed; ALAC quality parsing improved.
|
||||||
|
- **Extensions**: Shared link handling stabilized; genre falls back to extra metadata.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [4.2.2] - 2026-04-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Configurable Extension Download Fallback**.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Deezer is now an Extension**: Moved from built-in service to installable extension.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Metadata**: Downloaded metadata persisted, re-enrich matching aligned with autofill, composer preserved across Qobuz and history, flat singles output preserved for extension releases.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [4.2.1] - 2026-04-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Metadata Enrichment**: Composer and track totals.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Crash Fix**: Stale audio service manifest entries removed (crashed on some devices).
|
||||||
|
- **Stability**: Hardened gomobile extension bindings, M4A cover retention, local convert format and library entries preserved, embedded metadata details preserved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [4.2.0] - 2026-04-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **ReplayGain Scanning** and APEv2 tag support.
|
||||||
|
- **Singles/EP Filename Format**: Separate format setting (#271).
|
||||||
|
- **Bulk Re-enrich Field Selection**: Choose which fields to update.
|
||||||
|
- **Resolve API**: With SongLink fallback.
|
||||||
|
- **Extension API**: `skipLyrics` manifest field; additional search/metadata API with separate rate limiting.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Multi-Artist Tags** (#288).
|
||||||
|
- **Qobuz 401 Errors**: API calls routed through an authenticated gateway.
|
||||||
|
- **Metadata**: Missing track/disc numbers from search downloads, ISRC validation to prevent ID leakage, label/copyright resolved from file metadata.
|
||||||
|
- **UI**: System navigation bar matches app theme.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- **UI Smoothness**: Memoization, compute isolates, SQL-backed playlist picker, viewport-aware image caching.
|
||||||
|
- **Performance**: Incremental queue lookups, async cover cleanup, native JSON decoding on iOS.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- **Legacy API Clients**: Including the Yoinkify fallback and an unused lyrics provider.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [4.1.3] - 2026-03-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Artist Tag Mode**: Setting with split Vorbis support.
|
||||||
|
- **Search & Filters**: Metadata filters and extended sort options; Qobuz album-search fallback; stable cover cache keys.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Samsung SAF Library Scan**, Qobuz album cover, and M4A metadata save.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [4.1.2] - 2026-03-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Batch Progress Dialog**: Replaces snackbars for batch operations.
|
||||||
|
- **Library**: Haptic feedback when swiping tabs; play button on playlist/library track tiles.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Extension Quality**: Tidal quality options used as fallback; DEFAULT quality normalized to prevent API failures.
|
||||||
|
- **ALAC**: `attached_pic` disposition for cover art embedding.
|
||||||
|
- **Downloads**: `START_NOT_STICKY` prevents DownloadService auto-restart.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- **Track Matching**: Scoring-based ReEnrich selection; Spotify URLs routed through extensions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [4.1.1] - 2026-03-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Extension Download Progress**: Byte-level progress tracking.
|
||||||
|
- **Spectrogram Cache**: Cached as PNG for instant loading on subsequent views.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **UI**: Artist skeleton polish; SpectrogramView null crash when loading from PNG cache.
|
||||||
|
- **SAF**: `artist_album_flat` case added to relative output dir builder.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [4.1.0] - 2026-03-26
|
||||||
|
|
||||||
|
Version numbering returns to 4.x, continuing from the 3.x rollback in 3.7.0.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Audio Quality Analysis**: Analysis widget with cached results.
|
||||||
|
- **Smart Service Selection**: Recommended download service auto-selected based on content source.
|
||||||
|
- **Search Result Sorting**.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **YouTube Downloads**: Extracted to the YouTube Music extension.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **UI**: Skeleton visibility and artist header in light mode, store URL input flash on startup, unintended home reset on tab switch, embedded cover refresh.
|
||||||
|
- **Metadata**: Hi-res cover art for Tidal/Qobuz, album metadata override, FLAC metadata fallback for mismatched files, USLT lyrics detection.
|
||||||
|
- **Navigation**: Tidal/Qobuz items from Recent Access open built-in screens.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- **Startup**: Lazy extension VM init and incremental startup maintenance.
|
||||||
|
- **Deezer Resolution**: Uses Tidal/Qobuz metadata; Qobuz skips SongLink when ISRC is already available.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.9.0] - 2026-03-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Built-in Tidal/Qobuz Search**: With a recommended service picker and a built-in search provider setting.
|
||||||
|
- **Home Feed Provider Setting**.
|
||||||
|
- **Tidal HIGH Restored**: AAC 320kbps lossy quality option (#242).
|
||||||
|
- **Full M4A Tag Engine**: Read engine with atom path fallback, metadata/cover embed across all screens, and library scan support.
|
||||||
|
- **Playlist Source Folders**: `createPlaylistFolder` setting for playlist folder prefixes.
|
||||||
|
- **Resilient Artist Matching**: Diacritic folding for loose artist name matching.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Localization**: Crowdin locale files consolidated, ICU plural warnings fixed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.8.8] - 2026-03-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Tidal ISRC & Metadata Search**.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Lyrics Providers**: Migrated to Paxsenix endpoints.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Extension Download Reliability** and Qobuz API integration.
|
||||||
|
- **Stability**: Library scan IDs, pause queue behavior, and a scan race condition.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.8.7] - 2026-03-17
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Queue**: Active downloads properly stop when pausing; queue-as-FLAC hides when all selected tracks are already FLAC and skips FLAC tracks from selection.
|
||||||
|
- **Downloads**: Already-downloaded tracks skipped in library folder download-all; Spotify track availability resolution improved.
|
||||||
|
- **Covers**: Deezer and Tidal cover art upgraded to max quality.
|
||||||
|
- **Library**: Auto-scan cooldown honored.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.8.6] - 2026-03-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Local Library Auto-Scan** option.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Re-downloads**: Tracks converted to a different format are no longer re-downloaded.
|
||||||
|
- **iOS**: Folder picker delayed until sheet dismiss; Afkar hosts updated.
|
||||||
|
- **UI**: Double horizontal padding removed in the store tab.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.8.5] - 2026-03-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **FLAC/ALAC Bidirectional Lossless Conversion**.
|
||||||
|
- **Selective Auto-Fill**: Pick fields to auto-fill from online in the Edit Metadata sheet, with improved track resolution.
|
||||||
|
- **FLAC Redownload Queue**: Queue FLAC redownloads for local library tracks.
|
||||||
|
- **Opus 320kbps Quality** (Tidal HIGH tier removed; restored in 3.9.0).
|
||||||
|
- **Bulk Playlist Download**: Download multiple selected playlists — thanks @ViscousPot.
|
||||||
|
- **Playlist Import**: Playlist name auto-filled during import — thanks @ViscousPot.
|
||||||
|
- **Qobuz Afkar API Provider**: With request metadata preferred for consistent album grouping.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Wrong-Track Protection**: Resolved Tidal/Deezer tracks are verified against the download request before downloading.
|
||||||
|
- **Download All**: Skips already-downloaded tracks for albums and playlists.
|
||||||
|
- **Various Artists**: Album-level artist used instead of the first track's artist.
|
||||||
|
- **M4A/ALAC**: Cover art extracted for conversion, embedded lyrics detected, metadata and cover preserved in ALAC/M4A→FLAC conversion.
|
||||||
|
- **Batch Convert**: Target formats filtered by source formats.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.8.0] - 2026-03-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Qobuz & Tidal Metadata Search**: Built-in metadata search providers with priority-based unified search, full metadata API, URL parsers, and store support.
|
||||||
|
- **Auto-Enrich for Extension Downloads**: Metadata is automatically enriched.
|
||||||
|
- **Store Registry URL Management**: With iOS handler support and a cleaner store UI.
|
||||||
|
- **Provider Priority**: Deezer entry added to the priority UI.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Tidal**: Track resolution and playlist owner info.
|
||||||
|
- **iOS**: Stale built-in Spotify bridge handlers removed.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- **Performance**: Optimized polling, progress caching, staggered warmup, snapshot-based library scan, SAF metadata reading, CUE sibling resolution, and startup initialization.
|
||||||
|
- **Localization**: Hardcoded strings replaced with l10n keys across 13 screens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [3.7.2] - 2026-03-07
|
## [3.7.2] - 2026-03-07
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@@ -334,7 +842,7 @@ Thank you for your understanding and continued support. This decision was made t
|
|||||||
- Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service
|
- Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service
|
||||||
- New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)`
|
- New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)`
|
||||||
- SpotFetch metadata fallback integration for Spotify-blocked regions
|
- SpotFetch metadata fallback integration for Spotify-blocked regions
|
||||||
- New backend client for `spotify.afkarxyz.fun/api`
|
- New backend client for `sp.afkarxyz.qzz.io/api`
|
||||||
- Automatic fallback in Spotify metadata fetch path when primary source fails
|
- Automatic fallback in Spotify metadata fetch path when primary source fails
|
||||||
- Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC
|
- Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC
|
||||||
- Includes heuristic detection of lyrics stored in Comment fields
|
- Includes heuristic detection of lyrics stored in Comment fields
|
||||||
@@ -349,7 +857,7 @@ Thank you for your understanding and continued support. This decision was made t
|
|||||||
- Legacy Dart bridge methods (`downloadTrack`, `downloadWithFallback`, `downloadWithExtensions`, `downloadFromYouTube`) are now thin wrappers and marked `@Deprecated`
|
- Legacy Dart bridge methods (`downloadTrack`, `downloadWithFallback`, `downloadWithExtensions`, `downloadFromYouTube`) are now thin wrappers and marked `@Deprecated`
|
||||||
- Qobuz downloader updated to latest Jumo API contract (`/get` endpoint, required headers)
|
- Qobuz downloader updated to latest Jumo API contract (`/get` endpoint, required headers)
|
||||||
- Amazon download flow now returns `decryption_key` from Go and performs decryption in Flutter (local file + SAF paths)
|
- Amazon download flow now returns `decryption_key` from Go and performs decryption in Flutter (local file + SAF paths)
|
||||||
- Amazon now uses the new `amazon.afkarxyz.fun` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
|
- Amazon now uses the new `amzn.afkarxyz.qzz.io` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
|
||||||
- Amazon ASIN extraction rewritten with robust URL/query-param parsing and regex fallback
|
- Amazon ASIN extraction rewritten with robust URL/query-param parsing and regex fallback
|
||||||
- Amazon provider re-enabled in download service picker and download settings (alongside Tidal, Qobuz, and YouTube picker flow)
|
- Amazon provider re-enabled in download service picker and download settings (alongside Tidal, Qobuz, and YouTube picker flow)
|
||||||
- Track Metadata cover UI now refreshes from the embedded file after Edit Metadata/Re-enrich, so the displayed art matches actual file tags
|
- Track Metadata cover UI now refreshes from the embedded file after Edit Metadata/Re-enrich, so the displayed art matches actual file tags
|
||||||
@@ -745,8 +1253,8 @@ Thank you for your understanding and continued support. This decision was made t
|
|||||||
|
|
||||||
### Highlights
|
### Highlights
|
||||||
|
|
||||||
- **Local Library Scanning** ([#117](https://github.com/zarzet/SpotiFLAC-Mobile/issues/117)): Scan existing music collection to detect duplicates (FLAC, M4A, MP3, Opus, OGG)
|
- **Local Library Scanning** ([#117](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/117)): Scan existing music collection to detect duplicates (FLAC, M4A, MP3, Opus, OGG)
|
||||||
- **Duplicate Detection** ([#117](https://github.com/zarzet/SpotiFLAC-Mobile/issues/117)): "In Library" badge on tracks matching by ISRC or track name + artist
|
- **Duplicate Detection** ([#117](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/117)): "In Library" badge on tracks matching by ISRC or track name + artist
|
||||||
- **Unified Library Tab**: History renamed to Library, shows Downloaded + Local Library tracks with source badges
|
- **Unified Library Tab**: History renamed to Library, shows Downloaded + Local Library tracks with source badges
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -814,7 +1322,7 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Clear All Queue Button**: Cancel all queued downloads with one tap ([#96](https://github.com/zarzet/SpotiFLAC-Mobile/issues/96))
|
- **Clear All Queue Button**: Cancel all queued downloads with one tap ([#96](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/96))
|
||||||
- **IDHS Fallback**: Fallback link resolver when SongLink fails (rate limited 8 req/min)
|
- **IDHS Fallback**: Fallback link resolver when SongLink fails (rate limited 8 req/min)
|
||||||
- **Lossy Bitrate Options**: MP3 (320/256/192/128kbps), Opus (128/96/64kbps)
|
- **Lossy Bitrate Options**: MP3 (320/256/192/128kbps), Opus (128/96/64kbps)
|
||||||
- **Search Filters**: Filter results by type (Tracks, Artists, Albums, Playlists)
|
- **Search Filters**: Filter results by type (Tracks, Artists, Albums, Playlists)
|
||||||
@@ -831,10 +1339,10 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **MP3 Download Error 403**: Fixed 403 Forbidden error when downloading MP3 files ([#108](https://github.com/zarzet/SpotiFLAC-Mobile/issues/108))
|
- **MP3 Download Error 403**: Fixed 403 Forbidden error when downloading MP3 files ([#108](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/108))
|
||||||
- **Opus Cover Art**: Implemented METADATA_BLOCK_PICTURE for proper cover embedding
|
- **Opus Cover Art**: Implemented METADATA_BLOCK_PICTURE for proper cover embedding
|
||||||
- **Deezer Pagination**: Fixed >25 tracks only showing first 25 ([#112](https://github.com/zarzet/SpotiFLAC-Mobile/issues/112))
|
- **Deezer Pagination**: Fixed >25 tracks only showing first 25 ([#112](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/112))
|
||||||
- **Duplicate Embed Lyrics Setting**: Removed from Options page ([#110](https://github.com/zarzet/SpotiFLAC-Mobile/issues/110))
|
- **Duplicate Embed Lyrics Setting**: Removed from Options page ([#110](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/110))
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1047,7 +1555,7 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
|||||||
- Spanish: Credits 125 ([@credits125](https://crowdin.com/profile/credits125))
|
- Spanish: Credits 125 ([@credits125](https://crowdin.com/profile/credits125))
|
||||||
- Portuguese: Pedro Marcondes ([@justapedro](https://crowdin.com/profile/justapedro))
|
- Portuguese: Pedro Marcondes ([@justapedro](https://crowdin.com/profile/justapedro))
|
||||||
- Russian: Владислав ([@odinokiy_kot](https://crowdin.com/profile/odinokiy_kot))
|
- Russian: Владислав ([@odinokiy_kot](https://crowdin.com/profile/odinokiy_kot))
|
||||||
- **Quick Search Provider Switcher** ([#76](https://github.com/zarzet/SpotiFLAC-Mobile/issues/76)): Dropdown menu in search bar for instant provider switching
|
- **Quick Search Provider Switcher** ([#76](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/76)): Dropdown menu in search bar for instant provider switching
|
||||||
- Tap the search icon to reveal a dropdown menu with all available search providers
|
- Tap the search icon to reveal a dropdown menu with all available search providers
|
||||||
- Shows default provider (Deezer based on metadata source setting) at the top
|
- Shows default provider (Deezer based on metadata source setting) at the top
|
||||||
- Lists all enabled extensions with custom search capability
|
- Lists all enabled extensions with custom search capability
|
||||||
@@ -1056,16 +1564,16 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
|||||||
- Search hint text updates immediately when switching providers
|
- Search hint text updates immediately when switching providers
|
||||||
- Re-triggers search automatically if there's existing text in the search bar
|
- Re-triggers search automatically if there's existing text in the search bar
|
||||||
- Eliminates need to navigate to Settings > Extensions > Search Provider
|
- Eliminates need to navigate to Settings > Extensions > Search Provider
|
||||||
- **Extension Button Setting Type** ([#74](https://github.com/zarzet/SpotiFLAC-Mobile/issues/74)): New setting type for extension actions
|
- **Extension Button Setting Type** ([#74](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/74)): New setting type for extension actions
|
||||||
- Extensions can define `button` type in manifest settings
|
- Extensions can define `button` type in manifest settings
|
||||||
- Triggers JavaScript function when tapped (e.g., start OAuth flow)
|
- Triggers JavaScript function when tapped (e.g., start OAuth flow)
|
||||||
- Useful for authentication, manual sync, or any custom action
|
- Useful for authentication, manual sync, or any custom action
|
||||||
- **Genre & Label Metadata** ([#75](https://github.com/zarzet/SpotiFLAC-Mobile/issues/75)): Downloaded tracks now include genre and record label information
|
- **Genre & Label Metadata** ([#75](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/75)): Downloaded tracks now include genre and record label information
|
||||||
- Fetches genre and label from Deezer album API for each track
|
- Fetches genre and label from Deezer album API for each track
|
||||||
- Embeds GENRE, ORGANIZATION (label), and COPYRIGHT tags into FLAC files
|
- Embeds GENRE, ORGANIZATION (label), and COPYRIGHT tags into FLAC files
|
||||||
- Works automatically when Deezer track ID is available (via ISRC matching)
|
- Works automatically when Deezer track ID is available (via ISRC matching)
|
||||||
- Supports all download services (Tidal, Qobuz, Amazon) and extension downloads
|
- Supports all download services (Tidal, Qobuz, Amazon) and extension downloads
|
||||||
- **MP3 Quality Option** ([#69](https://github.com/zarzet/SpotiFLAC-Mobile/issues/69)): Optional MP3 download format with FLAC-to-MP3 conversion
|
- **MP3 Quality Option** ([#69](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/69)): Optional MP3 download format with FLAC-to-MP3 conversion
|
||||||
- New "Enable MP3 Option" toggle in Settings > Download > Audio Quality
|
- New "Enable MP3 Option" toggle in Settings > Download > Audio Quality
|
||||||
- When enabled, MP3 (320kbps) appears as a quality option alongside FLAC options
|
- When enabled, MP3 (320kbps) appears as a quality option alongside FLAC options
|
||||||
- Available in both the quality picker dialog and default quality settings
|
- Available in both the quality picker dialog and default quality settings
|
||||||
@@ -1091,12 +1599,12 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
|||||||
- **Artist Name in Album Screen**: Album info card now displays artist name below album title
|
- **Artist Name in Album Screen**: Album info card now displays artist name below album title
|
||||||
- Extracted from first track's artist metadata
|
- Extracted from first track's artist metadata
|
||||||
- Styled with `onSurfaceVariant` color for visual hierarchy
|
- Styled with `onSurfaceVariant` color for visual hierarchy
|
||||||
- **Disc Separation for Multi-Disc Albums** ([#70](https://github.com/zarzet/SpotiFLAC-Mobile/issues/70)): Downloaded albums with multiple discs now display tracks grouped by disc
|
- **Disc Separation for Multi-Disc Albums** ([#70](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/70)): Downloaded albums with multiple discs now display tracks grouped by disc
|
||||||
- Visual disc separator header showing "Disc 1", "Disc 2", etc.
|
- Visual disc separator header showing "Disc 1", "Disc 2", etc.
|
||||||
- Tracks sorted by disc number first, then by track number
|
- Tracks sorted by disc number first, then by track number
|
||||||
- Single-disc albums display normally without separators
|
- Single-disc albums display normally without separators
|
||||||
- Fixes confusion when albums have duplicate track numbers across discs
|
- Fixes confusion when albums have duplicate track numbers across discs
|
||||||
- **Album Grouping in Recents** ([#70](https://github.com/zarzet/SpotiFLAC-Mobile/issues/70)): Downloads now show as albums instead of individual tracks in the Recent section
|
- **Album Grouping in Recents** ([#70](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/70)): Downloads now show as albums instead of individual tracks in the Recent section
|
||||||
- Prevents flooding the recents list when downloading full albums
|
- Prevents flooding the recents list when downloading full albums
|
||||||
- Groups tracks by album name and artist
|
- Groups tracks by album name and artist
|
||||||
- Tapping navigates directly to the downloaded album screen
|
- Tapping navigates directly to the downloaded album screen
|
||||||
@@ -1236,4 +1744,4 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
_For older versions, see [GitHub Releases_](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
_For older versions, see [GitHub Releases_](https://github.com/spotiflacapp/SpotiFLAC-Mobile/releases)
|
||||||
|
|||||||
@@ -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)
|
- Search the [existing issues](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues)
|
||||||
- [How Can I Contribute?](#how-can-i-contribute)
|
before opening a new one.
|
||||||
- [Reporting Bugs](#reporting-bugs)
|
- Use the issue template that best matches the problem.
|
||||||
- [Suggesting Features](#suggesting-features)
|
- Keep pull requests focused. Separate unrelated fixes into separate PRs.
|
||||||
- [Code Contributions](#code-contributions)
|
- Never commit credentials, signing files, downloaded media, or generated build
|
||||||
- [Translations](#translations)
|
artifacts.
|
||||||
- [Development Setup](#development-setup)
|
|
||||||
- [Project Structure](#project-structure)
|
|
||||||
- [Coding Guidelines](#coding-guidelines)
|
|
||||||
- [Commit Guidelines](#commit-guidelines)
|
|
||||||
- [Pull Request Process](#pull-request-process)
|
|
||||||
|
|
||||||
## Code of Conduct
|
Translations are managed through the
|
||||||
|
[SpotiFLAC Mobile Crowdin project](https://crowdin.com/project/spotiflac-mobile).
|
||||||
|
The English source strings live in `lib/l10n/arb/app_en.arb`.
|
||||||
|
|
||||||
This project and everyone participating in it is governed by our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to the project maintainers.
|
## Toolchain
|
||||||
|
|
||||||
## How Can I Contribute?
|
The repository is the source of truth for tool versions:
|
||||||
|
|
||||||
### Reporting Bugs
|
- Flutter: `.fvmrc`
|
||||||
|
- Dart: bundled with the pinned Flutter SDK
|
||||||
|
- Go: `go_backend/go.mod`
|
||||||
|
- Android SDK, NDK, and Java: `.github/workflows/ci.yml`
|
||||||
|
- Xcode: required only for iOS builds
|
||||||
|
|
||||||
Before creating bug reports, please check the [existing issues](https://github.com/zarzet/SpotiFLAC-Mobile/issues) to avoid duplicates.
|
[FVM](https://fvm.app/) is recommended. If you do not use FVM, install the
|
||||||
|
exact Flutter version declared in `.fvmrc` and replace `fvm flutter` with
|
||||||
When creating a bug report, please use the bug report template and include:
|
`flutter` (and `fvm dart` with `dart`) in the commands below.
|
||||||
|
|
||||||
- **Clear and descriptive title**
|
|
||||||
- **Steps to reproduce** the issue
|
|
||||||
- **Expected behavior** vs **actual behavior**
|
|
||||||
- **Screenshots or screen recordings** if applicable
|
|
||||||
- **Device information** (model, OS version)
|
|
||||||
- **App version**
|
|
||||||
- **Logs** from Settings > About > View Logs
|
|
||||||
|
|
||||||
### Suggesting Features
|
|
||||||
|
|
||||||
Feature requests are welcome! Please use the feature request template and:
|
|
||||||
|
|
||||||
- **Check existing issues** to avoid duplicates
|
|
||||||
- **Describe the feature** clearly
|
|
||||||
- **Explain the use case** - why would this be useful?
|
|
||||||
- **Consider the scope** - is this a small enhancement or a major feature?
|
|
||||||
|
|
||||||
### Code Contributions
|
|
||||||
|
|
||||||
1. **Fork the repository** and create your branch from `dev`
|
|
||||||
2. **Make your changes** following our coding guidelines
|
|
||||||
3. **Test your changes** thoroughly
|
|
||||||
4. **Submit a pull request** to the `dev` branch
|
|
||||||
|
|
||||||
### Translations
|
|
||||||
|
|
||||||
We use [Crowdin](https://crowdin.com/project/spotiflac-mobile) for translations. To contribute:
|
|
||||||
|
|
||||||
1. Visit our [Crowdin project](https://crowdin.com/project/spotiflac-mobile)
|
|
||||||
2. Select your language or request a new one
|
|
||||||
3. Start translating!
|
|
||||||
|
|
||||||
Translation files are located in `lib/l10n/arb/`.
|
|
||||||
|
|
||||||
## Development Setup
|
## Development Setup
|
||||||
|
|
||||||
### Prerequisites
|
1. Fork and clone the repository:
|
||||||
|
|
||||||
- **Flutter SDK** 3.10.0 or higher
|
|
||||||
- **Dart SDK** 3.10.0 or higher
|
|
||||||
- **Android Studio** or **VS Code** with Flutter extensions
|
|
||||||
- **Git**
|
|
||||||
|
|
||||||
### Getting Started
|
|
||||||
|
|
||||||
1. **Clone your fork**
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/YOUR_USERNAME/SpotiFLAC-Mobile.git
|
git clone https://github.com/YOUR_USERNAME/SpotiFLAC-Mobile.git
|
||||||
cd SpotiFLAC-Mobile
|
cd SpotiFLAC-Mobile
|
||||||
|
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
|
```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)**
|
3. Build the Go backend for Android. `ANDROID_NDK_HOME` must point to the NDK
|
||||||
```bash
|
version used by CI and `CGO_ENABLED` must be enabled.
|
||||||
fvm use
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Install dependencies**
|
|
||||||
```bash
|
```bash
|
||||||
flutter pub get
|
cd go_backend
|
||||||
```
|
go mod download
|
||||||
|
go install golang.org/x/mobile/cmd/gomobile
|
||||||
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
|
|
||||||
gomobile init
|
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 ..
|
cd ..
|
||||||
```
|
```
|
||||||
|
|
||||||
7. **Run the app**
|
Running `go install` from `go_backend/` uses the `x/mobile` version pinned by
|
||||||
|
`go.mod`. Do not replace it with `@latest` in project scripts.
|
||||||
|
|
||||||
|
4. Run the app:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
flutter run
|
fvm flutter run
|
||||||
```
|
```
|
||||||
|
|
||||||
### Building
|
For iOS, run `scripts/build_ios.sh` on macOS before opening
|
||||||
|
`ios/Runner.xcworkspace`.
|
||||||
|
|
||||||
|
## Project Boundaries
|
||||||
|
|
||||||
|
```text
|
||||||
|
lib/ Flutter UI, state, models, and platform orchestration
|
||||||
|
go_backend/ Download pipeline, extension runtime, and shared backend logic
|
||||||
|
android/ Android platform bridge and foreground worker
|
||||||
|
ios/ iOS platform bridge and application project
|
||||||
|
test/ Flutter unit and widget tests
|
||||||
|
assets/ Images, fonts, and bundled resources
|
||||||
|
docs/ Contributor-facing technical contracts
|
||||||
|
scripts/ Reproducible project build helpers
|
||||||
|
```
|
||||||
|
|
||||||
|
SpotiFLAC Mobile is extension-driven. Extension-specific behavior must be
|
||||||
|
declared through a generic manifest field, capability, or reusable app API.
|
||||||
|
Do not add provider-name checks such as `if source == 'provider-name'` to the
|
||||||
|
main app. The Go backend should parse and expose the generic declaration, and
|
||||||
|
Dart should consume that declaration without knowing which extension uses it.
|
||||||
|
|
||||||
|
## Generated Files
|
||||||
|
|
||||||
|
- After changing ARB files, run `fvm flutter gen-l10n` and commit the resulting
|
||||||
|
localization sources.
|
||||||
|
- Run `fvm dart run build_runner build --delete-conflicting-outputs` only when a
|
||||||
|
model or generator input changes, then commit the relevant generated source.
|
||||||
|
- Do not commit `build/`, `.dart_tool/`, AAR/XCFramework output, IDE state, or
|
||||||
|
local research directories.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Run checks that cover the code you changed. Before opening a PR, the relevant
|
||||||
|
commands should pass.
|
||||||
|
|
||||||
|
Flutter and Dart:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Debug build
|
fvm dart format --output=none --set-exit-if-changed lib test
|
||||||
flutter build apk --debug
|
fvm flutter analyze
|
||||||
|
fvm flutter test
|
||||||
# Release build
|
|
||||||
flutter build apk --release
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Structure
|
Go backend:
|
||||||
|
|
||||||
```
|
|
||||||
lib/
|
|
||||||
├── l10n/ # Localization files
|
|
||||||
│ └── arb/ # ARB translation files
|
|
||||||
├── models/ # Data models
|
|
||||||
├── providers/ # Riverpod providers
|
|
||||||
├── screens/ # UI screens
|
|
||||||
│ └── settings/ # Settings sub-screens
|
|
||||||
├── services/ # Business logic services
|
|
||||||
├── theme/ # App theming
|
|
||||||
├── utils/ # Utility functions
|
|
||||||
├── widgets/ # Reusable widgets
|
|
||||||
├── app.dart # App configuration
|
|
||||||
└── main.dart # Entry point
|
|
||||||
```
|
|
||||||
|
|
||||||
## Coding Guidelines
|
|
||||||
|
|
||||||
### General
|
|
||||||
|
|
||||||
- Follow [Effective Dart](https://dart.dev/effective-dart) guidelines
|
|
||||||
- Use meaningful variable and function names
|
|
||||||
- Keep functions small and focused
|
|
||||||
- Add comments for complex logic
|
|
||||||
|
|
||||||
### Formatting
|
|
||||||
|
|
||||||
- Use `dart format` before committing
|
|
||||||
- Maximum line length: 80 characters
|
|
||||||
- Use trailing commas for better formatting
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dart format .
|
cd go_backend
|
||||||
|
gofmt -w .
|
||||||
|
go vet ./...
|
||||||
|
go test ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
### Linting
|
Android native code, after building `gobackend.aar`:
|
||||||
|
|
||||||
Ensure your code passes all lints:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
flutter analyze
|
cd android
|
||||||
|
./gradlew :app:compileDebugKotlin :app:testDebugUnitTest
|
||||||
```
|
```
|
||||||
|
|
||||||
### State Management
|
For user-facing changes, add or update tests where practical and include
|
||||||
|
before/after screenshots for UI changes.
|
||||||
|
|
||||||
We use **Riverpod** for state management. Follow these patterns:
|
## Code and Commit Style
|
||||||
|
|
||||||
```dart
|
- Follow `analysis_options.yaml`, `.editorconfig`, and existing module patterns.
|
||||||
// Use code generation with riverpod_annotation
|
- Keep user-facing strings in the localization files.
|
||||||
@riverpod
|
- Prefer small functions and explicit error handling at platform boundaries.
|
||||||
class MyNotifier extends _$MyNotifier {
|
- Use [Conventional Commits](https://www.conventionalcommits.org/), for example:
|
||||||
@override
|
|
||||||
MyState build() => MyState();
|
|
||||||
|
|
||||||
// Methods to update state
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
1. Create a branch from an up-to-date `main`.
|
||||||
// Good
|
2. Make one focused change and include tests or verification evidence.
|
||||||
Text(AppLocalizations.of(context)!.downloadComplete)
|
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
|
When reporting a crash, include the SpotiFLAC Mobile version, release channel,
|
||||||
Text('Download Complete')
|
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.
|
||||||
|
|
||||||
To add new strings:
|
|
||||||
1. Add the key to `lib/l10n/arb/app_en.arb`
|
|
||||||
2. Run `flutter gen-l10n`
|
|
||||||
|
|
||||||
## Commit Guidelines
|
|
||||||
|
|
||||||
We follow [Conventional Commits](https://www.conventionalcommits.org/):
|
|
||||||
|
|
||||||
```
|
|
||||||
<type>(<scope>): <description>
|
|
||||||
|
|
||||||
[optional body]
|
|
||||||
|
|
||||||
[optional footer(s)]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Types
|
|
||||||
|
|
||||||
- `feat`: New feature
|
|
||||||
- `fix`: Bug fix
|
|
||||||
- `docs`: Documentation changes
|
|
||||||
- `style`: Code style changes (formatting, etc.)
|
|
||||||
- `refactor`: Code refactoring
|
|
||||||
- `perf`: Performance improvements
|
|
||||||
- `test`: Adding or updating tests
|
|
||||||
- `chore`: Maintenance tasks
|
|
||||||
|
|
||||||
### Examples
|
|
||||||
|
|
||||||
```
|
|
||||||
feat(download): add batch download support
|
|
||||||
fix(ui): resolve overflow on small screens
|
|
||||||
docs: update contributing guidelines
|
|
||||||
chore(deps): update flutter_riverpod to 3.1.0
|
|
||||||
```
|
|
||||||
|
|
||||||
## Pull Request Process
|
|
||||||
|
|
||||||
1. **Update your fork**
|
|
||||||
```bash
|
|
||||||
git fetch upstream
|
|
||||||
git rebase upstream/dev
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Create a feature branch**
|
|
||||||
```bash
|
|
||||||
git checkout -b feat/my-new-feature
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Make your changes** and commit following our guidelines
|
|
||||||
|
|
||||||
4. **Push to your fork**
|
|
||||||
```bash
|
|
||||||
git push origin feat/my-new-feature
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Create a Pull Request**
|
|
||||||
- Target the `dev` branch
|
|
||||||
- Fill in the PR template
|
|
||||||
- Link related issues
|
|
||||||
|
|
||||||
6. **Address review feedback**
|
|
||||||
- Make requested changes
|
|
||||||
- Push additional commits
|
|
||||||
- Request re-review when ready
|
|
||||||
|
|
||||||
### PR Requirements
|
|
||||||
|
|
||||||
- [ ] Code follows project conventions
|
|
||||||
- [ ] All tests pass
|
|
||||||
- [ ] No new linting errors
|
|
||||||
- [ ] Documentation updated (if needed)
|
|
||||||
- [ ] Commit messages follow guidelines
|
|
||||||
- [ ] PR description is clear and complete
|
|
||||||
|
|
||||||
## Questions?
|
|
||||||
|
|
||||||
If you have questions, feel free to:
|
|
||||||
|
|
||||||
- Open a [Discussion](https://github.com/zarzet/SpotiFLAC-Mobile/discussions)
|
|
||||||
- Check existing [Issues](https://github.com/zarzet/SpotiFLAC-Mobile/issues)
|
|
||||||
|
|
||||||
Thank you for contributing! 💚
|
|
||||||
|
|||||||
@@ -1,32 +1,22 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="assets/images/banner-readme-dark.png">
|
<source media="(prefers-color-scheme: dark)" srcset="assets/readme/banner-readme-dark.png">
|
||||||
<source media="(prefers-color-scheme: light)" srcset="assets/images/banner-readme-light.png">
|
<source media="(prefers-color-scheme: light)" srcset="assets/readme/banner-readme-light.png">
|
||||||
<img alt="SpotiFLAC Mobile" src="assets/images/banner-readme-light.png" width="650" height="auto">
|
<img alt="SpotiFLAC Mobile" src="assets/readme/banner-readme-light.png" width="650" height="auto">
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://trendshift.io/repositories/17247">
|
<a href="https://trendshift.io/repositories/25971" target="_blank">
|
||||||
<img src="https://trendshift.io/api/badge/repositories/17247" alt="zarzet%2FSpotiFLAC-Mobile | Trendshift" width="250" height="55">
|
<img src="https://trendshift.io/api/badge/repositories/25971" alt="spotiflacapp%2FSpotiFLAC-Mobile | Trendshift" width="250" height="55">
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## 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" />
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
[](https://github.com/spotiflacapp/SpotiFLAC-Mobile/releases)
|
||||||
[](https://www.virustotal.com/gui/file/63a445a956fa71ea347ad3695a62d543e14e341933326b9dbb9a15d79614ef58)
|
|
||||||
[](https://crowdin.com/project/spotiflac-mobile)
|
[](https://crowdin.com/project/spotiflac-mobile)
|
||||||
|
|
||||||
[](https://t.me/spotiflac)
|
[](https://t.me/spotiflac)
|
||||||
@@ -34,62 +24,180 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/readme/1.jpg?v=2" width="200" />
|
||||||
|
<img src="assets/readme/2.jpg?v=2" width="200" />
|
||||||
|
<img src="assets/readme/3.jpg?v=2" width="200" />
|
||||||
|
<img src="assets/readme/4.jpg?v=2" width="200" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Extensions
|
## Extensions
|
||||||
|
|
||||||
Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
|
Extensions let the community add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
|
||||||
|
|
||||||
### Installing Extensions
|
### Installing Extensions
|
||||||
1. Go to **Store** tab in the app
|
|
||||||
2. Browse and install extensions with one tap
|
1. Open the **Store** tab in the app
|
||||||
3. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
2. On first launch, enter an **Extension Repository URL** when prompted
|
||||||
4. Configure extension settings if needed
|
3. Browse and install extensions with one tap
|
||||||
5. Set provider priority in **Settings > Extensions > Provider Priority**
|
4. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
||||||
|
5. Configure extension settings if needed
|
||||||
|
6. Set provider priority under **Settings > Extensions > Provider Priority**
|
||||||
|
|
||||||
### Developing Extensions
|
### Developing Extensions
|
||||||
Want to create your own extension? Check out the [Extension Development Guide](https://zarzet.github.io/SpotiFLAC-Mobile/docs) for complete documentation.
|
|
||||||
|
|
||||||
## Other project
|
> [!NOTE]
|
||||||
|
> 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)
|
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||||
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music 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).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
**Q: Why is my download failing with "Song not found"?**
|
<details>
|
||||||
A: The track may not be available on the streaming services. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions like Amazon Music from the Store.
|
<summary><b>Why does the Store tab ask me to enter a URL?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
**Q: Why are some tracks downloading in lower quality?**
|
Starting from version 3.8.0, SpotiFLAC Mobile uses a decentralized extension
|
||||||
A: Quality depends on what's available from the streaming service and extensions. Built-in providers: Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Deezer up to 16-bit/44.1kHz.
|
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.
|
||||||
|
|
||||||
**Q: Can I download playlists?**
|
</details>
|
||||||
A: Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
|
|
||||||
|
|
||||||
**Q: Why do I need to grant storage permission?**
|
<details>
|
||||||
A: The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant "All files access" in Settings > Apps > SpotiFLAC > Permissions.
|
<summary><b>Why is my download failing with "Song not found"?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
**Q: Is this app safe?**
|
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.
|
||||||
A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README).
|
|
||||||
|
|
||||||
**Q: Why is download not working in my country?**
|
</details>
|
||||||
A: Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
|
|
||||||
|
|
||||||
**Q: Can I add SpotiFLAC to AltStore or SideStore?**
|
<details>
|
||||||
A: Yes! You can add the official source to receive updates directly within the app. Just copy this link:
|
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
|
||||||
https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/refs/heads/main/apps.json
|
<br>
|
||||||
In AltStore/SideStore, go to the Browse tab, tap Sources at the top, then tap the + icon and paste the link.
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
### Want to support SpotiFLAC-Mobile?
|
</details>
|
||||||
|
|
||||||
_If this software is useful and brings you value, consider supporting the project. Your support helps keep development going._
|
<details>
|
||||||
|
<summary><b>Can I download playlists?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
[](https://ko-fi.com/zarzet)
|
Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Why do I need to grant storage permission?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
The app needs a writable destination for downloaded files. On Android, choose a
|
||||||
|
folder with the system folder picker (SAF), or use the app-specific folder. If
|
||||||
|
Android revokes a saved folder grant, select the folder again in Settings.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Is this app safe?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
SpotiFLAC Mobile is open source, so its code and build workflows can be
|
||||||
|
inspected directly in this repository.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Why is downloading not working in my country?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Can I add SpotiFLAC 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/spotiflacapp/SpotiFLAC-Mobile/refs/heads/main/apps.json
|
||||||
|
```
|
||||||
|
|
||||||
|
In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> If SpotiFLAC Mobile is useful to you, consider supporting development:
|
||||||
|
>
|
||||||
|
> [](https://ko-fi.com/zarzet)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
|
Thanks to everyone who has contributed to SpotiFLAC Mobile!
|
||||||
|
|
||||||
|
<a href="https://github.com/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.
|
||||||
|
|
||||||
|
Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md) to get started!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## API Credits
|
## API Credits
|
||||||
|
|
||||||
[hifi-api](https://github.com/binimum/hifi-api) · [music.binimum.org](https://music.binimum.org) · [qqdl.site](https://qqdl.site) · [squid.wtf](https://squid.wtf) · [spotisaver.net](https://spotisaver.net) · [dabmusic.xyz](https://dabmusic.xyz) · [AfkarXYZ](https://github.com/afkarxyz) · [LRCLib](https://lrclib.net) · [Paxsenix](https://lyrics.paxsenix.org) · [Cobalt](https://cobalt.tools) · [qwkuns.me](https://qwkuns.me) · [SpotubeDL](https://spotubedl.com) · [Song.link](https://song.link) · [IDHS](https://github.com/sjdonado/idonthavespotify)
|
| | | | | |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| [MusicDL](https://www.musicdl.me) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) | [Song.link](https://song.link) |
|
||||||
|
| [IDHS](https://github.com/sjdonado/idonthavespotify) | | | | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Disclaimer
|
||||||
|
|
||||||
|
This repository and its contents are provided strictly for educational and research purposes. The software is provided "as-is" without warranty of any kind, express or implied, as stated in the [MIT License](LICENSE).
|
||||||
|
|
||||||
|
- No copyrighted content is hosted, stored, mirrored, or distributed by this repository.
|
||||||
|
- Users must ensure that their use of this software is properly authorized and complies with all applicable laws, regulations, and third-party terms of service.
|
||||||
|
- This software is provided free of charge by the maintainer. If you paid a third party for access to this software in its original form from this repository, you may have been misled or scammed. Any redistribution or commercial use by third parties must comply with the terms of the repository license. No affiliation, endorsement, or support by the maintainer is implied unless explicitly stated in writing.
|
||||||
|
- SpotiFLAC Mobile is an independent project. It is not affiliated with, endorsed by, or connected to any other project or version on other platforms that may share a similar name. The maintainer of this repository has no control over or responsibility for third-party projects.
|
||||||
|
- The author(s) disclaim all liability for any direct, indirect, incidental, or consequential damages arising from the use or misuse of this software. Users assume all risk associated with its use.
|
||||||
|
- If you are a copyright holder or authorized representative and believe this repository infringes upon your rights, please contact the maintainer with sufficient detail (including relevant URLs and proof of ownership). The matter will be promptly investigated and appropriate action will be taken, which may include removal of the referenced material.
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
>
|
> **Star the repo** to get notified about all new releases directly from GitHub.
|
||||||
> **Star Us**, You will receive all release notifications from GitHub without any delay
|
|
||||||
|
|||||||
@@ -9,6 +9,19 @@
|
|||||||
# packages, and plugins designed to encourage good coding practices.
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
include: package:flutter_lints/flutter.yaml
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
riverpod_lint: 3.1.4-dev.3
|
||||||
|
|
||||||
|
analyzer:
|
||||||
|
exclude:
|
||||||
|
- build/**
|
||||||
|
- .dart_tool/**
|
||||||
|
- lib/**/*.g.dart
|
||||||
|
- lib/l10n/*.dart
|
||||||
|
language:
|
||||||
|
strict-casts: true
|
||||||
|
strict-inference: true
|
||||||
|
strict-raw-types: true
|
||||||
linter:
|
linter:
|
||||||
# The lint rules applied to this project can be customized in the
|
# The lint rules applied to this project can be customized in the
|
||||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||||
@@ -23,6 +36,16 @@ linter:
|
|||||||
rules:
|
rules:
|
||||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||||
|
always_declare_return_types: true
|
||||||
|
avoid_dynamic_calls: true
|
||||||
|
avoid_types_as_parameter_names: true
|
||||||
|
strict_top_level_inference: true
|
||||||
|
type_annotate_public_apis: true
|
||||||
|
cancel_subscriptions: true
|
||||||
|
close_sinks: true
|
||||||
|
# Catches dead cross-layer chains (Dart wrapper kept alive only by its own
|
||||||
|
# declaration) before they accumulate into another dedup campaign.
|
||||||
|
unreachable_from_main: true
|
||||||
|
|
||||||
# Additional information about this file can be found at
|
# Additional information about this file can be found at
|
||||||
# https://dart.dev/guides/language/analysis-options
|
# 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,18 +17,22 @@ if (keystorePropertiesFile.exists()) {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.zarz.spotiflac"
|
namespace = "com.zarz.spotiflac"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = 37
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
isCoreLibraryDesugaringEnabled = true
|
isCoreLibraryDesugaringEnabled = true
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_25
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_25
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +50,7 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.zarz.spotiflac"
|
applicationId = "com.zarz.spotiflac"
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = 36
|
targetSdk = 37
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
multiDexEnabled = true
|
multiDexEnabled = true
|
||||||
@@ -57,6 +61,20 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
getByName("debug") {
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
|
versionNameSuffix = "-debug"
|
||||||
|
ndk {
|
||||||
|
debugSymbolLevel = "FULL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getByName("profile") {
|
||||||
|
ndk {
|
||||||
|
debugSymbolLevel = "FULL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
release {
|
release {
|
||||||
// For local builds: use release signing if key.properties exists
|
// For local builds: use release signing if key.properties exists
|
||||||
// For CI builds: APK is signed by GitHub Action after build
|
// For CI builds: APK is signed by GitHub Action after build
|
||||||
@@ -71,6 +89,9 @@ android {
|
|||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
)
|
)
|
||||||
|
ndk {
|
||||||
|
debugSymbolLevel = "FULL"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,8 +122,14 @@ dependencies {
|
|||||||
// Include all AAR and JAR files from libs folder
|
// Include all AAR and JAR files from libs folder
|
||||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
||||||
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0")
|
||||||
implementation("androidx.documentfile:documentfile:1.1.0")
|
implementation("androidx.documentfile:documentfile:1.1.0")
|
||||||
implementation("androidx.activity:activity-ktx:1.12.3")
|
implementation("androidx.activity:activity-ktx:1.13.0")
|
||||||
|
|
||||||
|
// NativeDownloadFinalizer imports FFmpegKit APIs directly. The Flutter
|
||||||
|
// plugin owns the runtime AAR; compileOnly avoids packaging it twice here.
|
||||||
|
compileOnly("com.antonkarpenko:ffmpeg-kit-full:2.2.1")
|
||||||
|
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="29" />
|
android:maxSdkVersion="29" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
@@ -18,9 +19,12 @@
|
|||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="SpotiFLAC"
|
android:label="SpotiFLAC Mobile"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:fullBackupContent="@xml/backup_rules_legacy"
|
||||||
|
android:dataExtractionRules="@xml/backup_rules"
|
||||||
android:usesCleartextTraffic="false"
|
android:usesCleartextTraffic="false"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:enableOnBackInvokedCallback="true"
|
android:enableOnBackInvokedCallback="true"
|
||||||
@@ -86,6 +90,26 @@
|
|||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="https" android:host="music.youtube.com" />
|
<data android:scheme="https" android:host="music.youtube.com" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Extension OAuth (PKCE) redirect: spotiflac://callback?code=...&state=<extension_id> -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="spotiflac" android:host="callback" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="spotiflac" android:host="spotify-callback" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="spotiflac" android:host="session-grant" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<!-- Download Service -->
|
<!-- Download Service -->
|
||||||
@@ -94,16 +118,26 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="dataSync" />
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
<!-- Audio playback service for media notification / background audio -->
|
<receiver
|
||||||
|
android:name=".DownloadQueueWidgetProvider"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/widget_download_queue_info" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name="com.ryanheise.audioservice.AudioService"
|
android:name="com.ryanheise.audioservice.AudioService"
|
||||||
|
android:foregroundServiceType="mediaPlayback"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:foregroundServiceType="mediaPlayback">
|
android:enabled="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.media.browse.MediaBrowserService" />
|
<action android:name="android.media.browse.MediaBrowserService" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
@@ -128,6 +162,10 @@
|
|||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.android.gms.car.application"
|
||||||
|
android:resource="@xml/automotive_app_desc" />
|
||||||
|
|
||||||
<!-- FileProvider for APK installation -->
|
<!-- FileProvider for APK installation -->
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,793 @@
|
|||||||
|
package com.zarz.spotiflac
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared SAF download wrapper for foreground activity calls and service-owned
|
||||||
|
* native workers.
|
||||||
|
*/
|
||||||
|
object SafDownloadHandler {
|
||||||
|
private val safDirLock = Any()
|
||||||
|
private const val MAX_SAF_DISPLAY_NAME_UTF8_BYTES = 180
|
||||||
|
private const val STAGED_SAF_MIME_TYPE = "application/octet-stream"
|
||||||
|
|
||||||
|
// Serializes the exists-check/create/write/publish sequence per target
|
||||||
|
// file so concurrent downloads that sanitize to the same display name
|
||||||
|
// cannot interleave writes into one document. Different names keep
|
||||||
|
// downloading in parallel; the second same-name caller blocks, then hits
|
||||||
|
// the exists check and reports already_exists.
|
||||||
|
private val safNameLocks = java.util.concurrent.ConcurrentHashMap<String, Any>()
|
||||||
|
|
||||||
|
data class UniqueWriteResult(val uri: String, val fileName: String)
|
||||||
|
data class ExistingAwareWriteResult(
|
||||||
|
val uri: String,
|
||||||
|
val fileName: String,
|
||||||
|
val alreadyExists: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun <T> withSafNameLock(
|
||||||
|
treeUriStr: String,
|
||||||
|
relativeDir: String,
|
||||||
|
fileName: String,
|
||||||
|
block: () -> T
|
||||||
|
): T {
|
||||||
|
val key = "$treeUriStr|$relativeDir|${fileName.lowercase(Locale.ROOT)}"
|
||||||
|
val lock = safNameLocks.computeIfAbsent(key) { Any() }
|
||||||
|
return synchronized(lock) { block() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flushes and fsyncs a SAF output stream so the copied bytes are durable
|
||||||
|
* before the staged document is renamed to its final name; without this a
|
||||||
|
* power loss right after the rename can leave a truncated "complete" file.
|
||||||
|
*/
|
||||||
|
fun syncOutputStream(output: OutputStream) {
|
||||||
|
try {
|
||||||
|
output.flush()
|
||||||
|
(output as? FileOutputStream)?.fd?.sync()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handle(context: Context, requestJson: String, downloader: (String) -> String): String {
|
||||||
|
val req = JSONObject(requestJson)
|
||||||
|
val storageMode = req.optString("storage_mode", "")
|
||||||
|
val treeUriStr = req.optString("saf_tree_uri", "")
|
||||||
|
if (storageMode != "saf" || treeUriStr.isBlank()) {
|
||||||
|
return downloader(requestJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
val relativeDir = sanitizeRelativeDir(req.optString("saf_relative_dir", ""))
|
||||||
|
val outputExt = normalizeExt(req.optString("saf_output_ext", ""))
|
||||||
|
val fileName = buildSafFileName(req, outputExt)
|
||||||
|
return withSafNameLock(treeUriStr, relativeDir, fileName) {
|
||||||
|
handleSafLocked(context, req, downloader, treeUriStr, relativeDir, outputExt, fileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleSafLocked(
|
||||||
|
context: Context,
|
||||||
|
req: JSONObject,
|
||||||
|
downloader: (String) -> String,
|
||||||
|
treeUriStr: String,
|
||||||
|
relativeDir: String,
|
||||||
|
outputExt: String,
|
||||||
|
fileName: String
|
||||||
|
): String {
|
||||||
|
val treeUri = Uri.parse(treeUriStr)
|
||||||
|
val mimeType = mimeTypeForExt(outputExt)
|
||||||
|
val deferSafPublish = req.optBoolean("defer_saf_publish", false)
|
||||||
|
// Downloads are always written under a staged ".partial" name so a
|
||||||
|
// killed process can never leave a half-written file under the final
|
||||||
|
// name (which the exists check would then accept as complete forever).
|
||||||
|
// Callers that set stage_saf_output own the promotion to the final
|
||||||
|
// name (the native finalizer); for everyone else — the foreground
|
||||||
|
// Dart queue — this handler promotes right after a successful write.
|
||||||
|
val finalizerPromotesStaged = req.optBoolean("stage_saf_output", false) && !deferSafPublish
|
||||||
|
val useStagedOutput = !deferSafPublish
|
||||||
|
val stagedFileName = if (useStagedOutput) buildStagedSafFileName(fileName) else fileName
|
||||||
|
val stagedMimeType = if (useStagedOutput) STAGED_SAF_MIME_TYPE else mimeType
|
||||||
|
|
||||||
|
val existingDir = findDocumentDir(context, treeUri, relativeDir)
|
||||||
|
if (existingDir != null) {
|
||||||
|
val existing = existingDir.findFile(fileName)
|
||||||
|
if (existing != null && existing.isFile && existing.length() > 0) {
|
||||||
|
deleteStaleStagedFiles(existingDir, fileName, outputExt)
|
||||||
|
val obj = JSONObject()
|
||||||
|
obj.put("success", true)
|
||||||
|
obj.put("message", "File already exists")
|
||||||
|
obj.put("file_path", existing.uri.toString())
|
||||||
|
obj.put("file_name", existing.name ?: fileName)
|
||||||
|
obj.put("already_exists", true)
|
||||||
|
return obj.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val targetDir = ensureDocumentDir(context, treeUri, relativeDir)
|
||||||
|
?: return errorJson("Failed to access SAF directory")
|
||||||
|
|
||||||
|
if (deferSafPublish) {
|
||||||
|
deleteStaleStagedFiles(targetDir, fileName, outputExt)
|
||||||
|
val workingExt = outputExt.ifBlank { ".tmp" }
|
||||||
|
val workingFile = File.createTempFile("native_saf_work_", workingExt, context.cacheDir)
|
||||||
|
Log.i("SpotiFLAC", "SAF deferred native output: target=$fileName working=${workingFile.name}")
|
||||||
|
return try {
|
||||||
|
req.put("output_path", workingFile.absolutePath)
|
||||||
|
req.put("output_ext", outputExt)
|
||||||
|
req.remove("output_fd")
|
||||||
|
val response = downloader(req.toString())
|
||||||
|
val respObj = JSONObject(response)
|
||||||
|
if (respObj.optBoolean("success", false)) {
|
||||||
|
val reportedPath = respObj.optString("file_path", "").trim()
|
||||||
|
if (reportedPath.isEmpty() || reportedPath.startsWith("/proc/self/fd/")) {
|
||||||
|
respObj.put("file_path", workingFile.absolutePath)
|
||||||
|
} else if (reportedPath != workingFile.absolutePath) {
|
||||||
|
workingFile.delete()
|
||||||
|
}
|
||||||
|
respObj.put("file_name", respObj.optString("file_name", "").ifBlank { fileName })
|
||||||
|
respObj.put("saf_deferred_publish", true)
|
||||||
|
respObj.put("saf_final_file_name", fileName)
|
||||||
|
respObj.put("saf_relative_dir", relativeDir)
|
||||||
|
respObj.put("saf_tree_uri", treeUriStr)
|
||||||
|
respObj.put("saf_output_ext", outputExt)
|
||||||
|
respObj.put("saf_final_mime_type", mimeType)
|
||||||
|
} else {
|
||||||
|
workingFile.delete()
|
||||||
|
}
|
||||||
|
respObj.toString()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
workingFile.delete()
|
||||||
|
errorJson("SAF deferred download failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any stale partial from a previous killed attempt before
|
||||||
|
// creating the staged document: reusing it would let a shorter new
|
||||||
|
// write leave the old tail bytes in place (fd truncation is
|
||||||
|
// best-effort on some providers).
|
||||||
|
deleteStaleStagedFiles(targetDir, fileName, outputExt)
|
||||||
|
var document = createOrReuseDocumentFile(targetDir, stagedMimeType, stagedFileName)
|
||||||
|
?: return errorJson("Failed to create SAF file")
|
||||||
|
|
||||||
|
val pfd = context.contentResolver.openFileDescriptor(document.uri, "rw")
|
||||||
|
?: return errorJson("Failed to open SAF file")
|
||||||
|
|
||||||
|
var detachedFd: Int? = null
|
||||||
|
try {
|
||||||
|
detachedFd = pfd.detachFd()
|
||||||
|
req.put("output_path", "")
|
||||||
|
req.put("output_fd", detachedFd)
|
||||||
|
req.put("output_ext", outputExt)
|
||||||
|
val response = downloader(req.toString())
|
||||||
|
val respObj = JSONObject(response)
|
||||||
|
if (respObj.optBoolean("success", false)) {
|
||||||
|
var finalFileName = fileName
|
||||||
|
val goFilePath = respObj.optString("file_path", "")
|
||||||
|
if (goFilePath.isNotEmpty() &&
|
||||||
|
!goFilePath.startsWith("content://") &&
|
||||||
|
!goFilePath.startsWith("/proc/self/fd/")
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
val srcFile = File(goFilePath)
|
||||||
|
if (!srcFile.exists() || srcFile.length() <= 0) {
|
||||||
|
throw IllegalStateException("extension output missing or empty: $goFilePath")
|
||||||
|
}
|
||||||
|
val actualExt = normalizeExt(srcFile.extension)
|
||||||
|
if (actualExt.isNotBlank()) {
|
||||||
|
respObj.put("actual_extension", actualExt)
|
||||||
|
}
|
||||||
|
if (actualExt.isNotBlank() && actualExt != outputExt) {
|
||||||
|
val actualFileName = buildSafFileName(req, actualExt)
|
||||||
|
val actualStagedFileName = if (useStagedOutput) {
|
||||||
|
buildStagedSafFileName(actualFileName)
|
||||||
|
} else {
|
||||||
|
actualFileName
|
||||||
|
}
|
||||||
|
val actualMimeType = mimeTypeForExt(actualExt)
|
||||||
|
val replacement = createOrReuseDocumentFile(
|
||||||
|
targetDir,
|
||||||
|
if (useStagedOutput) STAGED_SAF_MIME_TYPE else actualMimeType,
|
||||||
|
actualStagedFileName
|
||||||
|
) ?: throw IllegalStateException(
|
||||||
|
"failed to create SAF output with actual extension"
|
||||||
|
)
|
||||||
|
if (replacement.uri != document.uri) {
|
||||||
|
document.delete()
|
||||||
|
document = replacement
|
||||||
|
}
|
||||||
|
finalFileName = actualFileName
|
||||||
|
}
|
||||||
|
context.contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
|
||||||
|
srcFile.inputStream().use { input ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
syncOutputStream(output)
|
||||||
|
} ?: throw IllegalStateException("failed to open SAF output stream")
|
||||||
|
srcFile.delete()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
document.delete()
|
||||||
|
android.util.Log.w(
|
||||||
|
"SpotiFLAC",
|
||||||
|
"Failed to copy extension output to SAF: ${e.message}"
|
||||||
|
)
|
||||||
|
return errorJson("Failed to copy extension output to SAF: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
respObj.put("file_path", document.uri.toString())
|
||||||
|
respObj.put("file_name", document.name ?: fileName)
|
||||||
|
if (finalizerPromotesStaged) {
|
||||||
|
respObj.put("saf_staged_output", true)
|
||||||
|
respObj.put("saf_staged_file_name", document.name ?: stagedFileName)
|
||||||
|
} else if (useStagedOutput) {
|
||||||
|
// Legacy caller (foreground Dart queue): publish here by
|
||||||
|
// renaming the staged file to its final name.
|
||||||
|
val published = replaceFinalDocument(targetDir, document, finalFileName)
|
||||||
|
if (published == null) {
|
||||||
|
document.delete()
|
||||||
|
return errorJson("Failed to publish SAF download")
|
||||||
|
}
|
||||||
|
respObj.put("file_path", published.uri.toString())
|
||||||
|
respObj.put("file_name", published.name ?: finalFileName)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
document.delete()
|
||||||
|
}
|
||||||
|
return respObj.toString()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
document.delete()
|
||||||
|
return errorJson("SAF download failed: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
if (detachedFd == null) {
|
||||||
|
try {
|
||||||
|
pfd.close()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swaps [document] into place under [finalName] without a window where the
|
||||||
|
* previous file is gone but the new one is not yet in place. Any existing
|
||||||
|
* file is renamed aside first and only deleted once the staged file holds
|
||||||
|
* the final name; a failed swap restores it. Returns the published
|
||||||
|
* document, or null when the swap failed (the caller owns [document]).
|
||||||
|
*/
|
||||||
|
private fun replaceFinalDocument(
|
||||||
|
targetDir: DocumentFile,
|
||||||
|
document: DocumentFile,
|
||||||
|
finalName: String
|
||||||
|
): DocumentFile? {
|
||||||
|
val existingFinal = targetDir.findFile(finalName)
|
||||||
|
var aside: DocumentFile? = null
|
||||||
|
if (existingFinal != null && existingFinal.uri != document.uri) {
|
||||||
|
val asideName = buildReplacedSafFileName(finalName)
|
||||||
|
try {
|
||||||
|
targetDir.findFile(asideName)?.delete()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
if (!existingFinal.renameTo(asideName)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
aside = targetDir.findFile(asideName) ?: existingFinal
|
||||||
|
}
|
||||||
|
if (!document.renameTo(finalName)) {
|
||||||
|
aside?.renameTo(finalName)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
aside?.delete()
|
||||||
|
return targetDir.findFile(finalName) ?: document
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildReplacedSafFileName(fileName: String): String {
|
||||||
|
return "${sanitizeFilename(fileName)}.replaced"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun copyContentUriToTemp(context: Context, uriStr: String): String? {
|
||||||
|
return try {
|
||||||
|
val uri = Uri.parse(uriStr)
|
||||||
|
val extension = DocumentFile.fromSingleUri(context, uri)
|
||||||
|
?.name
|
||||||
|
?.substringAfterLast('.', "")
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { ".$it" }
|
||||||
|
?: ".tmp"
|
||||||
|
val temp = File.createTempFile("native_saf_", extension, context.cacheDir)
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||||
|
temp.outputStream().use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
} ?: return null
|
||||||
|
temp.absolutePath
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w("SpotiFLAC", "Failed to copy SAF URI to temp: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeFileToSaf(
|
||||||
|
context: Context,
|
||||||
|
treeUriStr: String,
|
||||||
|
relativeDir: String,
|
||||||
|
fileName: String,
|
||||||
|
mimeType: String,
|
||||||
|
srcPath: String
|
||||||
|
): String? {
|
||||||
|
val finalName = sanitizeFilename(fileName)
|
||||||
|
return withSafNameLock(treeUriStr, sanitizeRelativeDir(relativeDir), finalName) {
|
||||||
|
writeFileToSafLocked(context, treeUriStr, relativeDir, finalName, srcPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeFileToSafUnique(
|
||||||
|
context: Context,
|
||||||
|
treeUriStr: String,
|
||||||
|
relativeDir: String,
|
||||||
|
fileName: String,
|
||||||
|
mimeType: String,
|
||||||
|
srcPath: String,
|
||||||
|
preservedSuffix: String = "",
|
||||||
|
): UniqueWriteResult? {
|
||||||
|
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||||
|
val preferredName = sanitizeFilenamePreservingSuffix(fileName, preservedSuffix)
|
||||||
|
return withSafNameLock(treeUriStr, safeRelativeDir, preferredName) {
|
||||||
|
val treeUri = Uri.parse(treeUriStr)
|
||||||
|
val targetDir = ensureDocumentDir(context, treeUri, safeRelativeDir) ?: return@withSafNameLock null
|
||||||
|
val availableName = findAvailableFileName(
|
||||||
|
targetDir,
|
||||||
|
preferredName,
|
||||||
|
preservedSuffix,
|
||||||
|
)
|
||||||
|
val uri = writeFileToSafLocked(
|
||||||
|
context,
|
||||||
|
treeUriStr,
|
||||||
|
safeRelativeDir,
|
||||||
|
availableName,
|
||||||
|
srcPath,
|
||||||
|
) ?: return@withSafNameLock null
|
||||||
|
UniqueWriteResult(uri = uri, fileName = availableName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeFileToSafCollisionAware(
|
||||||
|
context: Context,
|
||||||
|
treeUriStr: String,
|
||||||
|
relativeDir: String,
|
||||||
|
cleanFileName: String,
|
||||||
|
variantFileName: String,
|
||||||
|
mimeType: String,
|
||||||
|
srcPath: String,
|
||||||
|
preservedSuffix: String = "",
|
||||||
|
): UniqueWriteResult? {
|
||||||
|
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||||
|
val cleanName = sanitizeFilename(cleanFileName)
|
||||||
|
val preferredVariant = sanitizeFilenamePreservingSuffix(
|
||||||
|
variantFileName,
|
||||||
|
preservedSuffix,
|
||||||
|
)
|
||||||
|
return withSafNameLock(treeUriStr, safeRelativeDir, cleanName) {
|
||||||
|
val treeUri = Uri.parse(treeUriStr)
|
||||||
|
val targetDir = ensureDocumentDir(context, treeUri, safeRelativeDir)
|
||||||
|
?: return@withSafNameLock null
|
||||||
|
val selectedName = if (targetDir.findFile(cleanName) == null) {
|
||||||
|
cleanName
|
||||||
|
} else {
|
||||||
|
findAvailableFileName(targetDir, preferredVariant, preservedSuffix)
|
||||||
|
}
|
||||||
|
val uri = writeFileToSafLocked(
|
||||||
|
context,
|
||||||
|
treeUriStr,
|
||||||
|
safeRelativeDir,
|
||||||
|
selectedName,
|
||||||
|
srcPath,
|
||||||
|
) ?: return@withSafNameLock null
|
||||||
|
UniqueWriteResult(uri = uri, fileName = selectedName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeFileToSafIfAbsent(
|
||||||
|
context: Context,
|
||||||
|
treeUriStr: String,
|
||||||
|
relativeDir: String,
|
||||||
|
fileName: String,
|
||||||
|
mimeType: String,
|
||||||
|
srcPath: String,
|
||||||
|
): ExistingAwareWriteResult? {
|
||||||
|
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||||
|
val finalName = sanitizeFilename(fileName)
|
||||||
|
return withSafNameLock(treeUriStr, safeRelativeDir, finalName) {
|
||||||
|
val treeUri = Uri.parse(treeUriStr)
|
||||||
|
val targetDir = ensureDocumentDir(context, treeUri, safeRelativeDir)
|
||||||
|
?: return@withSafNameLock null
|
||||||
|
val existing = targetDir.findFile(finalName)
|
||||||
|
if (existing != null && existing.isFile && existing.length() > 0L) {
|
||||||
|
return@withSafNameLock ExistingAwareWriteResult(
|
||||||
|
uri = existing.uri.toString(),
|
||||||
|
fileName = existing.name ?: finalName,
|
||||||
|
alreadyExists = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val uri = writeFileToSafLocked(
|
||||||
|
context,
|
||||||
|
treeUriStr,
|
||||||
|
safeRelativeDir,
|
||||||
|
finalName,
|
||||||
|
srcPath,
|
||||||
|
) ?: return@withSafNameLock null
|
||||||
|
ExistingAwareWriteResult(
|
||||||
|
uri = uri,
|
||||||
|
fileName = finalName,
|
||||||
|
alreadyExists = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sanitizeFilenamePreservingSuffix(fileName: String, suffix: String): String {
|
||||||
|
val sanitized = sanitizeFilename(fileName)
|
||||||
|
val trimmedSuffix = suffix.trim()
|
||||||
|
if (trimmedSuffix.isEmpty() || sanitized.contains(trimmedSuffix)) return sanitized
|
||||||
|
|
||||||
|
val dotIndex = fileName.lastIndexOf('.')
|
||||||
|
val hasExtension = dotIndex > 0 && dotIndex < fileName.length - 1
|
||||||
|
val extension = if (hasExtension) fileName.substring(dotIndex) else ""
|
||||||
|
val rawStem = if (hasExtension) fileName.substring(0, dotIndex) else fileName
|
||||||
|
val rawPrefix = rawStem.replace(trimmedSuffix, "").trim(' ', '_', '-')
|
||||||
|
val safeSuffix = sanitizeFilename(trimmedSuffix)
|
||||||
|
val reserved = " - $safeSuffix$extension"
|
||||||
|
val prefixBytes = (MAX_SAF_DISPLAY_NAME_UTF8_BYTES - reserved.toByteArray(Charsets.UTF_8).size)
|
||||||
|
.coerceAtLeast(1)
|
||||||
|
val safePrefix = truncateUtf8Bytes(sanitizeFilename(rawPrefix), prefixBytes)
|
||||||
|
.trim()
|
||||||
|
.trim('.', ' ', '_', '-')
|
||||||
|
.ifBlank { "track" }
|
||||||
|
return "$safePrefix$reserved"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findAvailableFileName(
|
||||||
|
parent: DocumentFile,
|
||||||
|
preferredName: String,
|
||||||
|
preservedSuffix: String,
|
||||||
|
): String {
|
||||||
|
if (parent.findFile(preferredName) == null) return preferredName
|
||||||
|
for (counter in 2..9999) {
|
||||||
|
val candidate = appendFilenameCounter(
|
||||||
|
preferredName,
|
||||||
|
counter.toLong(),
|
||||||
|
preservedSuffix,
|
||||||
|
)
|
||||||
|
if (parent.findFile(candidate) == null) return candidate
|
||||||
|
}
|
||||||
|
return appendFilenameCounter(
|
||||||
|
preferredName,
|
||||||
|
System.currentTimeMillis(),
|
||||||
|
preservedSuffix,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun appendFilenameCounter(
|
||||||
|
fileName: String,
|
||||||
|
counter: Long,
|
||||||
|
preservedSuffix: String,
|
||||||
|
): String {
|
||||||
|
val dotIndex = fileName.lastIndexOf('.')
|
||||||
|
val hasExtension = dotIndex > 0 && dotIndex < fileName.length - 1
|
||||||
|
val extension = if (hasExtension) fileName.substring(dotIndex) else ""
|
||||||
|
val originalStem = if (hasExtension) fileName.substring(0, dotIndex) else fileName
|
||||||
|
val safePreservedSuffix = preservedSuffix.trim()
|
||||||
|
val hasPreservedSuffix = safePreservedSuffix.isNotEmpty() && originalStem.contains(safePreservedSuffix)
|
||||||
|
val stem = if (hasPreservedSuffix) {
|
||||||
|
originalStem.replace(safePreservedSuffix, "").trim(' ', '_', '-')
|
||||||
|
} else {
|
||||||
|
originalStem
|
||||||
|
}
|
||||||
|
val suffix = if (hasPreservedSuffix) {
|
||||||
|
" - $safePreservedSuffix ($counter)"
|
||||||
|
} else {
|
||||||
|
" ($counter)"
|
||||||
|
}
|
||||||
|
val reservedBytes = extension.toByteArray(Charsets.UTF_8).size +
|
||||||
|
suffix.toByteArray(Charsets.UTF_8).size
|
||||||
|
val maxStemBytes = (MAX_SAF_DISPLAY_NAME_UTF8_BYTES - reservedBytes).coerceAtLeast(1)
|
||||||
|
val safeStem = truncateUtf8Bytes(stem, maxStemBytes).trim().trim('.', ' ').ifBlank { "track" }
|
||||||
|
return "$safeStem$suffix$extension"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeFileToSafLocked(
|
||||||
|
context: Context,
|
||||||
|
treeUriStr: String,
|
||||||
|
relativeDir: String,
|
||||||
|
finalName: String,
|
||||||
|
srcPath: String
|
||||||
|
): String? {
|
||||||
|
var stagedDocument: DocumentFile? = null
|
||||||
|
return try {
|
||||||
|
val treeUri = Uri.parse(treeUriStr)
|
||||||
|
val targetDir = ensureDocumentDir(context, treeUri, relativeDir) ?: return null
|
||||||
|
val ext = normalizeExt(finalName.substringAfterLast('.', ""))
|
||||||
|
val stagedName = buildStagedSafFileName(finalName)
|
||||||
|
deleteStaleStagedFiles(targetDir, finalName, ext)
|
||||||
|
val document = createOrReuseDocumentFile(targetDir, STAGED_SAF_MIME_TYPE, stagedName)
|
||||||
|
?: return null
|
||||||
|
stagedDocument = document
|
||||||
|
val outputStream = context.contentResolver.openOutputStream(document.uri, "wt")
|
||||||
|
if (outputStream == null) {
|
||||||
|
document.delete()
|
||||||
|
stagedDocument = null
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
outputStream.use { output ->
|
||||||
|
File(srcPath).inputStream().use { input ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
syncOutputStream(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
val published = replaceFinalDocument(targetDir, document, finalName)
|
||||||
|
if (published == null) {
|
||||||
|
document.delete()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
stagedDocument = null
|
||||||
|
published.uri.toString()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
stagedDocument?.delete()
|
||||||
|
android.util.Log.w("SpotiFLAC", "Failed to write file to SAF: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteContentUri(context: Context, uriStr: String): Boolean {
|
||||||
|
return try {
|
||||||
|
DocumentFile.fromSingleUri(context, Uri.parse(uriStr))?.delete() == true
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun normalizeExt(ext: String?): String {
|
||||||
|
val trimmed = ext?.trim().orEmpty()
|
||||||
|
if (trimmed.isEmpty()) return ""
|
||||||
|
return if (trimmed.startsWith(".")) trimmed.lowercase(Locale.ROOT) else ".${trimmed.lowercase(Locale.ROOT)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun mimeTypeForExt(ext: String?): String {
|
||||||
|
return when (normalizeExt(ext)) {
|
||||||
|
".m4a", ".mp4" -> "audio/mp4"
|
||||||
|
".mp3" -> "audio/mpeg"
|
||||||
|
".opus", ".ogg" -> "audio/ogg"
|
||||||
|
".flac" -> "audio/flac"
|
||||||
|
".wav" -> "audio/wav"
|
||||||
|
".aiff", ".aif", ".aifc" -> "audio/aiff"
|
||||||
|
".lrc" -> "application/octet-stream"
|
||||||
|
else -> "application/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun forceFilenameExt(name: String, outputExt: String): String {
|
||||||
|
val normalizedExt = normalizeExt(outputExt)
|
||||||
|
if (normalizedExt.isBlank()) return sanitizeFilename(name)
|
||||||
|
|
||||||
|
val safeName = sanitizeFilename(name)
|
||||||
|
val lower = safeName.lowercase(Locale.ROOT)
|
||||||
|
val knownExts = listOf(".flac", ".m4a", ".mp4", ".mp3", ".opus", ".lrc")
|
||||||
|
for (knownExt in knownExts) {
|
||||||
|
if (lower.endsWith(knownExt)) {
|
||||||
|
return safeName.dropLast(knownExt.length) + normalizedExt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return safeName + normalizedExt
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildStagedSafFileName(fileName: String): String {
|
||||||
|
val safeName = sanitizeFilename(fileName)
|
||||||
|
return "$safeName.partial"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildLegacyStagedSafFileName(fileName: String, outputExt: String): String {
|
||||||
|
val safeName = sanitizeFilename(fileName)
|
||||||
|
val ext = normalizeExt(outputExt)
|
||||||
|
if (ext.isNotBlank() && safeName.lowercase(Locale.ROOT).endsWith(ext)) {
|
||||||
|
return safeName.dropLast(ext.length).trimEnd('.', ' ') + ".partial$ext"
|
||||||
|
}
|
||||||
|
val dot = safeName.lastIndexOf('.')
|
||||||
|
if (dot > 0 && dot < safeName.lastIndex) {
|
||||||
|
return safeName.substring(0, dot).trimEnd('.', ' ') +
|
||||||
|
".partial" +
|
||||||
|
safeName.substring(dot)
|
||||||
|
}
|
||||||
|
return "$safeName.partial"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteStaleStagedFiles(parent: DocumentFile, fileName: String, outputExt: String) {
|
||||||
|
val stagedNames = linkedSetOf(
|
||||||
|
buildStagedSafFileName(fileName),
|
||||||
|
buildLegacyStagedSafFileName(fileName, outputExt),
|
||||||
|
buildReplacedSafFileName(fileName)
|
||||||
|
)
|
||||||
|
for (stagedName in stagedNames) {
|
||||||
|
try {
|
||||||
|
parent.findFile(stagedName)?.delete()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun sanitizeFilename(name: String): String {
|
||||||
|
var sanitized = name
|
||||||
|
.replace("/", " ")
|
||||||
|
.replace(Regex("[\\\\:*?\"<>|]"), " ")
|
||||||
|
.filter { ch ->
|
||||||
|
val code = ch.code
|
||||||
|
!((code < 0x20 && ch != '\t' && ch != '\n' && ch != '\r') ||
|
||||||
|
code == 0x7F ||
|
||||||
|
(Character.isISOControl(ch) && ch != '\t' && ch != '\n' && ch != '\r'))
|
||||||
|
}
|
||||||
|
.trim()
|
||||||
|
.trim('.', ' ')
|
||||||
|
|
||||||
|
sanitized = sanitized
|
||||||
|
.replace(Regex("\\s+"), " ")
|
||||||
|
.replace(Regex("_+"), "_")
|
||||||
|
.trim('_', ' ')
|
||||||
|
|
||||||
|
sanitized = truncateSafDisplayName(sanitized, MAX_SAF_DISPLAY_NAME_UTF8_BYTES)
|
||||||
|
sanitized = sanitized.trim().trim('.', ' ').trim('_', ' ')
|
||||||
|
return if (sanitized.isBlank()) "Unknown" else sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun truncateSafDisplayName(name: String, maxBytes: Int): String {
|
||||||
|
if (maxBytes <= 0 || name.toByteArray(Charsets.UTF_8).size <= maxBytes) return name
|
||||||
|
|
||||||
|
val dotIndex = name.lastIndexOf('.')
|
||||||
|
val ext = if (
|
||||||
|
dotIndex > 0 &&
|
||||||
|
dotIndex < name.length - 1 &&
|
||||||
|
name.length - dotIndex <= 10
|
||||||
|
) {
|
||||||
|
name.substring(dotIndex)
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
val stem = if (ext.isNotEmpty()) name.substring(0, dotIndex) else name
|
||||||
|
val maxStemBytes = (maxBytes - ext.toByteArray(Charsets.UTF_8).size).coerceAtLeast(1)
|
||||||
|
return truncateUtf8Bytes(stem, maxStemBytes).trim().trim('.', ' ').trim('_', ' ') + ext
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun truncateUtf8Bytes(value: String, maxBytes: Int): String {
|
||||||
|
if (maxBytes <= 0 || value.toByteArray(Charsets.UTF_8).size <= maxBytes) return value
|
||||||
|
|
||||||
|
val builder = StringBuilder()
|
||||||
|
var usedBytes = 0
|
||||||
|
var index = 0
|
||||||
|
while (index < value.length) {
|
||||||
|
val codePoint = value.codePointAt(index)
|
||||||
|
val char = String(Character.toChars(codePoint))
|
||||||
|
val charBytes = char.toByteArray(Charsets.UTF_8).size
|
||||||
|
if (usedBytes + charBytes > maxBytes) break
|
||||||
|
builder.append(char)
|
||||||
|
usedBytes += charBytes
|
||||||
|
index += Character.charCount(codePoint)
|
||||||
|
}
|
||||||
|
return builder.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun sanitizeRelativeDir(relativeDir: String): String {
|
||||||
|
if (relativeDir.isBlank()) return ""
|
||||||
|
return relativeDir
|
||||||
|
.split("/")
|
||||||
|
.map { sanitizeFilename(it) }
|
||||||
|
.filter { it.isNotBlank() && it != "." && it != ".." }
|
||||||
|
.joinToString("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun ensureDocumentDir(
|
||||||
|
context: Context,
|
||||||
|
treeUri: Uri,
|
||||||
|
relativeDir: String
|
||||||
|
): DocumentFile? {
|
||||||
|
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||||
|
if (safeRelativeDir.isBlank()) {
|
||||||
|
return DocumentFile.fromTreeUri(context, treeUri)
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized(safDirLock) {
|
||||||
|
var current = DocumentFile.fromTreeUri(context, treeUri) ?: return null
|
||||||
|
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
|
||||||
|
for (part in parts) {
|
||||||
|
val existing = current.findFile(part)
|
||||||
|
current = if (existing != null && existing.isDirectory) {
|
||||||
|
existing
|
||||||
|
} else {
|
||||||
|
val created = current.createDirectory(part) ?: return null
|
||||||
|
val createdName = created.name ?: part
|
||||||
|
if (createdName != part) {
|
||||||
|
created.delete()
|
||||||
|
current.findFile(part) ?: return null
|
||||||
|
} else {
|
||||||
|
created
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun findDocumentDir(
|
||||||
|
context: Context,
|
||||||
|
treeUri: Uri,
|
||||||
|
relativeDir: String
|
||||||
|
): DocumentFile? {
|
||||||
|
var current = DocumentFile.fromTreeUri(context, treeUri) ?: return null
|
||||||
|
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||||
|
if (safeRelativeDir.isBlank()) return current
|
||||||
|
|
||||||
|
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
|
||||||
|
for (part in parts) {
|
||||||
|
val existing = current.findFile(part)
|
||||||
|
if (existing == null || !existing.isDirectory) return null
|
||||||
|
current = existing
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun createOrReuseDocumentFile(
|
||||||
|
parent: DocumentFile,
|
||||||
|
mimeType: String,
|
||||||
|
fileName: String
|
||||||
|
): DocumentFile? {
|
||||||
|
val safeFileName = sanitizeFilename(fileName)
|
||||||
|
if (safeFileName.isBlank()) return null
|
||||||
|
|
||||||
|
synchronized(safDirLock) {
|
||||||
|
val existing = parent.findFile(safeFileName)
|
||||||
|
if (existing != null && existing.isFile) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
val created = parent.createFile(mimeType, safeFileName) ?: return null
|
||||||
|
val createdName = created.name ?: safeFileName
|
||||||
|
if (createdName == safeFileName) {
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
val winner = parent.findFile(safeFileName)
|
||||||
|
if (winner != null && winner.isFile) {
|
||||||
|
if (winner.uri != created.uri) {
|
||||||
|
try {
|
||||||
|
created.delete()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return winner
|
||||||
|
}
|
||||||
|
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildSafFileName(req: JSONObject, outputExt: String): String {
|
||||||
|
val provided = req.optString("saf_file_name", "")
|
||||||
|
if (provided.isNotBlank()) return forceFilenameExt(provided, outputExt)
|
||||||
|
|
||||||
|
val trackName = req.optString("track_name", "track")
|
||||||
|
val artistName = req.optString("artist_name", "")
|
||||||
|
val baseName = if (artistName.isNotBlank()) "$artistName - $trackName" else trackName
|
||||||
|
return forceFilenameExt(baseName, outputExt)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun errorJson(message: String): String {
|
||||||
|
val obj = JSONObject()
|
||||||
|
obj.put("success", false)
|
||||||
|
obj.put("error", message)
|
||||||
|
obj.put("message", message)
|
||||||
|
return obj.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
@@ -1,12 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Modify this file to customize your launch splash screen -->
|
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="?android:colorBackground" />
|
<item android:drawable="?android:colorBackground" />
|
||||||
|
|
||||||
<!-- You can insert your own image assets here -->
|
|
||||||
<!-- <item>
|
|
||||||
<bitmap
|
|
||||||
android:gravity="center"
|
|
||||||
android:src="@mipmap/launch_image" />
|
|
||||||
</item> -->
|
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -1,12 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Modify this file to customize your launch splash screen -->
|
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="@android:color/white" />
|
<item android:drawable="@android:color/white" />
|
||||||
|
|
||||||
<!-- You can insert your own image assets here -->
|
|
||||||
<!-- <item>
|
|
||||||
<bitmap
|
|
||||||
android:gravity="center"
|
|
||||||
android:src="@mipmap/launch_image" />
|
|
||||||
</item> -->
|
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -6,4 +6,9 @@
|
|||||||
android:drawable="@drawable/ic_launcher_foreground"
|
android:drawable="@drawable/ic_launcher_foreground"
|
||||||
android:inset="16%" />
|
android:inset="16%" />
|
||||||
</foreground>
|
</foreground>
|
||||||
|
<monochrome>
|
||||||
|
<inset
|
||||||
|
android:drawable="@drawable/ic_launcher_monochrome"
|
||||||
|
android:inset="16%" />
|
||||||
|
</monochrome>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 954 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 647 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -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>
|
||||||
@@ -1,17 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
|
||||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
<!-- Show a splash screen on the activity. Automatically removed when
|
|
||||||
the Flutter engine draws its first frame -->
|
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
</style>
|
</style>
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
|
||||||
This theme determines the color of the Android Window while your
|
|
||||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
|
||||||
running.
|
|
||||||
|
|
||||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="widgetBackground">@android:color/system_neutral1_10</color>
|
||||||
|
<color name="widgetTextPrimary">@android:color/system_neutral1_900</color>
|
||||||
|
<color name="widgetTextSecondary">@android:color/system_neutral2_700</color>
|
||||||
|
<color name="widgetAccent">@android:color/system_accent1_600</color>
|
||||||
|
</resources>
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#000000</color>
|
<color name="ic_launcher_background">#000000</color>
|
||||||
|
<color name="widgetBackground">#F3F3F3</color>
|
||||||
|
<color name="widgetTextPrimary">#1C1B1F</color>
|
||||||
|
<color name="widgetTextSecondary">#49454F</color>
|
||||||
|
<color name="widgetAccent">#1DB954</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,17 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
|
||||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
<!-- Show a splash screen on the activity. Automatically removed when
|
|
||||||
the Flutter engine draws its first frame -->
|
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
</style>
|
</style>
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
|
||||||
This theme determines the color of the Android Window while your
|
|
||||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
|
||||||
running.
|
|
||||||
|
|
||||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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") {
|
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
|
||||||
compileOptions {
|
compileOptions {
|
||||||
isCoreLibraryDesugaringEnabled = true
|
isCoreLibraryDesugaringEnabled = true
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_25
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_25
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable multidex for all subprojects
|
// Enable multidex for all subprojects
|
||||||
@@ -27,7 +27,7 @@ subprojects {
|
|||||||
|
|
||||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
|
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||||
|
android.builtInKotlin=false
|
||||||
|
# This newDsl flag was added automatically by Flutter migrator
|
||||||
|
android.newDsl=false
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-all.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
retries=0
|
||||||
|
retryBackOffMs=500
|
||||||
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
|
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.13.2" apply false
|
id("com.android.application") version "9.3.1" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.2.21" apply false
|
id("org.jetbrains.kotlin.android") version "2.4.10" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "SpotiFLAC Source",
|
"name": "SpotiFLAC Mobile Source",
|
||||||
"identifier": "com.zarzet.spotiflac.source",
|
"identifier": "com.zarzet.spotiflac.source",
|
||||||
"subtitle": "FLAC Downloader for iOS",
|
"subtitle": "FLAC Downloader for iOS",
|
||||||
"apps": [
|
"apps": [
|
||||||
{
|
{
|
||||||
"name": "SpotiFLAC",
|
"name": "SpotiFLAC Mobile",
|
||||||
"bundleIdentifier": "com.zarzet.spotiflac",
|
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||||
"developerName": "zarzet",
|
"developerName": "zarzet",
|
||||||
"version": "3.8.5",
|
"version": "4.8.0",
|
||||||
"versionDate": "2026-03-15",
|
"versionDate": "2026-07-25",
|
||||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.8.5/SpotiFLAC-v3.8.5-ios-unsigned.ipa",
|
"downloadURL": "https://github.com/spotiflacapp/SpotiFLAC-Mobile/releases/download/v4.8.0/SpotiFLAC-v4.8.0-ios-unsigned.ipa",
|
||||||
"localizedDescription": "Mobile version of SpotiFLAC written in Flutter. Download Tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
|
"localizedDescription": "SpotiFLAC Mobile is written in Flutter and uses installable extensions for metadata and audio downloads.",
|
||||||
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
"iconURL": "https://raw.githubusercontent.com/spotiflacapp/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
||||||
"size": 33673615
|
"size": 37939793
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 71 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
|
# Template for the changelog body
|
||||||
body = """
|
body = """
|
||||||
{%- macro remote_url() -%}
|
{%- macro remote_url() -%}
|
||||||
https://github.com/zarzet/SpotiFLAC-Mobile
|
https://github.com/spotiflacapp/SpotiFLAC-Mobile
|
||||||
{%- endmacro -%}
|
{%- endmacro -%}
|
||||||
|
|
||||||
{% if version %}\
|
{% if version %}\
|
||||||
@@ -99,5 +99,5 @@ tag_pattern = "v[0-9].*"
|
|||||||
sort_commits = "newest"
|
sort_commits = "newest"
|
||||||
|
|
||||||
[remote.github]
|
[remote.github]
|
||||||
owner = "zarzet"
|
owner = "spotiflacapp"
|
||||||
repo = "SpotiFLAC-Mobile"
|
repo = "SpotiFLAC-Mobile"
|
||||||
|
|||||||
@@ -3,17 +3,19 @@ files:
|
|||||||
translation: /lib/l10n/arb/app_%locale%.arb
|
translation: /lib/l10n/arb/app_%locale%.arb
|
||||||
languages_mapping:
|
languages_mapping:
|
||||||
locale:
|
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
|
de: de
|
||||||
es: es
|
es-ES: es_ES
|
||||||
fr: fr
|
fr: fr
|
||||||
hi: hi
|
|
||||||
id: id
|
id: id
|
||||||
ja: ja
|
ja: ja
|
||||||
ko: ko
|
ko: ko
|
||||||
nl: nl
|
pt-PT: pt_PT
|
||||||
pt: pt
|
|
||||||
ru: ru
|
ru: ru
|
||||||
# Full codes for Chinese variants
|
tr: tr
|
||||||
zh-CN: zh_CN
|
uk: uk
|
||||||
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// APEv2 tag format constants.
|
||||||
|
const (
|
||||||
|
apeTagPreamble = "APETAGEX"
|
||||||
|
apeTagHeaderSize = 32
|
||||||
|
apeTagVersion2 = 2000
|
||||||
|
apeTagFlagHeader = 1 << 29 // bit 29: this is the header, not the footer
|
||||||
|
apeTagFlagReadOnly = 1 << 0
|
||||||
|
// Item flags: bits 1-2 encode content type
|
||||||
|
// (00: UTF-8 text, 01: binary data, 10: external link)
|
||||||
|
apeItemFlagBinary = 1 << 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// APETagItem represents a single key-value item in an APEv2 tag.
|
||||||
|
type APETagItem struct {
|
||||||
|
Key string
|
||||||
|
Value string
|
||||||
|
Flags uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// APETag represents a complete APEv2 tag block.
|
||||||
|
type APETag struct {
|
||||||
|
Version uint32
|
||||||
|
Items []APETagItem
|
||||||
|
ReadOnly bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadAPETags reads APEv2 tags from a file.
|
||||||
|
// APEv2 tags are typically appended at the end of the file.
|
||||||
|
// The layout is: [audio data] [APEv2 header (optional)] [items...] [APEv2 footer]
|
||||||
|
// We locate the footer first (last 32 bytes), then read the tag block.
|
||||||
|
func ReadAPETags(filePath string) (*APETag, error) {
|
||||||
|
f, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open file: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
fi, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to stat file: %w", err)
|
||||||
|
}
|
||||||
|
fileSize := fi.Size()
|
||||||
|
|
||||||
|
if fileSize < apeTagHeaderSize {
|
||||||
|
return nil, fmt.Errorf("file too small for APE tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The footer is the last 32 bytes before any ID3v1 tag (128 bytes).
|
||||||
|
tag, err := readAPETagAtOffset(f, fileSize, fileSize-apeTagHeaderSize)
|
||||||
|
if err == nil {
|
||||||
|
return tag, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry: skip ID3v1 tag (128 bytes) if present
|
||||||
|
if fileSize > apeTagHeaderSize+128 {
|
||||||
|
tag, err = readAPETagAtOffset(f, fileSize, fileSize-apeTagHeaderSize-128)
|
||||||
|
if err == nil {
|
||||||
|
return tag, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no APEv2 tag found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func readAPETagAtOffset(f *os.File, fileSize, footerOffset int64) (*APETag, error) {
|
||||||
|
if footerOffset < 0 || footerOffset+apeTagHeaderSize > fileSize {
|
||||||
|
return nil, fmt.Errorf("invalid footer offset")
|
||||||
|
}
|
||||||
|
|
||||||
|
footer := make([]byte, apeTagHeaderSize)
|
||||||
|
if _, err := f.ReadAt(footer, footerOffset); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read APE footer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(footer[0:8]) != apeTagPreamble {
|
||||||
|
return nil, fmt.Errorf("APE preamble not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
version := binary.LittleEndian.Uint32(footer[8:12])
|
||||||
|
tagSize := binary.LittleEndian.Uint32(footer[12:16]) // size of items + footer (32 bytes)
|
||||||
|
itemCount := binary.LittleEndian.Uint32(footer[16:20])
|
||||||
|
flags := binary.LittleEndian.Uint32(footer[20:24])
|
||||||
|
|
||||||
|
if version != apeTagVersion2 && version != 1000 {
|
||||||
|
return nil, fmt.Errorf("unsupported APE tag version: %d", version)
|
||||||
|
}
|
||||||
|
if tagSize < apeTagHeaderSize {
|
||||||
|
return nil, fmt.Errorf("APE tag size too small: %d", tagSize)
|
||||||
|
}
|
||||||
|
if itemCount > 1000 {
|
||||||
|
return nil, fmt.Errorf("APE tag item count too large: %d", itemCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This should be the footer (bit 29 clear)
|
||||||
|
isHeader := (flags & apeTagFlagHeader) != 0
|
||||||
|
if isHeader {
|
||||||
|
return nil, fmt.Errorf("expected APE footer but found header")
|
||||||
|
}
|
||||||
|
|
||||||
|
// tagSize includes items + footer (32 bytes), but NOT the header.
|
||||||
|
itemsSize := int64(tagSize) - apeTagHeaderSize
|
||||||
|
if itemsSize < 0 {
|
||||||
|
return nil, fmt.Errorf("invalid APE tag: items size negative")
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsOffset := footerOffset - itemsSize
|
||||||
|
if itemsOffset < 0 {
|
||||||
|
return nil, fmt.Errorf("APE tag items extend before file start")
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsData := make([]byte, itemsSize)
|
||||||
|
if _, err := f.ReadAt(itemsData, itemsOffset); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read APE items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := parseAPEItems(itemsData, int(itemCount))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse APE items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &APETag{
|
||||||
|
Version: version,
|
||||||
|
Items: items,
|
||||||
|
ReadOnly: (flags & apeTagFlagReadOnly) != 0,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAPEItems(data []byte, count int) ([]APETagItem, error) {
|
||||||
|
items := make([]APETagItem, 0, count)
|
||||||
|
pos := 0
|
||||||
|
|
||||||
|
for i := 0; i < count && pos < len(data); i++ {
|
||||||
|
if pos+8 > len(data) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
valueSize := int(binary.LittleEndian.Uint32(data[pos : pos+4]))
|
||||||
|
itemFlags := binary.LittleEndian.Uint32(data[pos+4 : pos+8])
|
||||||
|
pos += 8
|
||||||
|
|
||||||
|
// Key is null-terminated ASCII (2-255 bytes, case-insensitive)
|
||||||
|
keyEnd := pos
|
||||||
|
for keyEnd < len(data) && data[keyEnd] != 0 {
|
||||||
|
keyEnd++
|
||||||
|
}
|
||||||
|
if keyEnd >= len(data) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
key := string(data[pos:keyEnd])
|
||||||
|
pos = keyEnd + 1
|
||||||
|
|
||||||
|
if pos+valueSize > len(data) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
value := string(data[pos : pos+valueSize])
|
||||||
|
pos += valueSize
|
||||||
|
|
||||||
|
items = append(items, APETagItem{
|
||||||
|
Key: key,
|
||||||
|
Value: value,
|
||||||
|
Flags: itemFlags,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteAPETags writes APEv2 tags to the end of a file.
|
||||||
|
// If the file already has APEv2 tags, they are replaced.
|
||||||
|
// The tag is written with both header and footer.
|
||||||
|
func WriteAPETags(filePath string, tag *APETag) error {
|
||||||
|
existingSize, err := findExistingAPETagSize(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check existing APE tag: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tagData, err := marshalAPETag(tag)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal APE tag: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if existingSize > 0 {
|
||||||
|
fi, err := os.Stat(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to stat file: %w", err)
|
||||||
|
}
|
||||||
|
newSize := fi.Size() - int64(existingSize)
|
||||||
|
if err := os.Truncate(filePath, newSize); err != nil {
|
||||||
|
return fmt.Errorf("failed to truncate existing APE tag: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.OpenFile(filePath, os.O_WRONLY|os.O_APPEND, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open file for writing: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
if _, err := f.Write(tagData); err != nil {
|
||||||
|
return fmt.Errorf("failed to write APE tag: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findExistingAPETagSize returns the total size of an existing APE tag
|
||||||
|
// (header + items + footer) at the end of the file, or 0 if none exists.
|
||||||
|
func findExistingAPETagSize(filePath string) (int64, error) {
|
||||||
|
f, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
fi, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
fileSize := fi.Size()
|
||||||
|
|
||||||
|
offsets := []int64{fileSize - apeTagHeaderSize}
|
||||||
|
if fileSize > apeTagHeaderSize+128 {
|
||||||
|
offsets = append(offsets, fileSize-apeTagHeaderSize-128)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, offset := range offsets {
|
||||||
|
if offset < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
footer := make([]byte, apeTagHeaderSize)
|
||||||
|
if _, err := f.ReadAt(footer, offset); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if string(footer[0:8]) != apeTagPreamble {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
flags := binary.LittleEndian.Uint32(footer[20:24])
|
||||||
|
if (flags & apeTagFlagHeader) != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tagSize := int64(binary.LittleEndian.Uint32(footer[12:16]))
|
||||||
|
|
||||||
|
hasHeader := (flags & (1 << 31)) != 0 // bit 31 = tag contains header
|
||||||
|
totalSize := tagSize
|
||||||
|
if hasHeader {
|
||||||
|
totalSize += apeTagHeaderSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include any trailing data after the footer (e.g. ID3v1 128-byte tag).
|
||||||
|
// When truncating, we must remove the APE tag AND everything after it.
|
||||||
|
trailingBytes := fileSize - (offset + apeTagHeaderSize)
|
||||||
|
totalSize += trailingBytes
|
||||||
|
|
||||||
|
return totalSize, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// marshalAPETag serializes an APETag into bytes (header + items + footer).
|
||||||
|
func marshalAPETag(tag *APETag) ([]byte, error) {
|
||||||
|
if tag == nil || len(tag.Items) == 0 {
|
||||||
|
return nil, fmt.Errorf("empty APE tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
var itemsData []byte
|
||||||
|
for _, item := range tag.Items {
|
||||||
|
keyBytes := []byte(item.Key)
|
||||||
|
valueBytes := []byte(item.Value)
|
||||||
|
|
||||||
|
// 4 bytes: value size (LE)
|
||||||
|
sizeBuf := make([]byte, 4)
|
||||||
|
binary.LittleEndian.PutUint32(sizeBuf, uint32(len(valueBytes)))
|
||||||
|
|
||||||
|
// 4 bytes: item flags (LE)
|
||||||
|
flagsBuf := make([]byte, 4)
|
||||||
|
binary.LittleEndian.PutUint32(flagsBuf, item.Flags)
|
||||||
|
|
||||||
|
itemsData = append(itemsData, sizeBuf...)
|
||||||
|
itemsData = append(itemsData, flagsBuf...)
|
||||||
|
itemsData = append(itemsData, keyBytes...)
|
||||||
|
itemsData = append(itemsData, 0)
|
||||||
|
itemsData = append(itemsData, valueBytes...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tagSize = items data + footer (32 bytes)
|
||||||
|
tagSize := uint32(len(itemsData) + apeTagHeaderSize)
|
||||||
|
itemCount := uint32(len(tag.Items))
|
||||||
|
|
||||||
|
version := uint32(apeTagVersion2)
|
||||||
|
if tag.Version != 0 {
|
||||||
|
version = tag.Version
|
||||||
|
}
|
||||||
|
|
||||||
|
// flags: bit 29 = 1 (is header), bit 31 = 1 (contains header)
|
||||||
|
headerFlags := uint32(apeTagFlagHeader | (1 << 31))
|
||||||
|
header := buildAPEHeaderFooter(version, tagSize, itemCount, headerFlags)
|
||||||
|
|
||||||
|
// flags: bit 29 = 0 (is footer), bit 31 = 1 (contains header)
|
||||||
|
footerFlags := uint32(1 << 31)
|
||||||
|
footer := buildAPEHeaderFooter(version, tagSize, itemCount, footerFlags)
|
||||||
|
|
||||||
|
result := make([]byte, 0, len(header)+len(itemsData)+len(footer))
|
||||||
|
result = append(result, header...)
|
||||||
|
result = append(result, itemsData...)
|
||||||
|
result = append(result, footer...)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAPEHeaderFooter(version, tagSize, itemCount, flags uint32) []byte {
|
||||||
|
buf := make([]byte, apeTagHeaderSize)
|
||||||
|
copy(buf[0:8], apeTagPreamble)
|
||||||
|
binary.LittleEndian.PutUint32(buf[8:12], version)
|
||||||
|
binary.LittleEndian.PutUint32(buf[12:16], tagSize)
|
||||||
|
binary.LittleEndian.PutUint32(buf[16:20], itemCount)
|
||||||
|
binary.LittleEndian.PutUint32(buf[20:24], flags)
|
||||||
|
// bytes 24-31 are reserved (zeros)
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
// APETagToAudioMetadata converts an APETag to our unified AudioMetadata struct.
|
||||||
|
func APETagToAudioMetadata(tag *APETag) *AudioMetadata {
|
||||||
|
if tag == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := &AudioMetadata{}
|
||||||
|
for _, item := range tag.Items {
|
||||||
|
key := strings.ToUpper(strings.TrimSpace(item.Key))
|
||||||
|
value := strings.TrimSpace(item.Value)
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch key {
|
||||||
|
case "TITLE":
|
||||||
|
metadata.Title = value
|
||||||
|
case "ARTIST":
|
||||||
|
metadata.Artist = value
|
||||||
|
case "ALBUM":
|
||||||
|
metadata.Album = value
|
||||||
|
case "ALBUMARTIST", "ALBUM ARTIST":
|
||||||
|
metadata.AlbumArtist = value
|
||||||
|
case "GENRE":
|
||||||
|
metadata.Genre = value
|
||||||
|
case "YEAR":
|
||||||
|
metadata.Year = value
|
||||||
|
case "DATE":
|
||||||
|
metadata.Date = value
|
||||||
|
case "TRACK", "TRACKNUMBER":
|
||||||
|
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||||
|
case "DISC", "DISCNUMBER":
|
||||||
|
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||||
|
case "ISRC":
|
||||||
|
metadata.ISRC = value
|
||||||
|
case "LYRICS", "UNSYNCEDLYRICS":
|
||||||
|
if metadata.Lyrics == "" {
|
||||||
|
metadata.Lyrics = value
|
||||||
|
}
|
||||||
|
case "LABEL", "PUBLISHER":
|
||||||
|
metadata.Label = value
|
||||||
|
case "COPYRIGHT":
|
||||||
|
metadata.Copyright = value
|
||||||
|
case "COMPOSER":
|
||||||
|
metadata.Composer = value
|
||||||
|
case "COMMENT":
|
||||||
|
metadata.Comment = value
|
||||||
|
case "REPLAYGAIN_TRACK_GAIN":
|
||||||
|
metadata.ReplayGainTrackGain = value
|
||||||
|
case "REPLAYGAIN_TRACK_PEAK":
|
||||||
|
metadata.ReplayGainTrackPeak = value
|
||||||
|
case "REPLAYGAIN_ALBUM_GAIN":
|
||||||
|
metadata.ReplayGainAlbumGain = value
|
||||||
|
case "REPLAYGAIN_ALBUM_PEAK":
|
||||||
|
metadata.ReplayGainAlbumPeak = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioMetadataToAPEItems converts metadata fields to APE tag items.
|
||||||
|
func AudioMetadataToAPEItems(metadata *AudioMetadata) []APETagItem {
|
||||||
|
if metadata == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []APETagItem
|
||||||
|
addItem := func(key, value string) {
|
||||||
|
if value != "" {
|
||||||
|
items = append(items, APETagItem{Key: key, Value: value})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addItem("Title", metadata.Title)
|
||||||
|
addItem("Artist", metadata.Artist)
|
||||||
|
addItem("Album", metadata.Album)
|
||||||
|
addItem("Album Artist", metadata.AlbumArtist)
|
||||||
|
addItem("Genre", metadata.Genre)
|
||||||
|
if metadata.Date != "" {
|
||||||
|
addItem("Year", metadata.Date)
|
||||||
|
} else if metadata.Year != "" {
|
||||||
|
addItem("Year", metadata.Year)
|
||||||
|
}
|
||||||
|
if metadata.TrackNumber > 0 {
|
||||||
|
addItem("Track", formatIndexValue(metadata.TrackNumber, metadata.TotalTracks))
|
||||||
|
}
|
||||||
|
if metadata.DiscNumber > 0 {
|
||||||
|
addItem("Disc", formatIndexValue(metadata.DiscNumber, metadata.TotalDiscs))
|
||||||
|
}
|
||||||
|
addItem("ISRC", metadata.ISRC)
|
||||||
|
addItem("Lyrics", metadata.Lyrics)
|
||||||
|
addItem("Label", metadata.Label)
|
||||||
|
addItem("Copyright", metadata.Copyright)
|
||||||
|
addItem("Composer", metadata.Composer)
|
||||||
|
addItem("Comment", metadata.Comment)
|
||||||
|
addItem("REPLAYGAIN_TRACK_GAIN", metadata.ReplayGainTrackGain)
|
||||||
|
addItem("REPLAYGAIN_TRACK_PEAK", metadata.ReplayGainTrackPeak)
|
||||||
|
addItem("REPLAYGAIN_ALBUM_GAIN", metadata.ReplayGainAlbumGain)
|
||||||
|
addItem("REPLAYGAIN_ALBUM_PEAK", metadata.ReplayGainAlbumPeak)
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
// apeKeysFromFields builds a set of upper-case APE tag keys corresponding to
|
||||||
|
// the metadata fields map sent by the editor. This is used during merge to
|
||||||
|
// ensure that even empty (cleared) fields override old values.
|
||||||
|
func apeKeysFromFields(fields map[string]string) map[string]struct{} {
|
||||||
|
mapping := map[string]string{
|
||||||
|
"title": "TITLE",
|
||||||
|
"artist": "ARTIST",
|
||||||
|
"album": "ALBUM",
|
||||||
|
"album_artist": "ALBUM ARTIST",
|
||||||
|
"date": "DATE",
|
||||||
|
"genre": "GENRE",
|
||||||
|
"track_number": "TRACK",
|
||||||
|
"disc_number": "DISC",
|
||||||
|
"isrc": "ISRC",
|
||||||
|
"lyrics": "LYRICS",
|
||||||
|
"label": "LABEL",
|
||||||
|
"copyright": "COPYRIGHT",
|
||||||
|
"composer": "COMPOSER",
|
||||||
|
"comment": "COMMENT",
|
||||||
|
"replaygain_track_gain": "REPLAYGAIN_TRACK_GAIN",
|
||||||
|
"replaygain_track_peak": "REPLAYGAIN_TRACK_PEAK",
|
||||||
|
"replaygain_album_gain": "REPLAYGAIN_ALBUM_GAIN",
|
||||||
|
"replaygain_album_peak": "REPLAYGAIN_ALBUM_PEAK",
|
||||||
|
}
|
||||||
|
result := make(map[string]struct{})
|
||||||
|
for fk, apeKey := range mapping {
|
||||||
|
if _, present := fields[fk]; present {
|
||||||
|
result[strings.ToUpper(apeKey)] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Some fields have reader aliases that must also be cleared when the
|
||||||
|
// canonical key is updated (e.g. DATE writer ↔ DATE/YEAR reader,
|
||||||
|
// DISC ↔ DISCNUMBER, TRACK ↔ TRACKNUMBER, "ALBUM ARTIST" ↔ ALBUMARTIST,
|
||||||
|
// LABEL ↔ PUBLISHER, LYRICS ↔ UNSYNCEDLYRICS).
|
||||||
|
if _, present := fields["date"]; present {
|
||||||
|
result["DATE"] = struct{}{}
|
||||||
|
}
|
||||||
|
if _, present := fields["disc_number"]; present {
|
||||||
|
result["DISCNUMBER"] = struct{}{}
|
||||||
|
}
|
||||||
|
if _, present := fields["disc_total"]; present {
|
||||||
|
result["DISCNUMBER"] = struct{}{}
|
||||||
|
}
|
||||||
|
if _, present := fields["track_number"]; present {
|
||||||
|
result["TRACKNUMBER"] = struct{}{}
|
||||||
|
}
|
||||||
|
if _, present := fields["track_total"]; present {
|
||||||
|
result["TRACKNUMBER"] = struct{}{}
|
||||||
|
}
|
||||||
|
if _, present := fields["album_artist"]; present {
|
||||||
|
result["ALBUMARTIST"] = struct{}{}
|
||||||
|
}
|
||||||
|
if _, present := fields["label"]; present {
|
||||||
|
result["PUBLISHER"] = struct{}{}
|
||||||
|
}
|
||||||
|
if _, present := fields["lyrics"]; present {
|
||||||
|
result["UNSYNCEDLYRICS"] = struct{}{}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeAPEItems overlays newItems on top of existing items.
|
||||||
|
// For each new item, if a matching key exists (case-insensitive) in existing,
|
||||||
|
// it is replaced. New keys are appended. Existing items whose keys are NOT
|
||||||
|
// in newItems are preserved (cover art, ReplayGain, custom tags, etc.).
|
||||||
|
//
|
||||||
|
// overrideKeys is an optional set of upper-case keys that should be removed
|
||||||
|
// from existing even if they do not appear in newItems. This handles field
|
||||||
|
// deletion: the caller sends an empty value which is not serialized into
|
||||||
|
// newItems, but the old value must still be dropped.
|
||||||
|
func MergeAPEItems(existing, newItems []APETagItem, overrideKeys map[string]struct{}) []APETagItem {
|
||||||
|
combined := make(map[string]struct{}, len(newItems)+len(overrideKeys))
|
||||||
|
for k := range overrideKeys {
|
||||||
|
combined[strings.ToUpper(k)] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, item := range newItems {
|
||||||
|
combined[strings.ToUpper(item.Key)] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var merged []APETagItem
|
||||||
|
for _, item := range existing {
|
||||||
|
if _, overwritten := combined[strings.ToUpper(item.Key)]; !overwritten {
|
||||||
|
merged = append(merged, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merged = append(merged, newItems...)
|
||||||
|
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadAPETagsFromReader reads APEv2 tags from an io.ReaderAt + size.
|
||||||
|
// This is useful for reading APE tags from files opened via SAF or other abstractions.
|
||||||
|
func ReadAPETagsFromReader(r io.ReaderAt, fileSize int64) (*APETag, error) {
|
||||||
|
if fileSize < apeTagHeaderSize {
|
||||||
|
return nil, fmt.Errorf("file too small for APE tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
footer := make([]byte, apeTagHeaderSize)
|
||||||
|
if _, err := r.ReadAt(footer, fileSize-apeTagHeaderSize); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read APE footer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(footer[0:8]) == apeTagPreamble {
|
||||||
|
tag, err := parseAPETagFromFooter(r, fileSize, fileSize-apeTagHeaderSize, footer)
|
||||||
|
if err == nil {
|
||||||
|
return tag, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry: skip ID3v1 tag (128 bytes)
|
||||||
|
if fileSize > apeTagHeaderSize+128 {
|
||||||
|
offset := fileSize - apeTagHeaderSize - 128
|
||||||
|
if _, err := r.ReadAt(footer, offset); err == nil {
|
||||||
|
if string(footer[0:8]) == apeTagPreamble {
|
||||||
|
tag, err := parseAPETagFromFooter(r, fileSize, offset, footer)
|
||||||
|
if err == nil {
|
||||||
|
return tag, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no APEv2 tag found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAPETagFromFooter(r io.ReaderAt, _, footerOffset int64, footer []byte) (*APETag, error) {
|
||||||
|
version := binary.LittleEndian.Uint32(footer[8:12])
|
||||||
|
tagSize := binary.LittleEndian.Uint32(footer[12:16])
|
||||||
|
itemCount := binary.LittleEndian.Uint32(footer[16:20])
|
||||||
|
flags := binary.LittleEndian.Uint32(footer[20:24])
|
||||||
|
|
||||||
|
if version != apeTagVersion2 && version != 1000 {
|
||||||
|
return nil, fmt.Errorf("unsupported APE tag version: %d", version)
|
||||||
|
}
|
||||||
|
if tagSize < apeTagHeaderSize {
|
||||||
|
return nil, fmt.Errorf("APE tag size too small: %d", tagSize)
|
||||||
|
}
|
||||||
|
if itemCount > 1000 {
|
||||||
|
return nil, fmt.Errorf("APE tag item count too large: %d", itemCount)
|
||||||
|
}
|
||||||
|
if (flags & apeTagFlagHeader) != 0 {
|
||||||
|
return nil, fmt.Errorf("expected footer, found header")
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsSize := int64(tagSize) - apeTagHeaderSize
|
||||||
|
itemsOffset := footerOffset - itemsSize
|
||||||
|
if itemsOffset < 0 {
|
||||||
|
return nil, fmt.Errorf("APE items extend before file start")
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsData := make([]byte, itemsSize)
|
||||||
|
if _, err := r.ReadAt(itemsData, itemsOffset); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read APE items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := parseAPEItems(itemsData, int(itemCount))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse APE items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &APETag{
|
||||||
|
Version: version,
|
||||||
|
Items: items,
|
||||||
|
ReadOnly: (flags & apeTagFlagReadOnly) != 0,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAPETagReadWriteMergeAndMetadataConversion(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "sample.ape")
|
||||||
|
if err := os.WriteFile(path, []byte("audio-data"), 0600); err != nil {
|
||||||
|
t.Fatalf("write sample: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := &AudioMetadata{
|
||||||
|
Title: "Song",
|
||||||
|
Artist: "Artist",
|
||||||
|
Album: "Album",
|
||||||
|
AlbumArtist: "Album Artist",
|
||||||
|
Genre: "Pop",
|
||||||
|
Date: "2026",
|
||||||
|
TrackNumber: 3,
|
||||||
|
TotalTracks: 12,
|
||||||
|
DiscNumber: 1,
|
||||||
|
TotalDiscs: 2,
|
||||||
|
ISRC: "USRC17607839",
|
||||||
|
Lyrics: "lyrics",
|
||||||
|
Label: "Label",
|
||||||
|
Copyright: "Copyright",
|
||||||
|
Composer: "Composer",
|
||||||
|
Comment: "Comment",
|
||||||
|
ReplayGainTrackGain: "-6.50 dB",
|
||||||
|
ReplayGainTrackPeak: "0.98",
|
||||||
|
ReplayGainAlbumGain: "-5.00 dB",
|
||||||
|
ReplayGainAlbumPeak: "0.99",
|
||||||
|
}
|
||||||
|
items := AudioMetadataToAPEItems(metadata)
|
||||||
|
if len(items) == 0 {
|
||||||
|
t.Fatal("expected APE items")
|
||||||
|
}
|
||||||
|
|
||||||
|
tag := &APETag{Items: append(items, APETagItem{Key: "Custom", Value: "Keep"})}
|
||||||
|
if err := WriteAPETags(path, tag); err != nil {
|
||||||
|
t.Fatalf("WriteAPETags: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
readTag, err := ReadAPETags(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAPETags: %v", err)
|
||||||
|
}
|
||||||
|
if readTag.Version != apeTagVersion2 {
|
||||||
|
t.Fatalf("version = %d", readTag.Version)
|
||||||
|
}
|
||||||
|
readMetadata := APETagToAudioMetadata(readTag)
|
||||||
|
if readMetadata.Title != "Song" || readMetadata.TrackNumber != 3 || readMetadata.TotalTracks != 12 {
|
||||||
|
t.Fatalf("metadata = %#v", readMetadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
readerTag, err := ReadAPETagsFromReader(bytes.NewReader(mustReadFile(t, path)), int64(len(mustReadFile(t, path))))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAPETagsFromReader: %v", err)
|
||||||
|
}
|
||||||
|
if len(readerTag.Items) != len(readTag.Items) {
|
||||||
|
t.Fatalf("reader items = %d, file items = %d", len(readerTag.Items), len(readTag.Items))
|
||||||
|
}
|
||||||
|
|
||||||
|
override := apeKeysFromFields(map[string]string{"title": "", "lyrics": "", "disc_total": ""})
|
||||||
|
merged := MergeAPEItems(readTag.Items, []APETagItem{{Key: "Title", Value: "New Song"}}, override)
|
||||||
|
mergedMeta := APETagToAudioMetadata(&APETag{Items: merged})
|
||||||
|
if mergedMeta.Title != "New Song" {
|
||||||
|
t.Fatalf("merged title = %q", mergedMeta.Title)
|
||||||
|
}
|
||||||
|
if mergedMeta.Lyrics != "" {
|
||||||
|
t.Fatalf("expected lyrics cleared, got %q", mergedMeta.Lyrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := WriteAPETags(path, &APETag{Items: []APETagItem{{Key: "Title", Value: "Replacement"}}}); err != nil {
|
||||||
|
t.Fatalf("replace APE tags: %v", err)
|
||||||
|
}
|
||||||
|
replaced, err := ReadAPETags(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read replacement: %v", err)
|
||||||
|
}
|
||||||
|
if got := APETagToAudioMetadata(replaced).Title; got != "Replacement" {
|
||||||
|
t.Fatalf("replacement title = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := marshalAPETag(nil); err == nil {
|
||||||
|
t.Fatal("expected empty tag error")
|
||||||
|
}
|
||||||
|
if _, err := ReadAPETags(filepath.Join(dir, "missing.ape")); err == nil {
|
||||||
|
t.Fatal("expected missing file error")
|
||||||
|
}
|
||||||
|
if _, err := ReadAPETagsFromReader(bytes.NewReader([]byte("short")), 5); err == nil {
|
||||||
|
t.Fatal("expected small reader error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPETagInvalidFooterBranches(t *testing.T) {
|
||||||
|
footer := buildAPEHeaderFooter(9999, apeTagHeaderSize, 1, 0)
|
||||||
|
if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil {
|
||||||
|
t.Fatal("expected unsupported version")
|
||||||
|
}
|
||||||
|
|
||||||
|
footer = buildAPEHeaderFooter(apeTagVersion2, apeTagHeaderSize-1, 1, 0)
|
||||||
|
if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil {
|
||||||
|
t.Fatal("expected small tag size")
|
||||||
|
}
|
||||||
|
|
||||||
|
footer = buildAPEHeaderFooter(apeTagVersion2, apeTagHeaderSize, 1001, 0)
|
||||||
|
if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil {
|
||||||
|
t.Fatal("expected too many items")
|
||||||
|
}
|
||||||
|
|
||||||
|
footer = buildAPEHeaderFooter(apeTagVersion2, apeTagHeaderSize, 1, apeTagFlagHeader)
|
||||||
|
if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil {
|
||||||
|
t.Fatal("expected header flag error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveLibraryCoverCacheKeyUsesExplicitKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const explicitKey = "content://media/external/audio/media/42|123456"
|
||||||
|
got := resolveLibraryCoverCacheKey("/tmp/saf_random.flac", explicitKey)
|
||||||
|
if got != explicitKey {
|
||||||
|
t.Fatalf("expected explicit cache key %q, got %q", explicitKey, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveLibraryCoverCacheKeyUsesFilePathAndStatWhenNoExplicitKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tempFile, err := os.CreateTemp("", "cover-cache-*.flac")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTemp failed: %v", err)
|
||||||
|
}
|
||||||
|
tempPath := tempFile.Name()
|
||||||
|
tempFile.Close()
|
||||||
|
defer os.Remove(tempPath)
|
||||||
|
|
||||||
|
got := resolveLibraryCoverCacheKey(tempPath, "")
|
||||||
|
if !strings.HasPrefix(got, tempPath+"|") {
|
||||||
|
t.Fatalf("expected stat-based cache key to start with %q, got %q", tempPath+"|", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,133 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ffmpegCommand(args ...string) *exec.Cmd {
|
||||||
|
if ffmpegPath, err := exec.LookPath("ffmpeg"); err == nil {
|
||||||
|
return exec.Command(ffmpegPath, args...)
|
||||||
|
}
|
||||||
|
return exec.Command("ffmpeg", args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runFFmpegTestCommand(t *testing.T, args ...string) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := ffmpegCommand(args...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ffmpeg failed: %v\n%s", err, string(output))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractLyricsReadsMp3AfterCoverEmbed(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||||
|
t.Skip("ffmpeg not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
sourceFlac := filepath.Join(tempDir, "source.flac")
|
||||||
|
baseMp3 := filepath.Join(tempDir, "base.mp3")
|
||||||
|
finalMp3 := filepath.Join(tempDir, "final.mp3")
|
||||||
|
coverPath := filepath.Join(tempDir, "cover.jpg")
|
||||||
|
lyrics := "[ti:Test Song]\n[ar:Test Artist]\n[00:00.00]Hello from embedded lyrics"
|
||||||
|
|
||||||
|
runFFmpegTestCommand(
|
||||||
|
t,
|
||||||
|
"-y",
|
||||||
|
"-f",
|
||||||
|
"lavfi",
|
||||||
|
"-i",
|
||||||
|
"sine=frequency=440:duration=1",
|
||||||
|
"-c:a",
|
||||||
|
"flac",
|
||||||
|
sourceFlac,
|
||||||
|
)
|
||||||
|
|
||||||
|
runFFmpegTestCommand(
|
||||||
|
t,
|
||||||
|
"-y",
|
||||||
|
"-f",
|
||||||
|
"lavfi",
|
||||||
|
"-i",
|
||||||
|
"color=c=red:s=32x32:d=1",
|
||||||
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
coverPath,
|
||||||
|
)
|
||||||
|
|
||||||
|
runFFmpegTestCommand(
|
||||||
|
t,
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
sourceFlac,
|
||||||
|
"-b:a",
|
||||||
|
"320k",
|
||||||
|
"-metadata",
|
||||||
|
"title=Test Song",
|
||||||
|
"-metadata",
|
||||||
|
"artist=Test Artist",
|
||||||
|
"-metadata",
|
||||||
|
"lyrics="+lyrics,
|
||||||
|
baseMp3,
|
||||||
|
)
|
||||||
|
|
||||||
|
runFFmpegTestCommand(
|
||||||
|
t,
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
baseMp3,
|
||||||
|
"-i",
|
||||||
|
coverPath,
|
||||||
|
"-map",
|
||||||
|
"0:a",
|
||||||
|
"-map_metadata",
|
||||||
|
"-1",
|
||||||
|
"-map",
|
||||||
|
"1:0",
|
||||||
|
"-c:v:0",
|
||||||
|
"copy",
|
||||||
|
"-id3v2_version",
|
||||||
|
"3",
|
||||||
|
"-metadata",
|
||||||
|
"title=Test Song",
|
||||||
|
"-metadata",
|
||||||
|
"artist=Test Artist",
|
||||||
|
"-metadata",
|
||||||
|
"lyrics="+lyrics,
|
||||||
|
"-metadata:s:v",
|
||||||
|
"title=Album cover",
|
||||||
|
"-metadata:s:v",
|
||||||
|
"comment=Cover (front)",
|
||||||
|
"-c:a",
|
||||||
|
"copy",
|
||||||
|
finalMp3,
|
||||||
|
)
|
||||||
|
|
||||||
|
meta, err := ReadID3Tags(finalMp3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadID3Tags failed: %v", err)
|
||||||
|
}
|
||||||
|
if meta == nil {
|
||||||
|
t.Fatalf("ReadID3Tags returned nil metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
embeddedLyrics, err := ExtractLyrics(finalMp3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExtractLyrics failed: %v (metadata=%+v)", err, meta)
|
||||||
|
}
|
||||||
|
if !strings.Contains(embeddedLyrics, "Hello from embedded lyrics") {
|
||||||
|
t.Fatalf("embedded lyrics missing, got %q (metadata=%+v)", embeddedLyrics, meta)
|
||||||
|
}
|
||||||
|
if !strings.Contains(meta.Lyrics, "Hello from embedded lyrics") {
|
||||||
|
t.Fatalf("ReadID3Tags lyrics missing, got %+v", meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(finalMp3); err != nil {
|
||||||
|
t.Fatalf("expected final mp3 to exist: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAudioMetadataID3ParsingBranches(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "tagged.mp3")
|
||||||
|
tag := buildID3v23Tag(
|
||||||
|
id3TextFrame("TIT2", "Title"),
|
||||||
|
id3TextFrame("TPE1", "Artist"),
|
||||||
|
id3TextFrame("TPE2", "Album Artist"),
|
||||||
|
id3TextFrame("TALB", "Album"),
|
||||||
|
id3TextFrame("TDRC", "2026-05-04"),
|
||||||
|
id3TextFrame("TCON", "(13)Pop"),
|
||||||
|
id3TextFrame("TRCK", "4/12"),
|
||||||
|
id3TextFrame("TPOS", "1/2"),
|
||||||
|
id3TextFrame("TSRC", "USRC17607839"),
|
||||||
|
id3TextFrame("TCOM", "Composer"),
|
||||||
|
id3TextFrame("TPUB", "Label"),
|
||||||
|
id3TextFrame("TCOP", "Copyright"),
|
||||||
|
id3CommentFrame("COMM", "Comment"),
|
||||||
|
id3CommentFrame("USLT", "Lyrics"),
|
||||||
|
id3UserTextFrame("TXXX", "REPLAYGAIN_TRACK_GAIN", "-6.50 dB"),
|
||||||
|
id3UserTextFrame("TXXX", "REPLAYGAIN_TRACK_PEAK", "0.98"),
|
||||||
|
)
|
||||||
|
if err := os.WriteFile(path, append(tag, []byte("audio")...), 0600); err != nil {
|
||||||
|
t.Fatalf("write ID3v2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err := ReadID3Tags(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadID3Tags: %v", err)
|
||||||
|
}
|
||||||
|
if meta.Title != "Title" || meta.TrackNumber != 4 || meta.TotalTracks != 12 || meta.Genre != "Pop" {
|
||||||
|
t.Fatalf("metadata = %#v", meta)
|
||||||
|
}
|
||||||
|
if meta.Comment != "Comment" || meta.Lyrics != "Lyrics" || meta.ReplayGainTrackGain == "" {
|
||||||
|
t.Fatalf("metadata comments/lyrics/replaygain = %#v", meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
id3v1Path := filepath.Join(dir, "id3v1.mp3")
|
||||||
|
if err := os.WriteFile(id3v1Path, append([]byte("audio"), buildID3v1Tag("V1 Title", "V1 Artist", "V1 Album", "1999", 7, 13)...), 0600); err != nil {
|
||||||
|
t.Fatalf("write ID3v1: %v", err)
|
||||||
|
}
|
||||||
|
v1, err := ReadID3Tags(id3v1Path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadID3Tags v1: %v", err)
|
||||||
|
}
|
||||||
|
if v1.Title != "V1 Title" || v1.Artist != "V1 Artist" || v1.Genre == "" {
|
||||||
|
t.Fatalf("v1 = %#v", v1)
|
||||||
|
}
|
||||||
|
|
||||||
|
v22Path := filepath.Join(dir, "id3v22.mp3")
|
||||||
|
v22 := buildID3v22Tag(
|
||||||
|
id3v22TextFrame("TT2", "V22 Title"),
|
||||||
|
id3v22TextFrame("TP1", "V22 Artist"),
|
||||||
|
id3v22TextFrame("TRK", "2/5"),
|
||||||
|
id3v22CommentFrame("ULT", "V22 Lyrics"),
|
||||||
|
)
|
||||||
|
if err := os.WriteFile(v22Path, append(v22, []byte("audio")...), 0600); err != nil {
|
||||||
|
t.Fatalf("write ID3v2.2: %v", err)
|
||||||
|
}
|
||||||
|
v22Meta, err := ReadID3Tags(v22Path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadID3Tags v2.2: %v", err)
|
||||||
|
}
|
||||||
|
if v22Meta.Title != "V22 Title" || v22Meta.Artist != "V22 Artist" || v22Meta.Lyrics != "V22 Lyrics" {
|
||||||
|
t.Fatalf("v22 = %#v", v22Meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := decodeUTF16([]byte{0xff, 0xfe, 'H', 0, 'i', 0}); got != "Hi" {
|
||||||
|
t.Fatalf("decodeUTF16 = %q", got)
|
||||||
|
}
|
||||||
|
if got := decodeUTF16BE([]byte{0, 'O', 0, 'K'}); got != "OK" {
|
||||||
|
t.Fatalf("decodeUTF16BE = %q", got)
|
||||||
|
}
|
||||||
|
if n, total := parseIndexPair(" 8 / 10 "); n != 8 || total != 10 {
|
||||||
|
t.Fatalf("parseIndexPair = %d/%d", n, total)
|
||||||
|
}
|
||||||
|
if got := removeUnsync([]byte{0xff, 0x00, 0xe0}); !bytes.Equal(got, []byte{0xff, 0xe0}) {
|
||||||
|
t.Fatalf("removeUnsync = %#v", got)
|
||||||
|
}
|
||||||
|
if got := extendedHeaderSize([]byte{0, 0, 0, 6, 0, 0, 0, 0, 0, 0}, 3); got != 10 {
|
||||||
|
t.Fatalf("extendedHeaderSize = %d", got)
|
||||||
|
}
|
||||||
|
if got := syncsafeToInt([]byte{0, 0, 2, 0}); got != 256 {
|
||||||
|
t.Fatalf("syncsafe = %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudioMetadataCoverAndQualityHelpers(t *testing.T) {
|
||||||
|
png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0}
|
||||||
|
if detectCoverMIME("cover.jpg", png) != "image/png" || detectCoverMIME("cover.webp", []byte("RIFFxxxxWEBPdata")) != "image/webp" {
|
||||||
|
t.Fatal("cover MIME detection mismatch")
|
||||||
|
}
|
||||||
|
if _, err := buildPictureBlock("", nil); err == nil {
|
||||||
|
t.Fatal("expected empty picture block error")
|
||||||
|
}
|
||||||
|
|
||||||
|
apic := append([]byte{3}, []byte("image/png\x00")...)
|
||||||
|
apic = append(apic, 3, 0)
|
||||||
|
apic = append(apic, png...)
|
||||||
|
image, mime := parseAPICFrame(apic, 3)
|
||||||
|
if mime != "image/png" || !bytes.Equal(image, png) {
|
||||||
|
t.Fatalf("APIC = %s/%v", mime, image)
|
||||||
|
}
|
||||||
|
pic := append([]byte{0}, []byte("PNG")...)
|
||||||
|
pic = append(pic, 3, 0)
|
||||||
|
pic = append(pic, png...)
|
||||||
|
image, mime = parseAPICFrame(pic, 2)
|
||||||
|
if mime != "image/png" || !bytes.Equal(image, png) {
|
||||||
|
t.Fatalf("PIC = %s/%v", mime, image)
|
||||||
|
}
|
||||||
|
|
||||||
|
frame := make([]byte, 10)
|
||||||
|
copy(frame[:4], "APIC")
|
||||||
|
binary.BigEndian.PutUint32(frame[4:8], uint32(len(apic)))
|
||||||
|
tag := append(frame, apic...)
|
||||||
|
header := []byte{'I', 'D', '3', 3, 0, 0, byte(len(tag) >> 21), byte(len(tag) >> 14), byte(len(tag) >> 7), byte(len(tag))}
|
||||||
|
mp3CoverPath := filepath.Join(t.TempDir(), "cover.mp3")
|
||||||
|
if err := os.WriteFile(mp3CoverPath, append(append(header, tag...), []byte("audio")...), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
extracted, extractedMIME, err := extractMP3CoverArt(mp3CoverPath)
|
||||||
|
if err != nil || extractedMIME != "image/png" || !bytes.Equal(extracted, png) {
|
||||||
|
t.Fatalf("extractMP3CoverArt = %s/%v/%v", extractedMIME, extracted, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var picture bytes.Buffer
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(3))
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(len("image/png")))
|
||||||
|
picture.WriteString("image/png")
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(0))
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(1))
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(1))
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(32))
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(0))
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(len(png)))
|
||||||
|
picture.Write(png)
|
||||||
|
flacImage, flacMIME := parseFLACPictureBlock(picture.Bytes())
|
||||||
|
if flacMIME != "image/png" || !bytes.Equal(flacImage, png) {
|
||||||
|
t.Fatalf("FLAC picture = %s/%v", flacMIME, flacImage)
|
||||||
|
}
|
||||||
|
|
||||||
|
comment := "METADATA_BLOCK_PICTURE=" + base64.StdEncoding.EncodeToString(picture.Bytes())
|
||||||
|
var vorbis bytes.Buffer
|
||||||
|
binary.Write(&vorbis, binary.LittleEndian, uint32(6))
|
||||||
|
vorbis.WriteString("vendor")
|
||||||
|
binary.Write(&vorbis, binary.LittleEndian, uint32(1))
|
||||||
|
binary.Write(&vorbis, binary.LittleEndian, uint32(len(comment)))
|
||||||
|
vorbis.WriteString(comment)
|
||||||
|
commentImage, commentMIME := extractPictureFromVorbisComments(vorbis.Bytes())
|
||||||
|
if commentMIME != "image/png" || !bytes.Equal(commentImage, png) {
|
||||||
|
t.Fatalf("vorbis picture = %s/%v", commentMIME, commentImage)
|
||||||
|
}
|
||||||
|
if detectOggStreamType([][]byte{[]byte("OpusHeadxxxx")}) != oggStreamOpus {
|
||||||
|
t.Fatal("expected opus stream")
|
||||||
|
}
|
||||||
|
if detectOggStreamType([][]byte{append([]byte{1}, []byte("vorbisxxxx")...)}) != oggStreamVorbis {
|
||||||
|
t.Fatal("expected vorbis stream")
|
||||||
|
}
|
||||||
|
|
||||||
|
mp3Path := filepath.Join(t.TempDir(), "quality.mp3")
|
||||||
|
audio := append([]byte{0xFF, 0xFB, 0x90, 0x64}, bytes.Repeat([]byte{0}, 2000)...)
|
||||||
|
if err := os.WriteFile(mp3Path, audio, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
quality, err := GetMP3Quality(mp3Path)
|
||||||
|
if err != nil || quality.SampleRate != 44100 || quality.Bitrate != 128000 {
|
||||||
|
t.Fatalf("MP3 quality = %#v/%v", quality, err)
|
||||||
|
}
|
||||||
|
if _, _, err := extractMP3CoverArt(filepath.Join(t.TempDir(), "missing.mp3")); err == nil {
|
||||||
|
t.Fatal("expected missing MP3 cover error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestM4AMetadataAtomHelpers(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "tagged.m4a")
|
||||||
|
cover := []byte{0xFF, 0xD8, 0xFF, 0x00}
|
||||||
|
ilstPayload := []byte{}
|
||||||
|
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9nam", "M4A Title")...)
|
||||||
|
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9ART", "M4A Artist")...)
|
||||||
|
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9alb", "M4A Album")...)
|
||||||
|
ilstPayload = append(ilstPayload, buildM4ATextTag("aART", "Album Artist")...)
|
||||||
|
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9day", "2026")...)
|
||||||
|
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9gen", "Pop")...)
|
||||||
|
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9wrt", "Composer")...)
|
||||||
|
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9cmt", "[ti:Comment Lyrics]")...)
|
||||||
|
ilstPayload = append(ilstPayload, buildM4ATextTag("cprt", "Copyright")...)
|
||||||
|
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9lyr", "[00:00.00]M4A Lyrics")...)
|
||||||
|
ilstPayload = append(ilstPayload, buildM4AIndexTag("trkn", 3, 12)...)
|
||||||
|
ilstPayload = append(ilstPayload, buildM4AIndexTag("disk", 1, 2)...)
|
||||||
|
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("ISRC", "USRC17607839")...)
|
||||||
|
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("LABEL", "Label")...)
|
||||||
|
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("REPLAYGAIN_TRACK_GAIN", "-6.50 dB")...)
|
||||||
|
ilstPayload = append(ilstPayload, buildM4AAtom("covr", buildM4AAtom("data", append([]byte{0, 0, 0, 13, 0, 0, 0, 0}, cover...)))...)
|
||||||
|
fileData := buildM4AFileWithIlst(ilstPayload, true)
|
||||||
|
if err := os.WriteFile(path, fileData, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err := ReadM4ATags(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadM4ATags: %v", err)
|
||||||
|
}
|
||||||
|
if meta.Title != "M4A Title" || meta.Artist != "M4A Artist" || meta.TrackNumber != 3 || meta.TotalTracks != 12 || meta.ISRC != "USRC17607839" {
|
||||||
|
t.Fatalf("M4A metadata = %#v", meta)
|
||||||
|
}
|
||||||
|
if lyrics, err := extractLyricsFromM4A(path); err != nil || !strings.Contains(lyrics, "M4A Lyrics") {
|
||||||
|
t.Fatalf("extractLyricsFromM4A = %q/%v", lyrics, err)
|
||||||
|
}
|
||||||
|
if image, err := extractCoverFromM4A(path); err != nil || !bytes.Equal(image, cover) {
|
||||||
|
t.Fatalf("extractCoverFromM4A = %#v/%v", image, err)
|
||||||
|
}
|
||||||
|
if pathInfo, err := func() (m4aMetadataPath, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return m4aMetadataPath{}, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
info, _ := f.Stat()
|
||||||
|
return findM4AMetadataPath(f, info.Size())
|
||||||
|
}(); err != nil || pathInfo.udta == nil {
|
||||||
|
t.Fatalf("findM4AMetadataPath = %#v/%v", pathInfo, err)
|
||||||
|
}
|
||||||
|
if err := EditM4AReplayGain(path, map[string]string{"replaygain_track_gain": "-5.00 dB", "replaygain_track_peak": "0.98"}); err != nil {
|
||||||
|
t.Fatalf("EditM4AReplayGain: %v", err)
|
||||||
|
}
|
||||||
|
edited, err := ReadM4ATags(path)
|
||||||
|
if err != nil || edited.ReplayGainTrackGain != "-5.00 dB" || edited.ReplayGainTrackPeak != "0.98" {
|
||||||
|
t.Fatalf("edited M4A = %#v/%v", edited, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
noUdtaPath := filepath.Join(dir, "noudta.m4a")
|
||||||
|
if err := os.WriteFile(noUdtaPath, buildM4AFileWithIlst(buildM4ATextTag("\xa9nam", "No Udta"), false), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if meta, err := ReadM4ATags(noUdtaPath); err != nil || meta.Title != "No Udta" {
|
||||||
|
t.Fatalf("ReadM4ATags no udta = %#v/%v", meta, err)
|
||||||
|
}
|
||||||
|
if _, err := ReadM4ATags(filepath.Join(dir, "missing.m4a")); err == nil {
|
||||||
|
t.Fatal("expected missing M4A error")
|
||||||
|
}
|
||||||
|
emptyM4A := filepath.Join(dir, "empty.m4a")
|
||||||
|
if err := os.WriteFile(emptyM4A, buildM4AFileWithIlst(nil, true), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := ReadM4ATags(emptyM4A); err == nil {
|
||||||
|
t.Fatal("expected empty M4A tags error")
|
||||||
|
}
|
||||||
|
if _, err := extractCoverFromM4A(emptyM4A); err == nil {
|
||||||
|
t.Fatal("expected missing M4A cover error")
|
||||||
|
}
|
||||||
|
if _, err := extractLyricsFromM4A(emptyM4A); err == nil {
|
||||||
|
t.Fatal("expected missing M4A lyrics error")
|
||||||
|
}
|
||||||
|
|
||||||
|
sidecarAudio := filepath.Join(dir, "sidecar.mp3")
|
||||||
|
if err := os.WriteFile(sidecarAudio, []byte("audio"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "sidecar.lrc"), []byte(" [00:00.00]Sidecar "), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if lyrics, err := extractLyricsFromSidecarLRC(sidecarAudio); err != nil || !strings.Contains(lyrics, "Sidecar") {
|
||||||
|
t.Fatalf("sidecar lyrics = %q/%v", lyrics, err)
|
||||||
|
}
|
||||||
|
if !looksLikeEmbeddedLyrics("[ti:Song]") || !looksLikeEmbeddedLyrics("[00:00.00]Line\n[00:01.00]Next") || looksLikeEmbeddedLyrics("plain") {
|
||||||
|
t.Fatal("embedded lyric heuristic mismatch")
|
||||||
|
}
|
||||||
|
if formatIndexValue(3, 12) != "3/12" || formatIndexValue(3, 0) != "3" || formatIndexValue(0, 12) != "" {
|
||||||
|
t.Fatal("formatIndexValue mismatch")
|
||||||
|
}
|
||||||
|
if parsePositiveInt(" 42 ") != 42 || parsePositiveInt("bad") != 0 {
|
||||||
|
t.Fatal("parsePositiveInt mismatch")
|
||||||
|
}
|
||||||
|
if !hasMapKey(map[string]string{"x": "y"}, "x") {
|
||||||
|
t.Fatal("expected map key")
|
||||||
|
}
|
||||||
|
if _, ok := parseReplayGainDb("-6.50 dB"); !ok {
|
||||||
|
t.Fatal("expected ReplayGain dB parse")
|
||||||
|
}
|
||||||
|
if _, ok := parseReplayGainPeak("0.98"); !ok {
|
||||||
|
t.Fatal("expected ReplayGain peak parse")
|
||||||
|
}
|
||||||
|
if norm := buildITunNORMTag("-6.50 dB", "0.98"); norm == "" {
|
||||||
|
t.Fatal("expected iTunNORM")
|
||||||
|
}
|
||||||
|
if fields := collectM4AReplayGainFields(map[string]string{"replaygain_track_gain": "-6 dB", "replaygain_track_peak": "0.9"}); fields["iTunNORM"] == "" {
|
||||||
|
t.Fatalf("ReplayGain fields = %#v", fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
qualityPath := filepath.Join(dir, "quality-alac.m4a")
|
||||||
|
mvhd := make([]byte, 20)
|
||||||
|
binary.BigEndian.PutUint32(mvhd[12:16], 1000)
|
||||||
|
binary.BigEndian.PutUint32(mvhd[16:20], 180000)
|
||||||
|
sampleEntry := make([]byte, 32)
|
||||||
|
copy(sampleEntry[0:4], "alac")
|
||||||
|
binary.BigEndian.PutUint16(sampleEntry[22:24], 24)
|
||||||
|
sampleEntry[28] = 0xAC
|
||||||
|
sampleEntry[29] = 0x44
|
||||||
|
alacConfig := make([]byte, 24)
|
||||||
|
alacConfig[5] = 24
|
||||||
|
binary.BigEndian.PutUint32(alacConfig[20:24], 44100)
|
||||||
|
alacEntryPayload := append(append([]byte{}, sampleEntry[4:]...), buildM4AAtom("alac", alacConfig)...)
|
||||||
|
qualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), buildM4AAtom("alac", alacEntryPayload)...))...)
|
||||||
|
if err := os.WriteFile(qualityPath, qualityFile, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if quality, err := GetM4AQuality(qualityPath); err != nil || quality.BitDepth != 24 || quality.SampleRate != 44100 || quality.Duration != 180 {
|
||||||
|
t.Fatalf("GetM4AQuality = %#v/%v", quality, err)
|
||||||
|
}
|
||||||
|
if quality, err := GetAudioQuality(qualityPath); err != nil || quality.SampleRate != 44100 {
|
||||||
|
t.Fatalf("GetAudioQuality M4A = %#v/%v", quality, err)
|
||||||
|
}
|
||||||
|
aacQualityPath := filepath.Join(dir, "quality-aac.m4a")
|
||||||
|
copy(sampleEntry[0:4], "mp4a")
|
||||||
|
aacQualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), sampleEntry...))...)
|
||||||
|
if err := os.WriteFile(aacQualityPath, aacQualityFile, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if quality, err := GetM4AQuality(aacQualityPath); err != nil || quality.BitDepth != 0 || quality.SampleRate != 44100 || quality.Duration != 180 {
|
||||||
|
t.Fatalf("GetM4AQuality AAC = %#v/%v", quality, err)
|
||||||
|
}
|
||||||
|
eac3QualityPath := filepath.Join(dir, "quality-eac3.m4a")
|
||||||
|
zeroMvhd := make([]byte, 20)
|
||||||
|
eac3SampleEntry := make([]byte, 32)
|
||||||
|
copy(eac3SampleEntry[0:4], "ec-3")
|
||||||
|
eac3SampleEntry[28] = 0xBB
|
||||||
|
eac3SampleEntry[29] = 0x80
|
||||||
|
mdhd := make([]byte, 20)
|
||||||
|
binary.BigEndian.PutUint32(mdhd[12:16], 48000)
|
||||||
|
binary.BigEndian.PutUint32(mdhd[16:20], 48000*123)
|
||||||
|
eac3QualityFile := append(
|
||||||
|
buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")),
|
||||||
|
buildM4AAtom("moov", append(
|
||||||
|
append(buildM4AAtom("mvhd", zeroMvhd), buildM4AAtom("trak", buildM4AAtom("mdia", buildM4AAtom("mdhd", mdhd)))...),
|
||||||
|
eac3SampleEntry...,
|
||||||
|
))...,
|
||||||
|
)
|
||||||
|
if err := os.WriteFile(eac3QualityPath, eac3QualityFile, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if quality, err := GetM4AQuality(eac3QualityPath); err != nil || quality.Codec != "eac3" || quality.Duration != 123 {
|
||||||
|
t.Fatalf("GetM4AQuality EAC3 mdhd fallback = %#v/%v", quality, err)
|
||||||
|
}
|
||||||
|
if _, _, ok := parseALACSpecificConfig(make([]byte, 4)); ok {
|
||||||
|
t.Fatal("short ALAC config should not parse")
|
||||||
|
}
|
||||||
|
alac := make([]byte, 24)
|
||||||
|
alac[5] = 16
|
||||||
|
binary.BigEndian.PutUint32(alac[20:24], 48000)
|
||||||
|
if depth, rate, ok := parseALACSpecificConfig(alac); !ok || depth != 16 || rate != 48000 {
|
||||||
|
t.Fatalf("ALAC config = %d/%d/%v", depth, rate, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOggMetadataQualityAndCoverHelpers(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
opusHead := make([]byte, 19)
|
||||||
|
copy(opusHead[0:8], "OpusHead")
|
||||||
|
binary.LittleEndian.PutUint16(opusHead[10:12], 312)
|
||||||
|
binary.LittleEndian.PutUint32(opusHead[12:16], 48000)
|
||||||
|
|
||||||
|
var comments bytes.Buffer
|
||||||
|
binary.Write(&comments, binary.LittleEndian, uint32(6))
|
||||||
|
comments.WriteString("vendor")
|
||||||
|
entries := []string{
|
||||||
|
"TITLE=Ogg Title",
|
||||||
|
"ARTIST=Artist",
|
||||||
|
"ALBUMARTIST=Album Artist",
|
||||||
|
"TRACKNUMBER=2/9",
|
||||||
|
"DISCNUMBER=1/2",
|
||||||
|
"LYRICS=[00:00.00]Ogg Lyrics",
|
||||||
|
}
|
||||||
|
binary.Write(&comments, binary.LittleEndian, uint32(len(entries)))
|
||||||
|
for _, entry := range entries {
|
||||||
|
binary.Write(&comments, binary.LittleEndian, uint32(len(entry)))
|
||||||
|
comments.WriteString(entry)
|
||||||
|
}
|
||||||
|
opusTags := append([]byte("OpusTags"), comments.Bytes()...)
|
||||||
|
oggPath := filepath.Join(dir, "tagged.opus")
|
||||||
|
oggData := append(buildOggPage(0x02, 0, opusHead), buildOggPage(0x00, 48000+312, opusTags)...)
|
||||||
|
if err := os.WriteFile(oggPath, oggData, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
quality, err := GetOggQuality(oggPath)
|
||||||
|
if err != nil || quality.SampleRate != 48000 || quality.Duration != 1 {
|
||||||
|
t.Fatalf("GetOggQuality = %#v/%v", quality, err)
|
||||||
|
}
|
||||||
|
meta, err := ReadOggVorbisComments(oggPath)
|
||||||
|
if err != nil || meta.Title != "Ogg Title" || meta.TrackNumber != 2 || meta.TotalTracks != 9 {
|
||||||
|
t.Fatalf("ReadOggVorbisComments = %#v/%v", meta, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
picture := buildTestFLACPictureBlock([]byte{0x89, 0x50, 0x4E, 0x47}, "image/png")
|
||||||
|
pictureComment := "METADATA_BLOCK_PICTURE=" + base64.StdEncoding.EncodeToString(picture)
|
||||||
|
var coverComments bytes.Buffer
|
||||||
|
binary.Write(&coverComments, binary.LittleEndian, uint32(6))
|
||||||
|
coverComments.WriteString("vendor")
|
||||||
|
binary.Write(&coverComments, binary.LittleEndian, uint32(1))
|
||||||
|
binary.Write(&coverComments, binary.LittleEndian, uint32(len(pictureComment)))
|
||||||
|
coverComments.WriteString(pictureComment)
|
||||||
|
coverPath := filepath.Join(dir, "cover.opus")
|
||||||
|
coverData := append(buildOggPage(0x02, 0, opusHead), buildOggPage(0x00, 48000+312, append([]byte("OpusTags"), coverComments.Bytes()...))...)
|
||||||
|
if err := os.WriteFile(coverPath, coverData, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if image, mime, err := extractOggCoverArt(coverPath); err != nil || mime != "image/png" || len(image) == 0 {
|
||||||
|
t.Fatalf("extractOggCoverArt = %s/%#v/%v", mime, image, err)
|
||||||
|
}
|
||||||
|
if image, mime, err := extractAnyCoverArtWithHint(coverPath, "cover.opus"); err != nil || mime != "image/png" || len(image) == 0 {
|
||||||
|
t.Fatalf("extractAnyCoverArtWithHint = %s/%#v/%v", mime, image, err)
|
||||||
|
}
|
||||||
|
extractedCoverPath := filepath.Join(dir, "extracted.png")
|
||||||
|
if err := ExtractCoverToFile(coverPath, extractedCoverPath); err != nil {
|
||||||
|
t.Fatalf("ExtractCoverToFile = %v", err)
|
||||||
|
}
|
||||||
|
if data := mustReadFile(t, extractedCoverPath); len(data) == 0 {
|
||||||
|
t.Fatal("expected extracted cover data")
|
||||||
|
}
|
||||||
|
cachePath, err := SaveCoverToCacheWithHintAndKey(coverPath, "cover.opus", dir, "key")
|
||||||
|
if err != nil || cachePath == "" {
|
||||||
|
t.Fatalf("SaveCoverToCacheWithHintAndKey = %q/%v", cachePath, err)
|
||||||
|
}
|
||||||
|
if _, err := SaveCoverToCacheWithHintAndKey(filepath.Join(dir, "missing.opus"), "missing.opus", dir, "missing"); err == nil {
|
||||||
|
t.Fatal("expected missing cover cache error")
|
||||||
|
}
|
||||||
|
|
||||||
|
badPath := filepath.Join(dir, "bad.ogg")
|
||||||
|
if err := os.WriteFile(badPath, []byte("bad"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := GetOggQuality(badPath); err == nil {
|
||||||
|
t.Fatal("expected invalid Ogg quality error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildM4ADataPayload(payload []byte) []byte {
|
||||||
|
return append([]byte{0, 0, 0, 1, 0, 0, 0, 0}, payload...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildM4ATextTag(atomType, value string) []byte {
|
||||||
|
return buildM4AAtom(atomType, buildM4AAtom("data", buildM4ADataPayload([]byte(value))))
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildM4AIndexTag(atomType string, number, total int) []byte {
|
||||||
|
payload := []byte{0, 0, 0, byte(number), 0, byte(total), 0, 0}
|
||||||
|
return buildM4AAtom(atomType, buildM4AAtom("data", buildM4ADataPayload(payload)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildM4AFileWithIlst(ilstPayload []byte, withUdta bool) []byte {
|
||||||
|
ilst := buildM4AAtom("ilst", ilstPayload)
|
||||||
|
meta := buildM4AAtom("meta", append([]byte{0, 0, 0, 0}, ilst...))
|
||||||
|
moovPayload := meta
|
||||||
|
if withUdta {
|
||||||
|
moovPayload = buildM4AAtom("udta", meta)
|
||||||
|
}
|
||||||
|
return append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", moovPayload)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildOggPage(headerType byte, granule uint64, packet []byte) []byte {
|
||||||
|
header := make([]byte, 27)
|
||||||
|
copy(header[0:4], "OggS")
|
||||||
|
header[4] = 0
|
||||||
|
header[5] = headerType
|
||||||
|
binary.LittleEndian.PutUint64(header[6:14], granule)
|
||||||
|
header[26] = 1
|
||||||
|
return append(append(header, byte(len(packet))), packet...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTestFLACPictureBlock(image []byte, mime string) []byte {
|
||||||
|
var picture bytes.Buffer
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(3))
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(len(mime)))
|
||||||
|
picture.WriteString(mime)
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(0))
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(1))
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(1))
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(32))
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(0))
|
||||||
|
binary.Write(&picture, binary.BigEndian, uint32(len(image)))
|
||||||
|
picture.Write(image)
|
||||||
|
return picture.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestR128ToReplayGainDb(t *testing.T) {
|
||||||
|
// -1280/256 = -5 dB on the R128 (-23 LUFS) scale -> 0 dB ReplayGain (-18).
|
||||||
|
if db, ok := r128ToReplayGainDb("-1280"); !ok || db != "0.00 dB" {
|
||||||
|
t.Fatalf("got %q ok=%v", db, ok)
|
||||||
|
}
|
||||||
|
if db, ok := r128ToReplayGainDb(" -2944 "); !ok || db != "-6.50 dB" {
|
||||||
|
t.Fatalf("got %q ok=%v", db, ok)
|
||||||
|
}
|
||||||
|
if _, ok := r128ToReplayGainDb("abc"); ok {
|
||||||
|
t.Fatal("expected failure for non-numeric input")
|
||||||
|
}
|
||||||
|
}
|
||||||