Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a9e538845 | ||
|
|
130538cd9a | ||
|
|
1486613744 | ||
|
|
f2516206c0 | ||
|
|
f8902e05a6 | ||
|
|
c59b47c801 | ||
|
|
0e4845f2e2 | ||
|
|
e2775cd9de | ||
|
|
816bec88c8 | ||
|
|
d724bef4e4 | ||
|
|
cac851f120 | ||
|
|
c1df74ee27 | ||
|
|
0c7b51c622 | ||
|
|
56ecad24d4 | ||
|
|
6bb600648a | ||
|
|
fe2610c35a | ||
|
|
84bcbc9cea | ||
|
|
d741377e5a | ||
|
|
1ea106970d | ||
|
|
a879b7aded | ||
|
|
30ee000676 | ||
|
|
703cf80d5a | ||
|
|
34289f0176 | ||
|
|
8b8f32de02 | ||
|
|
095e5cc0d9 | ||
|
|
0c46c0a079 | ||
|
|
dd4b2ebb8c | ||
|
|
d30ee0c35a | ||
|
|
484fb16bba | ||
|
|
f320e6cef0 | ||
|
|
ea2421bc6d | ||
|
|
f43c31fb9a | ||
|
|
6825ad55e3 | ||
|
|
92841f8bb3 | ||
|
|
38e5e4c9a9 | ||
|
|
1bcfed05af | ||
|
|
e00ec4098a | ||
|
|
5d44953435 | ||
|
|
80ad5a3518 | ||
|
|
b5683ba4b8 | ||
|
|
884b3963d3 | ||
|
|
8de2296aaf | ||
|
|
c1869f469c | ||
|
|
86196b3122 | ||
|
|
fd03e42240 | ||
|
|
3e230b0d68 | ||
|
|
133d7f56e5 | ||
|
|
78d048ba62 | ||
|
|
323004a730 | ||
|
|
626bca9951 | ||
|
|
3ae0d4ff45 | ||
|
|
9d85df54e1 | ||
|
|
0ff1c95fd1 | ||
|
|
b93274fd53 | ||
|
|
0f0d1d64f9 | ||
|
|
d77e6c6f22 | ||
|
|
83ff5348c6 | ||
|
|
65012d40ed | ||
|
|
feb02bfa85 | ||
|
|
276e51b8ea | ||
|
|
c83d77c02a | ||
|
|
0cdb48a71a | ||
|
|
1556d7ac69 | ||
|
|
1e65abd962 | ||
|
|
9f11d799ac | ||
|
|
5c0f419e7e | ||
|
|
c33d42808a | ||
|
|
e8716a45b2 | ||
|
|
570dee30e3 | ||
|
|
97222ed233 | ||
|
|
e2a7fff6f9 | ||
|
|
efc8246a76 | ||
|
|
b4a95e0cf9 | ||
|
|
c66d5d4627 | ||
|
|
4de28e0390 | ||
|
|
f74721b366 | ||
|
|
4fb16e7098 | ||
|
|
a76c351206 | ||
|
|
7faf94cfab | ||
|
|
3a1e29f682 | ||
|
|
88cb287df8 | ||
|
|
eb26ee0cd4 | ||
|
|
844833f348 | ||
|
|
38b02cf771 | ||
|
|
1706d5cab9 | ||
|
|
640d2ff28b | ||
|
|
312b6ce987 | ||
|
|
489f70929e | ||
|
|
904ddfa5c6 | ||
|
|
3ed6840607 | ||
|
|
93125dd7c8 | ||
|
|
34356c6c56 | ||
|
|
05466fed2b | ||
|
|
535b818a22 | ||
|
|
e62e04dd14 | ||
|
|
61cd5fcf7b | ||
|
|
f308f71b5d | ||
|
|
c498bde1fd | ||
|
|
0c9b496137 | ||
|
|
9682d4f8f9 | ||
|
|
da76945f5e | ||
|
|
51074a58f4 | ||
|
|
3cc735b9c8 | ||
|
|
b74f31c26d | ||
|
|
4a96cbfb0e | ||
|
|
77a3c352b1 | ||
|
|
598ae01082 | ||
|
|
54e11899a0 | ||
|
|
dc4962ade1 | ||
|
|
ccf317c06f | ||
|
|
aff5717f19 | ||
|
|
fa39209272 | ||
|
|
d63082a889 | ||
|
|
a28d46ecbe | ||
|
|
3bc72ce881 | ||
|
|
314210117b | ||
|
|
57a8e48d3a | ||
|
|
2d3ab86238 | ||
|
|
b76edbf7de | ||
|
|
b2efc34b80 | ||
|
|
dee8d6bc61 | ||
|
|
3e5576481d | ||
|
|
ffd74aea6f | ||
|
|
145af33239 | ||
|
|
3851b2226a | ||
|
|
bb132101b5 | ||
|
|
1e6251edfb | ||
|
|
e251da9b8e | ||
|
|
335ee0cd60 | ||
|
|
dc9f6c22ad | ||
|
|
5568b54c99 | ||
|
|
e6e35e052a | ||
|
|
9c21db8172 | ||
|
|
77c9141d92 | ||
|
|
68e86f4f90 | ||
|
|
5e95f60461 | ||
|
|
908d7d4177 | ||
|
|
de1a9c35ec | ||
|
|
f7fa9cd330 | ||
|
|
ee8b371850 | ||
|
|
601228a369 | ||
|
|
5dcaefc460 | ||
|
|
026e94fc1b | ||
|
|
fd7c8467b4 | ||
|
|
06e651a844 | ||
|
|
1b0e7a3dfd | ||
|
|
abc5d1861a | ||
|
|
4b8129fd19 | ||
|
|
f610f385bd | ||
|
|
6994635fe4 | ||
|
|
61525e1351 | ||
|
|
a50070e133 | ||
|
|
db632424b1 | ||
|
|
6b92920f50 | ||
|
|
a30d362695 | ||
|
|
d105e62f18 | ||
|
|
d5ad796339 | ||
|
|
c0aabefc72 | ||
|
|
259d321f3d | ||
|
|
d2375d92e6 | ||
|
|
f8a63c5df1 | ||
|
|
5004d99473 | ||
|
|
5b26cc73e8 | ||
|
|
72a4c4542b | ||
|
|
fdaeb8f6b8 | ||
|
|
f2f550dae7 | ||
|
|
6780d72296 | ||
|
|
f1784f3c21 | ||
|
|
22d17ba621 | ||
|
|
4b4cc1c438 | ||
|
|
7aac664916 | ||
|
|
12c4a014f2 | ||
|
|
1eada12714 | ||
|
|
5adb831136 | ||
|
|
5b8a8a7449 | ||
|
|
005dae76ab | ||
|
|
cb2c7276dd | ||
|
|
54fe61c51c | ||
|
|
bdf27ec421 | ||
|
|
3addd39fc5 | ||
|
|
19bf456e47 | ||
|
|
66bf889e9f | ||
|
|
09ea158734 | ||
|
|
88c1b553e3 | ||
|
|
1d1fd1b54d | ||
|
|
9d3267b1fc | ||
|
|
22a04adde0 | ||
|
|
1dba90ceb5 | ||
|
|
4e9794d574 | ||
|
|
23a747d3a9 | ||
|
|
4678c35453 | ||
|
|
c153e53c5b | ||
|
|
e20ee09bbb | ||
|
|
619687472d | ||
|
|
b74f23f6d1 | ||
|
|
d073b4c402 | ||
|
|
258883d88b | ||
|
|
29c8d6236a | ||
|
|
a1443594f7 | ||
|
|
ac2e9570e5 | ||
|
|
983b1c67ac | ||
|
|
764493f5c9 | ||
|
|
d7981e7cc0 | ||
|
|
11fbe97325 | ||
|
|
3f37a63439 | ||
|
|
ee78b95617 | ||
|
|
d7091f8a8e | ||
|
|
4b5cdade77 | ||
|
|
cb67d1ae40 | ||
|
|
676af4a2bf | ||
|
|
0feb02babe | ||
|
|
90c481b12b | ||
|
|
59fed488ba | ||
|
|
8431e7b6b2 | ||
|
|
be30cdd540 | ||
|
|
54927ed77c | ||
|
|
97f974d931 | ||
|
|
0de2cc0922 | ||
|
|
e80ad81f05 | ||
|
|
a3960421d7 | ||
|
|
2f2eeeb13a | ||
|
|
eeb026abf0 | ||
|
|
0fed96f59c | ||
|
|
8369939135 | ||
|
|
08056a650f | ||
|
|
5b53b7490c | ||
|
|
ce95c3ae8c | ||
|
|
5e0c4e3e77 | ||
|
|
10bf48df40 | ||
|
|
ae935798f3 | ||
|
|
bf71f8cf86 | ||
|
|
4206696f0d | ||
|
|
2903662f8b | ||
|
|
469847e4b7 | ||
|
|
d7a86b0401 | ||
|
|
4fdd346c00 | ||
|
|
4f26b63ab1 | ||
|
|
0fed91302c | ||
|
|
831cefd9a7 | ||
|
|
114a3dda8b | ||
|
|
55ddd2c5a0 | ||
|
|
1d276186f8 | ||
|
|
fb3886b981 | ||
|
|
2bed7be654 | ||
|
|
5c460ad747 | ||
|
|
32220d7509 | ||
|
|
b0265001f8 | ||
|
|
9ede4634cb | ||
|
|
0e008ecd5c | ||
|
|
2ae82fdd9e | ||
|
|
3b430b95ed | ||
|
|
e907892c4a | ||
|
|
d9cd3764b9 | ||
|
|
9c2bf05a24 | ||
|
|
91e52c4c5d | ||
|
|
b41d7f539f | ||
|
|
9735b980d7 | ||
|
|
706483ed06 | ||
|
|
7760776a5f | ||
|
|
8b9a7893d3 | ||
|
|
729250a2cc | ||
|
|
f14203f85e | ||
|
|
60e880f7f2 | ||
|
|
16cef0ae65 | ||
|
|
12ff1f421e | ||
|
|
61c33da2b0 | ||
|
|
8b0c7205b7 | ||
|
|
b02c96ada8 | ||
|
|
52b20e4c8e | ||
|
|
a94ac8284f | ||
|
|
ee399136a9 | ||
|
|
ab54422acf | ||
|
|
d56c5720eb | ||
|
|
040de4b000 | ||
|
|
815a774e3d | ||
|
|
b2e7f58e14 | ||
|
|
8d124a20ac | ||
|
|
d1a4a54d2a | ||
|
|
049bb06762 | ||
|
|
e5dd473981 | ||
|
|
ca5cc3bb30 | ||
|
|
444d75c803 | ||
|
|
ccda3a6098 | ||
|
|
ce1c70a4e1 | ||
|
|
5fcfa979c5 | ||
|
|
ec4d609ba1 | ||
|
|
b42278a372 | ||
|
|
1f96035ba9 | ||
|
|
332edb5dca | ||
|
|
c5681d31eb | ||
|
|
685d70c639 | ||
|
|
848878bc5e | ||
|
|
088d063d38 | ||
|
|
4e493db987 | ||
|
|
6d017d4b27 | ||
|
|
a34066d650 | ||
|
|
0e34d63c84 | ||
|
|
9dca8ebfa0 | ||
|
|
c908aea1ae | ||
|
|
c7a58bbce6 | ||
|
|
b26f303479 | ||
|
|
13d64d2ce2 | ||
|
|
ddb7fb3d6e | ||
|
|
457967bfb2 | ||
|
|
734742ecbe | ||
|
|
26dab3870a | ||
|
|
ca9acbb089 | ||
|
|
f78d6fec1d | ||
|
|
bafb852545 | ||
|
|
a755db45b9 | ||
|
|
b9715f2159 | ||
|
|
e90dbaf691 | ||
|
|
08ac55f718 | ||
|
|
5bf831e236 | ||
|
|
1905804f2a | ||
|
|
afc931ac8c | ||
|
|
9fb032e030 | ||
|
|
09b0c96263 | ||
|
|
7c66816023 | ||
|
|
3e7e5df5de | ||
|
|
1c84a74034 | ||
|
|
09969dd9d7 | ||
|
|
63b57e1113 | ||
|
|
585677e515 | ||
|
|
57f2b5f7e5 | ||
|
|
3597735556 | ||
|
|
2442cf2286 | ||
|
|
97438e13d2 | ||
|
|
57dff9d122 | ||
|
|
963fc792a0 | ||
|
|
35a6418f38 | ||
|
|
3c0b951132 | ||
|
|
3dc9e7d8b4 | ||
|
|
ab372b8357 | ||
|
|
c79f04581f | ||
|
|
0dcd049453 | ||
|
|
7170b09586 | ||
|
|
3d893b126f | ||
|
|
fb73b8b106 | ||
|
|
cede77e3fa | ||
|
|
0572d6a308 | ||
|
|
5967e3df46 | ||
|
|
6b1ee83654 | ||
|
|
0af1496a0f | ||
|
|
52b08efd88 | ||
|
|
7a3e4e56e2 | ||
|
|
abe6638f66 | ||
|
|
eb62c2cea9 | ||
|
|
7e6eddce3e | ||
|
|
bc517e0cb0 | ||
|
|
41900b0048 | ||
|
|
9ea71b931c | ||
|
|
64f222b5b7 | ||
|
|
6c27406803 | ||
|
|
2f51e7ad99 | ||
|
|
d86dd63236 | ||
|
|
f794133915 | ||
|
|
c7d2e7acc2 | ||
|
|
2dde3840c6 | ||
|
|
4675847fec | ||
|
|
830d1a2243 | ||
|
|
8a43f0ded5 | ||
|
|
af24e0ffbe | ||
|
|
d3a70bf2c1 | ||
|
|
a62fe705f9 | ||
|
|
1c6407238c | ||
|
|
ec897b185c | ||
|
|
69e15b3797 | ||
|
|
dae4af070f | ||
|
|
beaf0f3bfd | ||
|
|
8057982184 | ||
|
|
9a5a808780 | ||
|
|
d2d5f4a705 | ||
|
|
4aa875660f | ||
|
|
5ab8f00aeb | ||
|
|
1e1b5f56ce | ||
|
|
49b9ee96f3 | ||
|
|
aef7716634 | ||
|
|
57f2e1d500 | ||
|
|
41c696992a | ||
|
|
f231d4e1e4 | ||
|
|
0efbe41b7e | ||
|
|
be9675bb10 | ||
|
|
4e45d35925 | ||
|
|
a5e293554c | ||
|
|
c76d4822fe | ||
|
|
0f8fd5cc0e | ||
|
|
8b27890254 | ||
|
|
8747c8375e | ||
|
|
50f27c110c | ||
|
|
c1f8997c01 | ||
|
|
12ed767961 | ||
|
|
335808217b | ||
|
|
b5bb7024fc | ||
|
|
ac2806dc66 | ||
|
|
9d149dab49 | ||
|
|
451404c0f9 | ||
|
|
a3f1f077ce | ||
|
|
dcd13833ae | ||
|
|
78b732b75c | ||
|
|
3152933937 | ||
|
|
6c6126bb27 | ||
|
|
ad13b7687d | ||
|
|
633e366e82 | ||
|
|
69b1786859 | ||
|
|
41839a961a | ||
|
|
ccb50c64bb | ||
|
|
d88ac1b424 | ||
|
|
c807611e55 | ||
|
|
155ec1403e | ||
|
|
ecde657c3e | ||
|
|
6aa8374f25 | ||
|
|
c2f01473fd | ||
|
|
41e607c995 | ||
|
|
fee754f15f | ||
|
|
d62d18232d | ||
|
|
a1acab0fed | ||
|
|
d39a7099c7 | ||
|
|
7b2c4f99f3 | ||
|
|
b941ff08dc | ||
|
|
210a74c75f | ||
|
|
eafce71cd0 | ||
|
|
d5777b501d | ||
|
|
dd60e5eb21 | ||
|
|
c57dbf23e1 | ||
|
|
cb52adb4de | ||
|
|
a75b0fc549 | ||
|
|
1a546b72d2 | ||
|
|
fd13d268d0 | ||
|
|
5837fc11a5 | ||
|
|
efdbbe40e8 | ||
|
|
5103cf1c12 | ||
|
|
e855a3de7d | ||
|
|
b90e38d595 | ||
|
|
1864f39be0 | ||
|
|
89e80b6f8b | ||
|
|
564295ee64 | ||
|
|
61c7f26007 | ||
|
|
54b9ece5b6 | ||
|
|
335136561a | ||
|
|
56f1cacd07 | ||
|
|
558e358d0b | ||
|
|
290e6eecc4 | ||
|
|
f512f55782 | ||
|
|
ad6eba58d2 | ||
|
|
b675aee8ff | ||
|
|
c29ea9c89c | ||
|
|
50016a31a8 | ||
|
|
1cecffc30f | ||
|
|
833273c19d | ||
|
|
35603d0caf | ||
|
|
de1377afdd | ||
|
|
a22ddd10cc | ||
|
|
280add7107 | ||
|
|
b34abcab56 | ||
|
|
788b26949e | ||
|
|
c11da3df3d | ||
|
|
0d68fea0eb | ||
|
|
c98672bcef | ||
|
|
bb0c079757 | ||
|
|
891144546e | ||
|
|
86611d764d | ||
|
|
e604268004 | ||
|
|
396c3da277 | ||
|
|
0591b90089 | ||
|
|
6763be53ac | ||
|
|
aee23f7550 | ||
|
|
57b5528d43 | ||
|
|
bd8cf18a1a | ||
|
|
84acdd901a | ||
|
|
e213a95996 | ||
|
|
78cd2eecf9 | ||
|
|
ebfeea1087 | ||
|
|
ddca623792 | ||
|
|
f0da35d115 | ||
|
|
10eb355f92 | ||
|
|
85702c534a | ||
|
|
e2dfe317a9 | ||
|
|
7008ae8672 | ||
|
|
10d9fa3581 | ||
|
|
2b85927739 | ||
|
|
8c402238ed | ||
|
|
758770d278 | ||
|
|
ee7ebb159b | ||
|
|
f47a66060f | ||
|
|
8929a630ed | ||
|
|
077f5d7547 | ||
|
|
369ad42946 | ||
|
|
4bd996eb46 | ||
|
|
a6ddc2b9f5 | ||
|
|
afb2e76b2d | ||
|
|
47812546e3 | ||
|
|
5ab6b85b30 | ||
|
|
9709d6348a | ||
|
|
10fea9b23d | ||
|
|
051dd5d128 | ||
|
|
369e18b1c2 | ||
|
|
fd49d17de4 | ||
|
|
e0f034fa99 | ||
|
|
80bc629ac2 | ||
|
|
1a90c23e76 | ||
|
|
48ddd4272f | ||
|
|
ea408ac7a4 | ||
|
|
79c48af3e9 | ||
|
|
12c2c0a132 | ||
|
|
78abfc2168 | ||
|
|
0f721b94c2 | ||
|
|
f126bf0ec0 | ||
|
|
064a435c79 | ||
|
|
c0e5bb872a | ||
|
|
e9561b5783 | ||
|
|
77c3a2f0c7 | ||
|
|
db4e64dc2a | ||
|
|
fcc05a8b19 | ||
|
|
c37d94e71b | ||
|
|
aa59bc4607 | ||
|
|
a993df4f0b | ||
|
|
7e83e0f085 | ||
|
|
5b2449adb0 | ||
|
|
5811912745 | ||
|
|
b7d6e9e497 | ||
|
|
5fd0cc3fe4 | ||
|
|
6e2e6f4492 | ||
|
|
ed6f7cfe5f | ||
|
|
6b17babd7a | ||
|
|
c77e4355fc | ||
|
|
25de65f41f | ||
|
|
af037ee215 | ||
|
|
39c4764b40 | ||
|
|
ec28d2aeb5 | ||
|
|
baf8635587 | ||
|
|
a3839a28a2 | ||
|
|
4247f545c5 | ||
|
|
c54c3ff5de | ||
|
|
1d84f07a40 | ||
|
|
b47c80dc11 | ||
|
|
75e2b2b337 | ||
|
|
08ca7fff35 | ||
|
|
3140df28b7 | ||
|
|
14546b8d74 | ||
|
|
341e896eca | ||
|
|
b3b4636e7f | ||
|
|
43f5aeb5dc | ||
|
|
d38aabf7b8 | ||
|
|
c64b8d16e6 | ||
|
|
1938fc3a27 | ||
|
|
36181396c7 | ||
|
|
bb76a3b214 | ||
|
|
339bb7fcd2 | ||
|
|
3a25057d05 | ||
|
|
14ab6032f5 | ||
|
|
3e1f785437 | ||
|
|
91bb9d6089 | ||
|
|
9cf373e9f4 | ||
|
|
9ec22e9000 | ||
|
|
9616860e23 | ||
|
|
0f6638dfdf | ||
|
|
fe9f49779c | ||
|
|
3aab6a207b | ||
|
|
49e9fd4b3b | ||
|
|
0143ae62aa | ||
|
|
2882f65b69 | ||
|
|
62e9f7f620 | ||
|
|
57f2ebf2b5 | ||
|
|
884134cab9 | ||
|
|
0c90de78ab | ||
|
|
1c5530ee2a | ||
|
|
fbb67a8fe6 | ||
|
|
c8c5f5dcb7 | ||
|
|
eb449eeca6 | ||
|
|
7cfdb10813 | ||
|
|
be599b5562 | ||
|
|
07d97a4937 | ||
|
|
7037d6f833 | ||
|
|
2deca322e7 | ||
|
|
ab84cb396d | ||
|
|
bb63c61ca8 | ||
|
|
f4256d1084 | ||
|
|
fc87b86f52 | ||
|
|
5dff617ed6 | ||
|
|
f2a171c596 | ||
|
|
336970c1f7 | ||
|
|
d04bc038d4 | ||
|
|
1ab12355f3 | ||
|
|
fb5d9fb7b0 | ||
|
|
9aae57f966 | ||
|
|
fefc82c521 | ||
|
|
43ebdcb10c | ||
|
|
adfb166320 | ||
|
|
1c54ae6465 | ||
|
|
9309f7d8cf | ||
|
|
8717fad08a | ||
|
|
983b4c7417 | ||
|
|
a663e9af14 | ||
|
|
249a606bf5 | ||
|
|
0ec8d8814d | ||
|
|
f0cee72f43 | ||
|
|
a82aafa6b8 | ||
|
|
3b624205ad | ||
|
|
c6d38e40d0 | ||
|
|
1ca7834feb | ||
|
|
77c9977d27 | ||
|
|
521130562d | ||
|
|
9512ca7e84 | ||
|
|
159901f79e | ||
|
|
9758568ded | ||
|
|
a953f83d07 | ||
|
|
2bbe2e6ba0 | ||
|
|
ec4ab8c078 | ||
|
|
7885c4ca9c | ||
|
|
8371ec3a01 | ||
|
|
43cbadcd2d | ||
|
|
6e1e2c3534 | ||
|
|
a46389e745 | ||
|
|
ce5f8d5fca | ||
|
|
bdbbce4cb0 | ||
|
|
034cb321ef | ||
|
|
ac2d453aa2 | ||
|
|
860d2d953f | ||
|
|
a486102747 | ||
|
|
735264cb2b | ||
|
|
7dece447df | ||
|
|
3fb5d6aae9 | ||
|
|
d5dfc33fce | ||
|
|
6dc40e50ad | ||
|
|
b8ebd8ca7e | ||
|
|
e9eb7d357f | ||
|
|
3f2cb8ac0c | ||
|
|
8e36c27ec9 | ||
|
|
fa90a3b821 | ||
|
|
2d2319fa27 | ||
|
|
ba161b7b92 | ||
|
|
d438c8b256 | ||
|
|
bbb4555c0e | ||
|
|
6e8ea96f32 | ||
|
|
b06de77f42 | ||
|
|
3aa8279914 | ||
|
|
a64724e114 | ||
|
|
052234a3d9 | ||
|
|
aa2c565afd | ||
|
|
d0fd225d99 | ||
|
|
c40acdd7c7 | ||
|
|
590610022d | ||
|
|
f7dae32f50 | ||
|
|
3cbbc0837f | ||
|
|
0197e70a1a | ||
|
|
40a7d9e398 | ||
|
|
c7b2dcbbbe | ||
|
|
a524e18820 | ||
|
|
81894d691b | ||
|
|
0fb192dd41 | ||
|
|
3f608c6c8e | ||
|
|
dcff9ee73d | ||
|
|
f1cd66d3e8 | ||
|
|
d95fef09a3 | ||
|
|
d1060e724e | ||
|
|
a7faaaac86 | ||
|
|
3762f95bae | ||
|
|
b9f04deb28 | ||
|
|
9cc820c76e | ||
|
|
f7195fb40c | ||
|
|
9c0f029992 | ||
|
|
f370b8ad1e | ||
|
|
7c962ec43a | ||
|
|
2013a6692e | ||
|
|
4a5eb91f16 | ||
|
|
a606801bb0 | ||
|
|
db395e40e8 | ||
|
|
1c498a2c71 | ||
|
|
e4a647d425 | ||
|
|
c1a91ab610 | ||
|
|
093007a72b | ||
|
|
cb5cdb09f7 | ||
|
|
8f2e1ad7e7 | ||
|
|
ed6bcd60b3 | ||
|
|
edbe131f1b | ||
|
|
881e537a89 | ||
|
|
abb286f4a8 | ||
|
|
04ffc0a589 | ||
|
|
4cccdeeff0 | ||
|
|
2295cf4756 | ||
|
|
dcd63ff093 | ||
|
|
c4bf076d0a | ||
|
|
5b32602aca | ||
|
|
82167ab5ec | ||
|
|
aac963a21c | ||
|
|
fc84d846b2 | ||
|
|
7674593bfe | ||
|
|
500dc809bf | ||
|
|
b4e9c52301 | ||
|
|
6efac246ff | ||
|
|
bec841e833 | ||
|
|
25ebfbcccf | ||
|
|
74c2bed572 | ||
|
|
a1672dfa45 | ||
|
|
abe494e2b0 | ||
|
|
fabcfc1377 | ||
|
|
9f89507ce4 | ||
|
|
f277557e83 | ||
|
|
59dfdf1400 | ||
|
|
322b25db00 | ||
|
|
a2b424de93 | ||
|
|
09e6e5e534 | ||
|
|
5450c8ddf1 | ||
|
|
99e772e9f6 | ||
|
|
69fb3f1dbd | ||
|
|
e9c4f9f9bb | ||
|
|
fa6e46f453 | ||
|
|
2d6bafecaf | ||
|
|
7dffc3def7 | ||
|
|
922f4be14f | ||
|
|
e7bab87e6e | ||
|
|
dd67501337 | ||
|
|
4f60f2fa9a | ||
|
|
7cb215be52 | ||
|
|
e2b472cf44 | ||
|
|
64c46ac87f | ||
|
|
334b782ca3 | ||
|
|
9e214243c9 | ||
|
|
77bf84fef4 | ||
|
|
cde6046ffb | ||
|
|
af4ac98357 | ||
|
|
6f66813db0 | ||
|
|
f42fef85b7 | ||
|
|
400f448108 | ||
|
|
4659735c12 | ||
|
|
c08fe0e54b | ||
|
|
37d4a1c81a | ||
|
|
857971517f | ||
|
|
51d16c9026 | ||
|
|
6b4fdf53eb | ||
|
|
09dd5b7c5f | ||
|
|
c182767e5e | ||
|
|
efa93a1529 | ||
|
|
6efd1e54a8 | ||
|
|
231bd03d9a | ||
|
|
38c096b4d6 | ||
|
|
68ca275673 | ||
|
|
de05612873 | ||
|
|
bcb0c7471b | ||
|
|
f856f79e51 | ||
|
|
fbea08dded | ||
|
|
dc52376395 | ||
|
|
1e2bc0ca85 | ||
|
|
7849d6098a | ||
|
|
0b101b758e | ||
|
|
756ff0e0c1 | ||
|
|
2ddeca7d29 | ||
|
|
1de934e30a | ||
|
|
0409c526ee | ||
|
|
a7c340188f | ||
|
|
5dc54849cc | ||
|
|
058fbee0c8 | ||
|
|
daea76eef3 | ||
|
|
b81b1d94a3 | ||
|
|
226b7c741c | ||
|
|
e452bd761c | ||
|
|
19c3d97a46 | ||
|
|
64164b28de | ||
|
|
b03cbe7a0a | ||
|
|
91bd702deb | ||
|
|
ede22e10e9 | ||
|
|
c648ad4ae3 | ||
|
|
a0fbd1129a | ||
|
|
b90ea03924 | ||
|
|
88efe2ad87 | ||
|
|
39f933418e | ||
|
|
cc8c7d84e2 | ||
|
|
6d9783c5f5 | ||
|
|
c10d46b774 | ||
|
|
a5417277a8 | ||
|
|
5a1c931513 | ||
|
|
09f4503593 | ||
|
|
045cae66f5 | ||
|
|
e67ef5fb0f | ||
|
|
1677c0021f | ||
|
|
3400e747a8 | ||
|
|
c08ff4843c | ||
|
|
5e905eae17 | ||
|
|
7ebe73ccb3 | ||
|
|
574911cfa0 | ||
|
|
91a84dc4f4 | ||
|
|
9a578e54a8 | ||
|
|
1a8d5eded3 | ||
|
|
94765057db | ||
|
|
86c639ad2c | ||
|
|
f3045debb3 | ||
|
|
ffc826f04d | ||
|
|
e70a4bbf1c | ||
|
|
a9eca51bfa | ||
|
|
de4a0aecd5 | ||
|
|
f097fac8ea | ||
|
|
a20b35f7cd | ||
|
|
2d51a0e159 | ||
|
|
e524077242 | ||
|
|
a5b5fe8d2a | ||
|
|
ddb3e0e02d | ||
|
|
ec97ec428a | ||
|
|
49b834af5d | ||
|
|
6329aaf6a9 | ||
|
|
fd6e5ab084 | ||
|
|
1dbb1b9ba3 | ||
|
|
fc65a63d43 | ||
|
|
4d1ddde57b | ||
|
|
7d14fb5341 | ||
|
|
e310b3aec8 | ||
|
|
056457d028 | ||
|
|
73183e760e | ||
|
|
3fff729d32 | ||
|
|
19e199bd58 | ||
|
|
7b0f5801a1 | ||
|
|
4dbea91a5e | ||
|
|
548356aaa4 | ||
|
|
8b3f4c636c | ||
|
|
1b0212f3bb | ||
|
|
14e9ec0b45 | ||
|
|
dab113ab76 | ||
|
|
ad729769ec | ||
|
|
bb4ae549a3 | ||
|
|
0786642a38 | ||
|
|
77d5f5884e | ||
|
|
7a0a23b7fd | ||
|
|
1207a5eec1 | ||
|
|
48a0ecad41 | ||
|
|
f792feeb6c | ||
|
|
d6b682a23a | ||
|
|
d77d547c15 | ||
|
|
0dc828fa85 | ||
|
|
bd00a5f414 | ||
|
|
f587ad4bbc | ||
|
|
b8f2b1e3f8 | ||
|
|
bf3b81f1cd | ||
|
|
9375a6fce4 | ||
|
|
ca148f490e | ||
|
|
9ab0468913 | ||
|
|
99a73a80db | ||
|
|
80ba6e6c79 | ||
|
|
7a4f0f0d3f | ||
|
|
a9a6c6ae15 | ||
|
|
c5190fc2a6 | ||
|
|
93b543e1a4 | ||
|
|
4d77ca9c11 | ||
|
|
03cf3d1e3c | ||
|
|
d2134f3467 | ||
|
|
97fe411ac0 | ||
|
|
ff953b4535 | ||
|
|
b68fb9a9f9 | ||
|
|
ad1895d6e1 | ||
|
|
51b4ea3b16 | ||
|
|
3d86a38a2e | ||
|
|
605aad1df3 | ||
|
|
3d2e3534ff | ||
|
|
2a65ffe6c4 | ||
|
|
e3e3e03249 | ||
|
|
6bd8642c91 | ||
|
|
40ae842f82 | ||
|
|
a2ab1aa49e | ||
|
|
6d3cbf32df | ||
|
|
7a8138773c | ||
|
|
5ae79deca4 | ||
|
|
285f593a0f | ||
|
|
e0ae621b62 | ||
|
|
259d4dabc8 | ||
|
|
f7b03d9622 | ||
|
|
bd188a8ca7 | ||
|
|
28910a9332 | ||
|
|
ee38252e1a | ||
|
|
6f3595db31 | ||
|
|
c5c0cd9cd2 | ||
|
|
f5da32d233 | ||
|
|
315869dafe | ||
|
|
330802e29b | ||
|
|
3d5e988ef0 | ||
|
|
9a2b4b3d1e | ||
|
|
4a1ebcdbc6 | ||
|
|
bc66fdb2b6 | ||
|
|
6c2b0557c6 | ||
|
|
c24dc41811 | ||
|
|
0a41d32084 | ||
|
|
b7aa087b74 | ||
|
|
1f511d964d | ||
|
|
14eabecb7d | ||
|
|
8e31aa431d | ||
|
|
e47a636c2d | ||
|
|
bdd14b16de | ||
|
|
8cc5aacb9a | ||
|
|
d8e861dd54 | ||
|
|
e369f9eb21 | ||
|
|
ce6246e479 | ||
|
|
0c58cc6687 | ||
|
|
19687b725d | ||
|
|
02dee5fb6c | ||
|
|
e08a1ad514 | ||
|
|
865f1064f5 | ||
|
|
2b798e5a69 | ||
|
|
da4d2ecade | ||
|
|
89540d8b76 | ||
|
|
931709d277 | ||
|
|
1b199750bd | ||
|
|
4b1fa710f7 | ||
|
|
360b6f4cf0 | ||
|
|
e960438968 | ||
|
|
93230c45c7 | ||
|
|
a3fd30c427 | ||
|
|
c9bac6f7d2 | ||
|
|
06c0b64ee5 | ||
|
|
70a1181ea1 | ||
|
|
be9938fe95 | ||
|
|
27acfa9489 | ||
|
|
fc26125f80 | ||
|
|
193a5c1e4d | ||
|
|
700722627a | ||
|
|
1f6996d881 | ||
|
|
1cbad9ccfd | ||
|
|
04c9de648d | ||
|
|
2b60a0f76f | ||
|
|
176ff80172 | ||
|
|
e2d150ce15 | ||
|
|
636004a1bb | ||
|
|
2258bb36b9 | ||
|
|
0583a59177 | ||
|
|
fbbcfc5a57 | ||
|
|
317da71ec3 | ||
|
|
22bbcfe3e5 | ||
|
|
de3832f3ce | ||
|
|
04b07d6bfb | ||
|
|
fdf00d8acc | ||
|
|
3d2296858f | ||
|
|
b1390cd74a | ||
|
|
216d92cad7 | ||
|
|
dcde41f89a | ||
|
|
1e9518ac54 | ||
|
|
ac2aa8297c | ||
|
|
8611798051 | ||
|
|
a659897d37 | ||
|
|
b4303fa81c | ||
|
|
8f0a75a81b | ||
|
|
e006476b52 | ||
|
|
bb7bfc4a6e | ||
|
|
03c3f6b6a2 | ||
|
|
900f3c2471 | ||
|
|
cd86acdcf0 | ||
|
|
abf338f71a | ||
|
|
91bb0cba45 | ||
|
|
8499e3197d | ||
|
|
bfc3568b00 | ||
|
|
26613f8b68 | ||
|
|
0b2d4b5f89 | ||
|
|
6561ec9404 | ||
|
|
517df8a3ad | ||
|
|
4a0cdc0e87 | ||
|
|
a54bc99925 | ||
|
|
eeae98360f | ||
|
|
c5a938d4eb | ||
|
|
c5b7dbd50f | ||
|
|
26cd478c6f | ||
|
|
670805f18a | ||
|
|
8b851d2220 | ||
|
|
7a1279b02c | ||
|
|
3f2a49dedf | ||
|
|
d0f792c107 | ||
|
|
ff9172001f | ||
|
|
0b1772b30d | ||
|
|
64b77cab6b | ||
|
|
8aecd9dbce | ||
|
|
0bdd0fd801 | ||
|
|
0ede2cd96b | ||
|
|
8219dcecd7 | ||
|
|
42a5fdf9a1 | ||
|
|
9bc6f44af4 | ||
|
|
ccb31c5866 | ||
|
|
30f9ebf900 | ||
|
|
9b4eb2d383 | ||
|
|
6e2f765e69 | ||
|
|
57fd88556d | ||
|
|
034f5b217e | ||
|
|
a5112e0bbb | ||
|
|
f6db5fa14e | ||
|
|
d7de41d642 | ||
|
|
ca0d1a4f90 | ||
|
|
46fc9143b4 | ||
|
|
0800cff6fc | ||
|
|
3106b56dee | ||
|
|
08b1ac04bb | ||
|
|
1a55cc482c | ||
|
|
73aa7ad890 | ||
|
|
95db3da58f | ||
|
|
31926e6ad9 | ||
|
|
71a8ad2418 | ||
|
|
49bd1c8836 | ||
|
|
fc3dd62858 | ||
|
|
ff114a0ae5 | ||
|
|
8bfcb6cd9a | ||
|
|
8154e30939 | ||
|
|
506440825f | ||
|
|
646723fcf8 | ||
|
|
a560a49aac | ||
|
|
de763d68f3 | ||
|
|
f731c25332 | ||
|
|
f738eb92e8 | ||
|
|
b9f4f43d30 | ||
|
|
e42f00604e | ||
|
|
74eed98b9b | ||
|
|
cef6f5864a | ||
|
|
d5e816b1d3 | ||
|
|
dcc4201019 | ||
|
|
9c093a8455 | ||
|
|
9a56a3ee4d | ||
|
|
f2b0714dea | ||
|
|
0a1357acc7 | ||
|
|
403af711eb | ||
|
|
31cd72cd15 | ||
|
|
55afe876e0 | ||
|
|
49fadd8c9a | ||
|
|
58157ddfee | ||
|
|
2a9cd87848 | ||
|
|
855d3f6144 |
@@ -0,0 +1,38 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
|
||||
{
|
||||
"name": "Node.js",
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
"image": "mcr.microsoft.com/devcontainers/javascript-node:1-18-bookworm",
|
||||
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
"forwardPorts": [9078,3000],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "npm install",
|
||||
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"hbenl.vscode-mocha-test-adapter",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"unifiedjs.vscode-mdx"
|
||||
]
|
||||
}
|
||||
},
|
||||
"portsAttributes": {
|
||||
"3000": {
|
||||
"label": "Docusaurus"
|
||||
},
|
||||
"9078": {
|
||||
"label": "App"
|
||||
}
|
||||
}
|
||||
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
||||
@@ -2,12 +2,29 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
Dockerfile
|
||||
*.Dockerfile
|
||||
.dockerignore
|
||||
.gitignore
|
||||
.github
|
||||
.git
|
||||
config/currentCreds.json
|
||||
*.log
|
||||
config/maloja.json
|
||||
config/spotify.json
|
||||
config/config.json
|
||||
.idea
|
||||
config/*.json
|
||||
config/*.bak
|
||||
config/*.p8
|
||||
/docs
|
||||
/logs
|
||||
.flatpak-builder
|
||||
**/generated-sources.*
|
||||
flatpak/.flatpak-builder
|
||||
docsite/build
|
||||
docsite/node_modules
|
||||
docsite/.docusaurus
|
||||
docsite/.cache-loader
|
||||
build
|
||||
*.secrets
|
||||
.devcontainer
|
||||
.vscode
|
||||
**/act/*
|
||||
tmp-*
|
||||
.env
|
||||
@@ -0,0 +1,59 @@
|
||||
name: Bug Report
|
||||
description: Report a reproducible problem
|
||||
title: "bug: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Please check existing knowledge before opening an issue
|
||||
options:
|
||||
- label: I have [checked the FAQ](https://foxxmd.github.io/multi-scrobbler/docs/FAQ/) and [documentation](https://foxxmd.github.io/multi-scrobbler/docs/configuration/)
|
||||
required: true
|
||||
- label: I have [searched through existing issues](https://github.com/FoxxMD/multi-scrobbler/issues?q=sort%3Aupdated-desc) and [discussions.](https://github.com/FoxxMD/multi-scrobbler/discussions)
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
If this a how-do-I question, troublehooting, or generally **not** a bug/error with multi-scrobbler please consider [**starting a discussion**](https://github.com/FoxxMD/multi-scrobbler/discussions) instead of opening an issue.
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: Describe the Bug
|
||||
description: Describe what you expected to happen and what happened instead. Please provide as much detail as possible.
|
||||
placeholder: The app should have scrobbled from Spotify but instead it crashed with an error about undefined properties, etc...
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Platform
|
||||
description: What platform are you running multi-scrobbler on?
|
||||
options:
|
||||
- Docker
|
||||
- Local (NodeJS)
|
||||
- Flatpak
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Versions
|
||||
description: What version of multi-scrobbler and issue-related sources/clients are running?
|
||||
placeholder: |
|
||||
- multi-scrobbler: 0.8.3 on docker
|
||||
- maloja 3.1.4
|
||||
- jellyfin 10.8.9
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
description: |
|
||||
Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
|
||||
If possible reproduce the issue with [**Debug Mode** enabled](https://foxxmd.github.io/multi-scrobbler/docs/configuration#debug-mode)
|
||||
render: shell
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context about the problem here.
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or functionality
|
||||
title: "feat: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Please check existing knowledge before opening an issue
|
||||
options:
|
||||
- label: I have checked the [documentation](https://foxxmd.github.io/multi-scrobbler/docs/configuration/)
|
||||
required: true
|
||||
- label: I have [searched through existing issues](https://github.com/FoxxMD/multi-scrobbler/issues?q=sort%3Aupdated-desc) and [discussions.](https://github.com/FoxxMD/multi-scrobbler/discussions)
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the new functionality
|
||||
description: Please describe the feature or functionality you want multi-scrobbler to have. Use as much detail as possible about the exact behavior you wish to see.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Configuration Example
|
||||
description: |
|
||||
If the functionality involves changes to configuration files please provide an example here
|
||||
render: json5
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Will you help implement it?
|
||||
options:
|
||||
- label: I am willing to contribute code towards completing this feature
|
||||
- label: I am willing to help test preview builds for this feature
|
||||
@@ -0,0 +1,75 @@
|
||||
name: Request a new Source or Client
|
||||
description: Request a new Source or Client to be implemented
|
||||
title: "feat: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Please check existing knowledge before opening an issue
|
||||
options:
|
||||
- label: I have checked the [documentation](https://foxxmd.github.io/multi-scrobbler/docs/configuration/)
|
||||
required: true
|
||||
- label: I have [searched through existing issues](https://github.com/FoxxMD/multi-scrobbler/issues?q=sort%3Aupdated-desc) and [discussions.](https://github.com/FoxxMD/multi-scrobbler/discussions)
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: What Type?
|
||||
description: Is this a Source or a Client?
|
||||
options:
|
||||
- Source
|
||||
- Client
|
||||
default: 1
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Name
|
||||
description: "What is the name of the new service/software?"
|
||||
placeholder: "My Cool Player"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Website
|
||||
description: "Website for the service/software. Preferably a github repo or other source-code homepage"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: API Available?
|
||||
description: Does the service have an API or other protocol that multi-scrobbler can use to communicate? **Without this the likelyhood of implementation is very low.**
|
||||
options:
|
||||
- 'Yes'
|
||||
- 'No'
|
||||
default: 1
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Free To Use?
|
||||
description: Is this service/software freely available or does it require purchasing/subscription?
|
||||
options:
|
||||
- 'Yes'
|
||||
- 'No'
|
||||
default: 1
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Willing to finance development?
|
||||
description: If the software is **non-free** are you will to provide a paid account/subscription to multi-scrobbler developer's in order to facilitate development/testing?
|
||||
options:
|
||||
- 'Yes'
|
||||
- 'No'
|
||||
validations:
|
||||
required: false
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Will you help implement it?
|
||||
options:
|
||||
- label: I am willing to contribute code towards completing this feature
|
||||
- label: I am willing to help test preview builds for this feature
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context about the new Source/Client here
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -0,0 +1,5 @@
|
||||
--directory ../../
|
||||
--env-file .github/act/.env
|
||||
--secret-file .github/act/.secrets
|
||||
--artifact-server-path out
|
||||
#--action-offline-mode
|
||||
@@ -0,0 +1,5 @@
|
||||
NO_DOCKER_BUILD=true
|
||||
#https://github.com/nektos/act/issues/329#issuecomment-1905955589
|
||||
ACTIONS_RUNTIME_URL=http://YOUR_HOST_IP:8082/
|
||||
ACTIONS_RUNTIME_TOKEN=foo
|
||||
ACTIONS_CACHE_URL=http://YOUR_HOST_IP:8082/
|
||||
@@ -0,0 +1,3 @@
|
||||
DOCKER_PASSWORD=
|
||||
DOCKER_USERNAME=
|
||||
GITHUB_TOKEN=
|
||||
@@ -0,0 +1,53 @@
|
||||
Testing GH Actions with ACT
|
||||
|
||||
Need to have credentials in a [`.secrets` file](https://nektosact.com/usage/index.html#secrets), copy and rename [`.secrets.example`](./secrets.example) to `.secrets`, then fill out blank fields. Required for docker/metadata-action to read...something. Fails with `Parameter token or opts.auth is required` if they are not supplied.
|
||||
|
||||
An ENV file can also be made by copy and renaming [`.env.example`](./env.example). Set `NO_DOCKER_BUILD=true` if you only want to test APP_VERSION and docker tags output.
|
||||
|
||||
If running a full docker build for multi-runner workflows you will need to create an [artifact server](https://github.com/nektos/act/issues/329#issuecomment-1905955589) for ACT to work:
|
||||
|
||||
|
||||
```shell
|
||||
docker pull ghcr.io/jefuller/artifact-server:latest
|
||||
docker run -d --name artifact-server -p 8082:8080 --add-host host.docker.internal:host-gateway -e AUTH_KEY=foo ghcr.io/jefuller/artifact-server:latest
|
||||
```
|
||||
|
||||
Run the following **from this directory** to make use of `.actrc` and proper working directory.
|
||||
|
||||
### Test Branch Test Suite
|
||||
|
||||
```shell
|
||||
act -W '.github/act/testSuite.yml' -e '.github/act/actBranchEvent.json'
|
||||
```
|
||||
|
||||
### Test Branch Push
|
||||
|
||||
```shell
|
||||
act -W '.github/act/actTest.yml' -e '.github/act/actBranchEvent.json'
|
||||
```
|
||||
|
||||
|
||||
### Test Tag (Release) Push
|
||||
|
||||
```shell
|
||||
act -W '.github/act/actTest.yml' -e '.github/act/actTagEvent.json'
|
||||
```
|
||||
|
||||
|
||||
### Test Tag (Pre-Release) Push
|
||||
|
||||
```shell
|
||||
act -W '.github/act/actTest.yml' -e '.github/act/actTagPreEvent.json'
|
||||
```
|
||||
|
||||
### Test Multi-Runner Push (WIP)
|
||||
|
||||
```shell
|
||||
act -W '.github/act/multiRunnerTest.yml' -e '.github/act/actBranchEvent.json'
|
||||
```
|
||||
|
||||
### Test Multi-Runner Bake Push (WIP)
|
||||
|
||||
```shell
|
||||
act -W '.github/act/multiRunnerBakeTest.yml' -e '.github/act/actBranchEvent.json'
|
||||
```
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"ref": "refs/heads/master",
|
||||
"sha": "14d5eb3313110f2cfd1aa8fe9c229c21901c1eeb"
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
{
|
||||
"action": "synchronize",
|
||||
"number": 210,
|
||||
"pull_request": {
|
||||
"url": "https://api.github.com/repos/FoxxMD/multi-scrobbler/pulls/210",
|
||||
"id": 2123337348,
|
||||
"node_id": "PR_kwDOEpP-x85-j46E",
|
||||
"number": 210,
|
||||
"state": "open",
|
||||
"locked": false,
|
||||
"merge_commit_sha": "349bea1f4580b9e61aa22446eae73f8b1225bbb7",
|
||||
"labels": [
|
||||
{
|
||||
"id": 2494982061,
|
||||
"node_id": "MDU6TGFiZWwyNDk0OTgyMDYx",
|
||||
"url": "https://api.github.com/repos/FoxxMD/multi-scrobbler/labels/bug",
|
||||
"name": "bug",
|
||||
"color": "d73a4a",
|
||||
"default": true,
|
||||
"description": "Something isn't working"
|
||||
},
|
||||
{
|
||||
"id": 7131135993,
|
||||
"node_id": "LA_kwDOEpP-x88AAAABqQx_-Q",
|
||||
"url": "https://api.github.com/repos/FoxxMD/multi-scrobbler/labels/safe%20to%20test",
|
||||
"name": "safe to test",
|
||||
"color": "D4C5F9",
|
||||
"default": false,
|
||||
"description": "trusted to build image"
|
||||
}
|
||||
],
|
||||
"milestone": null,
|
||||
"draft": false,
|
||||
"head": {
|
||||
"label": "FoxxMD:GH-209/jellyfinCleanup",
|
||||
"ref": "GH-209/jellyfinCleanup",
|
||||
"sha": "e251da9b8eecfcb3aba8756393e5075d0092794b",
|
||||
"user": {
|
||||
"login": "FoxxMD",
|
||||
"id": 4663766,
|
||||
"node_id": "MDQ6VXNlcjQ2NjM3NjY=",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/4663766?v=4",
|
||||
"gravatar_id": "",
|
||||
"url": "https://api.github.com/users/FoxxMD",
|
||||
"html_url": "https://github.com/FoxxMD",
|
||||
"followers_url": "https://api.github.com/users/FoxxMD/followers",
|
||||
"following_url": "https://api.github.com/users/FoxxMD/following{/other_user}",
|
||||
"gists_url": "https://api.github.com/users/FoxxMD/gists{/gist_id}",
|
||||
"starred_url": "https://api.github.com/users/FoxxMD/starred{/owner}{/repo}",
|
||||
"subscriptions_url": "https://api.github.com/users/FoxxMD/subscriptions",
|
||||
"organizations_url": "https://api.github.com/users/FoxxMD/orgs",
|
||||
"repos_url": "https://api.github.com/users/FoxxMD/repos",
|
||||
"events_url": "https://api.github.com/users/FoxxMD/events{/privacy}",
|
||||
"received_events_url": "https://api.github.com/users/FoxxMD/received_events",
|
||||
"type": "User",
|
||||
"site_admin": false
|
||||
},
|
||||
"repo": {
|
||||
"id": 311688903,
|
||||
"node_id": "MDEwOlJlcG9zaXRvcnkzMTE2ODg5MDM=",
|
||||
"name": "multi-scrobbler",
|
||||
"full_name": "FoxxMD/multi-scrobbler",
|
||||
"private": false,
|
||||
"owner": {
|
||||
"login": "FoxxMD",
|
||||
"id": 4663766,
|
||||
"node_id": "MDQ6VXNlcjQ2NjM3NjY=",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/4663766?v=4",
|
||||
"gravatar_id": "",
|
||||
"url": "https://api.github.com/users/FoxxMD",
|
||||
"html_url": "https://github.com/FoxxMD",
|
||||
"followers_url": "https://api.github.com/users/FoxxMD/followers",
|
||||
"following_url": "https://api.github.com/users/FoxxMD/following{/other_user}",
|
||||
"gists_url": "https://api.github.com/users/FoxxMD/gists{/gist_id}",
|
||||
"starred_url": "https://api.github.com/users/FoxxMD/starred{/owner}{/repo}",
|
||||
"subscriptions_url": "https://api.github.com/users/FoxxMD/subscriptions",
|
||||
"organizations_url": "https://api.github.com/users/FoxxMD/orgs",
|
||||
"repos_url": "https://api.github.com/users/FoxxMD/repos",
|
||||
"events_url": "https://api.github.com/users/FoxxMD/events{/privacy}",
|
||||
"received_events_url": "https://api.github.com/users/FoxxMD/received_events",
|
||||
"type": "User",
|
||||
"site_admin": false
|
||||
},
|
||||
"html_url": "https://github.com/FoxxMD/multi-scrobbler",
|
||||
"description": "Scrobble plays from multiple sources to multiple clients",
|
||||
"fork": false,
|
||||
"created_at": "2020-11-10T14:45:38Z",
|
||||
"updated_at": "2024-10-14T09:21:37Z",
|
||||
"pushed_at": "2024-10-14T15:43:00Z",
|
||||
"allow_forking": true,
|
||||
"is_template": false,
|
||||
"web_commit_signoff_required": false,
|
||||
"visibility": "public",
|
||||
"forks": 16,
|
||||
"open_issues": 19,
|
||||
"watchers": 369,
|
||||
"default_branch": "master",
|
||||
"allow_squash_merge": true,
|
||||
"allow_merge_commit": true,
|
||||
"allow_rebase_merge": true,
|
||||
"allow_auto_merge": false,
|
||||
"delete_branch_on_merge": false,
|
||||
"allow_update_branch": false,
|
||||
"use_squash_pr_title_as_default": false,
|
||||
"squash_merge_commit_message": "COMMIT_MESSAGES",
|
||||
"squash_merge_commit_title": "COMMIT_OR_PR_TITLE",
|
||||
"merge_commit_message": "PR_TITLE",
|
||||
"merge_commit_title": "MERGE_MESSAGE"
|
||||
}
|
||||
},
|
||||
"base": {
|
||||
"label": "FoxxMD:master",
|
||||
"ref": "master",
|
||||
"sha": "9c21db8172b6beff9d321e831e9353bf17e92a37",
|
||||
"repo": {
|
||||
"id": 311688903,
|
||||
"node_id": "MDEwOlJlcG9zaXRvcnkzMTE2ODg5MDM=",
|
||||
"name": "multi-scrobbler",
|
||||
"full_name": "FoxxMD/multi-scrobbler",
|
||||
"private": false,
|
||||
"fork": false,
|
||||
"created_at": "2020-11-10T14:45:38Z",
|
||||
"updated_at": "2024-10-14T09:21:37Z",
|
||||
"pushed_at": "2024-10-14T15:43:00Z",
|
||||
"allow_forking": true,
|
||||
"is_template": false,
|
||||
"web_commit_signoff_required": false,
|
||||
"visibility": "public",
|
||||
"forks": 16,
|
||||
"open_issues": 19,
|
||||
"watchers": 369,
|
||||
"default_branch": "master",
|
||||
"allow_squash_merge": true,
|
||||
"allow_merge_commit": true,
|
||||
"allow_rebase_merge": true,
|
||||
"allow_auto_merge": false,
|
||||
"delete_branch_on_merge": false,
|
||||
"allow_update_branch": false,
|
||||
"use_squash_pr_title_as_default": false,
|
||||
"squash_merge_commit_message": "COMMIT_MESSAGES",
|
||||
"squash_merge_commit_title": "COMMIT_OR_PR_TITLE",
|
||||
"merge_commit_message": "PR_TITLE",
|
||||
"merge_commit_title": "MERGE_MESSAGE"
|
||||
}
|
||||
},
|
||||
"author_association": "OWNER",
|
||||
"auto_merge": null,
|
||||
"active_lock_reason": null,
|
||||
"merged": false,
|
||||
"mergeable": null,
|
||||
"rebaseable": null,
|
||||
"mergeable_state": "unknown",
|
||||
"merged_by": null,
|
||||
"comments": 0,
|
||||
"review_comments": 0,
|
||||
"maintainer_can_modify": false,
|
||||
"commits": 5,
|
||||
"additions": 84,
|
||||
"deletions": 28,
|
||||
"changed_files": 5
|
||||
},
|
||||
"before": "335ee0cd601f5a82e36643c812507b2b53e7daf0",
|
||||
"after": "e251da9b8eecfcb3aba8756393e5075d0092794b"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"ref": "refs/tags/0.8.4",
|
||||
"ref_name": "0.8.4",
|
||||
"before": "0000000000000000000000000000000000000000",
|
||||
"after": "005dae76ab51799d3d55112738e301cb1af0dafd",
|
||||
"sha": "005dae76ab51799d3d55112738e301cb1af0dafd",
|
||||
"base_ref": "refs/heads/master"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"ref": "refs/tags/0.8.5-gh-runner",
|
||||
"ref_name": "0.8.5-gh-runner",
|
||||
"before": "0000000000000000000000000000000000000000",
|
||||
"after": "005dae76ab51799d3d55112738e301cb1af0dafd",
|
||||
"sha": "005dae76ab51799d3d55112738e301cb1af0dafd",
|
||||
"base_ref": "refs/heads/master"
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
name: Publish Docker image to Dockerhub
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
tags:
|
||||
- '*.*.*'
|
||||
# don't trigger if just updating docs
|
||||
paths-ignore:
|
||||
- 'README.md'
|
||||
- '.github/**'
|
||||
- 'flatpak/**'
|
||||
# use release instead of tags once version is correctly parsed
|
||||
# https://github.com/docker/metadata-action/issues/422
|
||||
# https://github.com/docker/metadata-action/issues/240
|
||||
# release:
|
||||
# types: [ published ]
|
||||
|
||||
jobs:
|
||||
|
||||
push_to_registry:
|
||||
name: Build and push container images
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Debug
|
||||
run: echo $JSON
|
||||
env:
|
||||
JSON: ${{ toJSON(github) }}
|
||||
|
||||
- name: Set git state to ENV
|
||||
id: vars
|
||||
# https://dev.to/hectorleiva/github-actions-and-creating-a-short-sha-hash-8b7
|
||||
# short sha available under env.COMMIT_SHORT_SHA
|
||||
run: |
|
||||
calculatedSha=$(git rev-parse --short HEAD)
|
||||
branchName=$(git rev-parse --abbrev-ref HEAD)
|
||||
echo "COMMIT_SHORT_SHA=$calculatedSha" >> $GITHUB_ENV
|
||||
echo "COMMIT_BRANCH=$branchName" >> $GITHUB_ENV
|
||||
|
||||
- name: Check App Version
|
||||
env:
|
||||
# use release instead of tags once version is correctly parsed
|
||||
#APP_VERSION: ${{ github.event.release.tag_name }}
|
||||
|
||||
# https://github.com/actions/runner/issues/409#issuecomment-752775072
|
||||
# https://stackoverflow.com/a/69919067/1469797
|
||||
APP_VERSION: ${{ contains(github.ref, 'refs/tags/') && github.ref_name || format('{0}-{1}', env.COMMIT_BRANCH, env.COMMIT_SHORT_SHA ) }}
|
||||
run: |
|
||||
echo $APP_VERSION
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
# generate Docker tags based on the following events/attributes
|
||||
# https://github.com/docker/metadata-action/issues/247#issuecomment-1511259674 for NOT is default branch, eventually
|
||||
tags: |
|
||||
type=edge
|
||||
|
||||
# maybe re-enable branch-named tags in the futures
|
||||
#type=ref,event=branch,enable=${{ !endsWith(github.ref, 'master') }}
|
||||
|
||||
# tag non-prelease as latest -- has a higher priority than regular tag so it shows first in registries
|
||||
type=match,pattern=\d.\d.\d$,priority=901
|
||||
|
||||
# tag all semver (include pre-release)
|
||||
type=semver,pattern={{version}}
|
||||
# flavor: |
|
||||
# latest=false
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
id: qemu
|
||||
if: ${{ !env.NO_DOCKER_BUILD }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
if: ${{ steps.qemu.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
|
||||
- name: Build and push Docker image
|
||||
if: ${{ steps.qemu.outcome == 'success' }}
|
||||
env:
|
||||
# use release instead of tags once version is correctly parsed
|
||||
#APP_VERSION: ${{ github.event.release.tag_name }}
|
||||
|
||||
# https://github.com/actions/runner/issues/409#issuecomment-752775072
|
||||
# https://stackoverflow.com/a/69919067/1469797
|
||||
APP_VERSION: ${{ contains(github.ref, 'refs/tags/') && github.ref_name || format('{0}-{1}', env.COMMIT_BRANCH, env.COMMIT_SHORT_SHA ) }}
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
# https://github.com/docker/build-push-action/issues/1026#issue-2041857786
|
||||
build-args: |
|
||||
APP_BUILD_VERSION=${{env.APP_VERSION}}
|
||||
push: false
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -0,0 +1,30 @@
|
||||
variable "DEFAULT_TAG" {
|
||||
default = "multi-scrobbler:local"
|
||||
}
|
||||
|
||||
// Special target: https://github.com/docker/metadata-action#bake-definition
|
||||
target "docker-metadata-action" {
|
||||
tags = ["${DEFAULT_TAG}"]
|
||||
}
|
||||
|
||||
// Default target if none specified
|
||||
group "default" {
|
||||
targets = ["image-local"]
|
||||
}
|
||||
|
||||
target "image" {
|
||||
inherits = ["docker-metadata-action"]
|
||||
}
|
||||
|
||||
target "image-local" {
|
||||
inherits = ["image"]
|
||||
output = ["type=docker"]
|
||||
}
|
||||
|
||||
target "image-all" {
|
||||
inherits = ["image"]
|
||||
platforms = [
|
||||
"linux/amd64",
|
||||
"linux/arm64"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
# Based on https://docs.docker.com/build/ci/github-actions/multi-platform/#with-bake (https://github.com/docker/build-push-action/issues/671#issuecomment-1609106171)
|
||||
# with example from here https://github.com/crazy-max/docker-linguist/blob/master/.github/workflows/build.yml that uses caching
|
||||
#
|
||||
# may have to use this to enable pushing to multiple registries? https://github.com/docker/build-push-action/discussions/1067#discussioncomment-8677682
|
||||
|
||||
name: build
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
tags:
|
||||
- '*.*.*'
|
||||
paths-ignore:
|
||||
- 'README.md'
|
||||
- '.github/**'
|
||||
- 'flatpak/**'
|
||||
|
||||
env:
|
||||
DOCKERHUB_SLUG: foxxmd/multi-scrobbler
|
||||
GHCR_SLUG: ghcr.io/foxxmd/multi-scrobbler
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.platforms.outputs.matrix }}
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set short git commit SHA
|
||||
id: appvars
|
||||
# https://dev.to/hectorleiva/github-actions-and-creating-a-short-sha-hash-8b7
|
||||
# short sha available under env.COMMIT_SHORT_SHA
|
||||
run: |
|
||||
calculatedSha=$(git rev-parse --short HEAD)
|
||||
branchName=$(git rev-parse --abbrev-ref HEAD)
|
||||
echo "COMMIT_SHORT_SHA=$calculatedSha" >> $GITHUB_ENV
|
||||
echo "COMMIT_BRANCH=$branchName" >> $GITHUB_ENV
|
||||
|
||||
- name: Get App Version
|
||||
id: appversion
|
||||
env:
|
||||
# use release instead of tags once version is correctly parsed
|
||||
#APP_VERSION: ${{ github.event.release.tag_name }}
|
||||
|
||||
# https://github.com/actions/runner/issues/409#issuecomment-752775072
|
||||
# https://stackoverflow.com/a/69919067/1469797
|
||||
APP_VERSION: ${{ contains(github.ref, 'refs/tags/') && github.ref_name || format('{0}-{1}', env.COMMIT_BRANCH, env.COMMIT_SHORT_SHA ) }}
|
||||
run: |
|
||||
echo appversion=$APP_VERSION >>${GITHUB_OUTPUT}
|
||||
|
||||
-
|
||||
name: Create matrix
|
||||
id: platforms
|
||||
run: |
|
||||
echo "matrix=$(docker buildx bake --file ./docker-bake.hcl image-all --print | jq -cr '.target."image-all".platforms')" >>${GITHUB_OUTPUT}
|
||||
-
|
||||
name: Show matrix
|
||||
run: |
|
||||
echo ${{ steps.platforms.outputs.matrix }}
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKERHUB_SLUG }}
|
||||
${{ env.GHCR_SLUG }}
|
||||
tags: |
|
||||
type=edge
|
||||
|
||||
# maybe re-enable branch-named tags in the futures
|
||||
#type=ref,event=branch,enable=${{ !endsWith(github.ref, 'master') }}
|
||||
|
||||
# tag non-prelease as latest -- has a higher priority than regular tag so it shows first in registries
|
||||
type=match,pattern=\d.\d.\d$,priority=901
|
||||
|
||||
# tag all semver (include pre-release)
|
||||
type=semver,pattern={{version}}
|
||||
labels: |
|
||||
org.opencontainers.image.title=Multi-Scrobbler
|
||||
org.opencontainers.image.description=Scrobble from many sources to many clients
|
||||
org.opencontainers.image.vendor=FoxxMD
|
||||
-
|
||||
name: Rename meta bake definition file
|
||||
run: |
|
||||
mv "${{ steps.meta.outputs.bake-file }}" "/tmp/bake-meta.json"
|
||||
-
|
||||
name: Upload meta bake definition
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: bake-meta
|
||||
path: /tmp/bake-meta.json
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- prepare
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: ${{ fromJson(needs.prepare.outputs.matrix) }}
|
||||
steps:
|
||||
-
|
||||
name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Test App Version Transfer
|
||||
run: |
|
||||
echo "App Version in Build ${{ needs.prepare.appversion.outputs.appversion }}"
|
||||
-
|
||||
name: Download meta bake definition
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: bake-meta
|
||||
path: /tmp
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
buildkitd-flags: "--debug"
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
-
|
||||
name: Login to GHCR
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
-
|
||||
name: Build
|
||||
id: bake
|
||||
uses: docker/bake-action@v5
|
||||
with:
|
||||
files: |
|
||||
./.github/act/docker-bake.hcl
|
||||
/tmp/bake-meta.json
|
||||
targets: image
|
||||
set: |
|
||||
*.args.APP_BUILD_VERSION=${{ needs.prepare.appversion.outputs.appversion }}
|
||||
*.tags=
|
||||
*.platform=${{ matrix.platform }}
|
||||
*.cache-from=type=gha,scope=build-${{ env.PLATFORM_PAIR }}
|
||||
*.cache-to=type=gha,scope=build-${{ env.PLATFORM_PAIR }}
|
||||
*.output=type=image,"name=${{ env.DOCKERHUB_SLUG }},${{ env.GHCR_SLUG }}",push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
|
||||
-
|
||||
name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ fromJSON(steps.bake.outputs.metadata).image['containerimage.digest'] }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
-
|
||||
name: Upload digest
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request'
|
||||
needs:
|
||||
- build
|
||||
steps:
|
||||
-
|
||||
name: Download meta bake definition
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: bake-meta
|
||||
path: /tmp
|
||||
-
|
||||
name: Download digests
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
-
|
||||
name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
-
|
||||
name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
-
|
||||
name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.target."docker-metadata-action".tags | map(select(startswith("${{ env.DOCKERHUB_SLUG }}")) | "-t " + .) | join(" ")' /tmp/bake-meta.json) \
|
||||
$(printf '${{ env.DOCKERHUB_SLUG }}@sha256:%s ' *)
|
||||
docker buildx imagetools create $(jq -cr '.target."docker-metadata-action".tags | map(select(startswith("${{ env.GHCR_SLUG }}")) | "-t " + .) | join(" ")' /tmp/bake-meta.json) \
|
||||
$(printf '${{ env.GHCR_SLUG }}@sha256:%s ' *)
|
||||
-
|
||||
name: Inspect image
|
||||
run: |
|
||||
tag=$(jq -r '.target."docker-metadata-action".args.DOCKER_META_VERSION' /tmp/bake-meta.json)
|
||||
docker buildx imagetools inspect ${{ env.DOCKERHUB_SLUG }}:${tag}
|
||||
docker buildx imagetools inspect ${{ env.GHCR_SLUG }}:${tag}
|
||||
@@ -0,0 +1,198 @@
|
||||
# Based on https://docs.docker.com/build/ci/github-actions/multi-platform/#distribute-build-across-multiple-runners
|
||||
|
||||
name: build
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
tags:
|
||||
- '*.*.*'
|
||||
paths-ignore:
|
||||
- 'README.md'
|
||||
- '.github/**'
|
||||
- 'flatpak/**'
|
||||
|
||||
env:
|
||||
DOCKERHUB_SLUG: foxxmd/multi-scrobbler
|
||||
GHCR_SLUG: ghcr.io/foxxmd/multi-scrobbler
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
steps:
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set short git commit SHA
|
||||
id: appvars
|
||||
# https://dev.to/hectorleiva/github-actions-and-creating-a-short-sha-hash-8b7
|
||||
# short sha available under env.COMMIT_SHORT_SHA
|
||||
run: |
|
||||
calculatedSha=$(git rev-parse --short HEAD)
|
||||
branchName=$(git rev-parse --abbrev-ref HEAD)
|
||||
echo "COMMIT_SHORT_SHA=$calculatedSha" >> $GITHUB_ENV
|
||||
echo "COMMIT_BRANCH=$branchName" >> $GITHUB_ENV
|
||||
|
||||
- name: Get App Version
|
||||
id: appversion
|
||||
env:
|
||||
# use release instead of tags once version is correctly parsed
|
||||
#APP_VERSION: ${{ github.event.release.tag_name }}
|
||||
|
||||
# https://github.com/actions/runner/issues/409#issuecomment-752775072
|
||||
# https://stackoverflow.com/a/69919067/1469797
|
||||
APP_VERSION: ${{ contains(github.ref, 'refs/tags/') && github.ref_name || format('{0}-{1}', env.COMMIT_BRANCH, env.COMMIT_SHORT_SHA ) }}
|
||||
run: |
|
||||
echo appversion=$APP_VERSION >>${GITHUB_OUTPUT}
|
||||
|
||||
# - name: Extract metadata (tags, labels) for Docker
|
||||
# id: meta
|
||||
# uses: docker/metadata-action@v5
|
||||
# with:
|
||||
# images: |
|
||||
# ${{ env.DOCKERHUB_SLUG }}
|
||||
# ${{ env.GHCR_SLUG }}
|
||||
# labels: |
|
||||
# org.opencontainers.image.title=Multi-Scrobbler
|
||||
# org.opencontainers.image.description=Scrobble from many sources to many clients
|
||||
# org.opencontainers.image.vendor=FoxxMD
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# https://github.com/docker/build-push-action/issues/671#issuecomment-1619353328
|
||||
# for caching
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
build-args: |
|
||||
APP_BUILD_VERSION=${{steps.appversion.outputs.appversion}}
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
#tags: ${{ steps.meta.outputs.tags }}
|
||||
outputs: type=image,"name=${{ env.DOCKERHUB_SLUG }},${{ env.GHCR_SLUG }}",push-by-digest=true,name-canonical=true,push=true
|
||||
#cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
|
||||
#cache-to: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
needs:
|
||||
- build
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKERHUB_SLUG }}
|
||||
${{ env.GHCR_SLUG }}
|
||||
# generate Docker tags based on the following events/attributes
|
||||
# https://github.com/docker/metadata-action/issues/247#issuecomment-1511259674 for NOT is default branch, eventually
|
||||
tags: |
|
||||
type=edge
|
||||
|
||||
# maybe re-enable branch-named tags in the futures
|
||||
type=ref,event=branch,enable=${{ !endsWith(github.ref, 'master') }}
|
||||
|
||||
# tag non-prelease as latest -- has a higher priority than regular tag so it shows first in registries
|
||||
type=match,pattern=\d.\d.\d$,priority=901
|
||||
|
||||
# tag all semver (include pre-release)
|
||||
type=semver,pattern={{version}}
|
||||
# flavor: |
|
||||
# latest=false
|
||||
labels: |
|
||||
org.opencontainers.image.title=Multi-Scrobbler
|
||||
org.opencontainers.image.description=Scrobble from many sources to many clients
|
||||
org.opencontainers.image.vendor=FoxxMD
|
||||
|
||||
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.DOCKERHUB_SLUG }}@sha256:%s ' *)
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.GHCR_SLUG }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.DOCKERHUB_SLUG }}:${{ steps.meta.outputs.version }}
|
||||
docker buildx imagetools inspect ${{ env.GHCR_SLUG }}:${{ steps.meta.outputs.version }}
|
||||
@@ -0,0 +1,10 @@
|
||||
name: PR Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
uses: ./.github/workflows/testAndSanity.yml
|
||||
@@ -0,0 +1,19 @@
|
||||
## Checklist before requesting a review
|
||||
|
||||
- [ ] I have read the [contributing guidelines.](../CONTRIBUTING.md)
|
||||
|
||||
## Type of change
|
||||
|
||||
Please delete options that are not relevant.
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] This change requires a documentation update
|
||||
|
||||
## Describe your changes
|
||||
|
||||
|
||||
|
||||
## Issue number and link, if applicable
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
name: Test Dependabot PR
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- synchronize
|
||||
- reopened
|
||||
- opened
|
||||
branches:
|
||||
- 'master'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Tests / Build / Sanity Run
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
uses: ./.github/workflows/testAndSanity.yml
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [released]
|
||||
# Review gh actions docs if you want to further define triggers, paths, etc
|
||||
# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#on
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to GitHub Pages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install app dev dependencies
|
||||
run: npm ci
|
||||
- name: Generate schema files
|
||||
run: npm run schema
|
||||
|
||||
- name: Install docusaurus dependencies
|
||||
run: npm ci
|
||||
working-directory: ./docsite
|
||||
- name: Build website
|
||||
env:
|
||||
ANALYTICS: ${{ vars.ANALYTICS }}
|
||||
ANALYTICS_DOMAIN: ${{ vars.ANALYTICS_DOMAIN }}
|
||||
DOCS_BASE: '/multi-scrobbler'
|
||||
run: npm run build
|
||||
working-directory: ./docsite
|
||||
|
||||
# Popular action to deploy to GitHub Pages:
|
||||
# Docs: https://github.com/peaceiris/actions-gh-pages#%EF%B8%8F-docusaurus
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Build output to publish to the `gh-pages` branch:
|
||||
publish_dir: ./docsite/build
|
||||
# The following lines assign commit authorship to the official
|
||||
# GH-Actions bot for deploys to `gh-pages` branch:
|
||||
# https://github.com/actions/checkout/issues/13#issuecomment-724415212
|
||||
# The GH actions bot is used by default if you didn't specify the two fields.
|
||||
# You can swap them out with your own user credentials.
|
||||
user_name: github-actions[bot]
|
||||
user_email: 41898282+github-actions[bot]@users.noreply.github.com
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Delete Untagged Packages
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 1 * * 0'
|
||||
workflow_run:
|
||||
workflows: ["Publish Docker image to Dockerhub"]
|
||||
types:
|
||||
- completed
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
delete-untagged:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- uses: dataaxiom/ghcr-cleanup-action@v1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# maybe set up for PR close later
|
||||
# https://github.com/dataaxiom/ghcr-cleanup-action?tab=readme-ov-file#delete-image-when-pull-request-is-closed
|
||||
@@ -0,0 +1,218 @@
|
||||
name: PR Workflow
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.head.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- labeled
|
||||
- synchronize
|
||||
- reopened
|
||||
- opened
|
||||
branches:
|
||||
- 'master'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: contains(github.event.pull_request.labels.*.name, 'safe to test')
|
||||
uses: ./.github/workflows/testAndSanity.yml
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
build-snapshot:
|
||||
name: Build OCI snapshot
|
||||
needs: test
|
||||
if: contains(github.event.pull_request.labels.*.name, 'safe to test')
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
arch: amd64
|
||||
platform: linux/amd64
|
||||
- os: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
platform: linux/arm64
|
||||
steps:
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
# list all registries to push to and join all non-empty with comma
|
||||
# https://unix.stackexchange.com/a/693165/116849
|
||||
# https://stackoverflow.com/a/9429887/1469797
|
||||
strings=("${{vars.DOCKERHUB_SLUG}}" "${{vars.GHCR_SLUG}}")
|
||||
for i in ${!strings[@]}; do [[ -z ${strings[i]} ]] && unset strings[i]; done
|
||||
joined_string=$(IFS=, ; echo "${strings[*]}")
|
||||
echo "REGISTRIES_JOINED=$joined_string" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set short git commit SHA
|
||||
run: |
|
||||
short=$(echo "${{ github.event.pull_request.head.sha }}" | head -c7)
|
||||
echo "COMMIT_SHORT_SHA=$short" >> $GITHUB_ENV
|
||||
|
||||
- name: Get App Version
|
||||
id: appversion
|
||||
env:
|
||||
APP_VERSION: ${{ format('pr{0}-{1}', github.event.number, env.COMMIT_SHORT_SHA ) }}
|
||||
run: |
|
||||
echo appversion=$APP_VERSION >>${GITHUB_OUTPUT}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ vars.DOCKERHUB_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ vars.GHCR_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# metadata extract for docker labels/image names is done in merge job
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# https://github.com/docker/build-push-action/issues/671#issuecomment-1619353328
|
||||
# for caching
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
build-args: |
|
||||
APP_BUILD_VERSION=${{steps.appversion.outputs.appversion}}
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,"name=${{ env.REGISTRIES_JOINED }}",push-by-digest=true,name-canonical=true,push=true
|
||||
#cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
|
||||
#cache-to: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
release-snapshot:
|
||||
name: Merge OCI Images and Push
|
||||
if: ${{ vars.DOCKERHUB_SLUG != '' || vars.GHCR_SLUG != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
needs:
|
||||
- build-snapshot
|
||||
- test
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ vars.DOCKERHUB_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ vars.GHCR_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ vars.DOCKERHUB_SLUG }}
|
||||
${{ vars.GHCR_SLUG }}
|
||||
# generate Docker tags based on the following events/attributes
|
||||
# https://github.com/docker/metadata-action/issues/247#issuecomment-1511259674 for NOT is default branch, eventually
|
||||
tags: |
|
||||
type=ref,event=pr
|
||||
flavor: |
|
||||
latest=false
|
||||
labels: |
|
||||
org.opencontainers.image.title=Multi-Scrobbler
|
||||
org.opencontainers.image.description=Scrobble from many sources to many clients
|
||||
org.opencontainers.image.vendor=FoxxMD
|
||||
|
||||
- name: Create manifest list and push dockerhub
|
||||
if: ${{ vars.DOCKERHUB_SLUG != '' }}
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ vars.DOCKERHUB_SLUG }}@sha256:%s ' *)
|
||||
|
||||
- name: Create manifest list and push gchr
|
||||
if: ${{ vars.GHCR_SLUG != '' }}
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ vars.GHCR_SLUG }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image dockerhub
|
||||
if: ${{ vars.DOCKERHUB_SLUG != '' }}
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ vars.DOCKERHUB_SLUG }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
- name: Inspect image ghcr
|
||||
if: ${{ vars.GHCR_SLUG != '' }}
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ vars.GHCR_SLUG }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
|
||||
combine-and-comment:
|
||||
name: Leave comment
|
||||
runs-on: ubuntu-latest
|
||||
needs: release-snapshot
|
||||
if: contains(github.event.pull_request.labels.*.name, 'safe to test')
|
||||
steps:
|
||||
- name: Create comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
with:
|
||||
recreate: true
|
||||
header: "pr-release"
|
||||
message: |
|
||||
#### :package: A new release has been made for this pull request.
|
||||
|
||||
To play around with this PR, pull an image:
|
||||
* `foxxmd/multi-scrobbler:pr-${{ github.event.number }}`
|
||||
|
||||
Images are available for x86_64 and ARM64.
|
||||
|
||||
> Latest commit: ${{ github.event.pull_request.head.sha }}
|
||||
@@ -0,0 +1,228 @@
|
||||
# Based on https://docs.docker.com/build/ci/github-actions/multi-platform/#distribute-build-across-multiple-runners
|
||||
|
||||
name: Publish Docker image to Dockerhub
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
tags:
|
||||
- '*.*.*'
|
||||
# don't trigger if just updating docs
|
||||
paths-ignore:
|
||||
- 'README.md'
|
||||
- '.github/**'
|
||||
- 'flatpak/**'
|
||||
# use release instead of tags once version is correctly parsed
|
||||
# https://github.com/docker/metadata-action/issues/422
|
||||
# https://github.com/docker/metadata-action/issues/240
|
||||
# release:
|
||||
# types: [ published ]
|
||||
|
||||
# define in GH Repository -> Actions -> Variables (or act .variables) to enable pushing to registries
|
||||
# -- will only push to registries that are defined
|
||||
# EX
|
||||
# DOCKERHUB_SLUG=foxxmd/multi-scrobbler
|
||||
# GHCR_SLUG=ghcr.io/foxxmd/multi-scrobbler
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: ./.github/workflows/testAndSanity.yml
|
||||
|
||||
build:
|
||||
name: Build OCI Images
|
||||
if: ${{ github.event_name != 'pull_request' && (vars.DOCKERHUB_SLUG != '' || vars.GHCR_SLUG != '') }}
|
||||
needs: test
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
arch: amd64
|
||||
platform: linux/amd64
|
||||
- os: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
platform: linux/arm64
|
||||
steps:
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
# list all registries to push to and join all non-empty with comma
|
||||
# https://unix.stackexchange.com/a/693165/116849
|
||||
# https://stackoverflow.com/a/9429887/1469797
|
||||
strings=("${{vars.DOCKERHUB_SLUG}}" "${{vars.GHCR_SLUG}}")
|
||||
for i in ${!strings[@]}; do [[ -z ${strings[i]} ]] && unset strings[i]; done
|
||||
joined_string=$(IFS=, ; echo "${strings[*]}")
|
||||
echo "REGISTRIES_JOINED=$joined_string" >> $GITHUB_ENV
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set short git commit SHA
|
||||
id: appvars
|
||||
# https://dev.to/hectorleiva/github-actions-and-creating-a-short-sha-hash-8b7
|
||||
# short sha available under env.COMMIT_SHORT_SHA
|
||||
run: |
|
||||
calculatedSha=$(git rev-parse --short HEAD)
|
||||
branchName=$(git rev-parse --abbrev-ref HEAD)
|
||||
echo "COMMIT_SHORT_SHA=$calculatedSha" >> $GITHUB_ENV
|
||||
echo "COMMIT_BRANCH=$branchName" >> $GITHUB_ENV
|
||||
|
||||
- name: Get App Version
|
||||
id: appversion
|
||||
env:
|
||||
# use release instead of tags once version is correctly parsed
|
||||
#APP_VERSION: ${{ github.event.release.tag_name }}
|
||||
|
||||
# https://github.com/actions/runner/issues/409#issuecomment-752775072
|
||||
# https://stackoverflow.com/a/69919067/1469797
|
||||
APP_VERSION: ${{ contains(github.ref, 'refs/tags/') && github.ref_name || format('{0}-{1}', env.COMMIT_BRANCH, env.COMMIT_SHORT_SHA ) }}
|
||||
run: |
|
||||
echo appversion=$APP_VERSION >>${GITHUB_OUTPUT}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ github.event_name != 'pull_request' && vars.DOCKERHUB_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ github.event_name != 'pull_request' && vars.GHCR_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# metadata extract for docker labels/image names is done in merge job
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# https://github.com/docker/build-push-action/issues/671#issuecomment-1619353328
|
||||
# for caching
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
build-args: |
|
||||
APP_BUILD_VERSION=${{steps.appversion.outputs.appversion}}
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,"name=${{ env.REGISTRIES_JOINED }}",push-by-digest=true,name-canonical=true,push=true
|
||||
#cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
|
||||
#cache-to: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
name: Merge OCI Images and Push
|
||||
if: ${{ github.event_name != 'pull_request' && (vars.DOCKERHUB_SLUG != '' || vars.GHCR_SLUG != '') }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
needs:
|
||||
- build
|
||||
- test
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ github.event_name != 'pull_request' && vars.DOCKERHUB_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ github.event_name != 'pull_request' && vars.GHCR_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ vars.DOCKERHUB_SLUG }}
|
||||
${{ vars.GHCR_SLUG }}
|
||||
# generate Docker tags based on the following events/attributes
|
||||
# https://github.com/docker/metadata-action/issues/247#issuecomment-1511259674 for NOT is default branch, eventually
|
||||
tags: |
|
||||
type=edge
|
||||
|
||||
# push with branch name as tag if not master/main
|
||||
type=ref,event=branch,enable=${{ !endsWith(github.ref, 'master') }}
|
||||
|
||||
# tag non-prelease as latest -- has a higher priority than regular tag so it shows first in registries
|
||||
type=match,pattern=\d.\d.\d$,priority=901
|
||||
|
||||
# tag all semver (include pre-release)
|
||||
type=semver,pattern={{version}}
|
||||
# flavor: |
|
||||
# latest=false
|
||||
labels: |
|
||||
org.opencontainers.image.title=Multi-Scrobbler
|
||||
org.opencontainers.image.description=Scrobble from many sources to many clients
|
||||
org.opencontainers.image.vendor=FoxxMD
|
||||
|
||||
- name: Create manifest list and push dockerhub
|
||||
if: ${{ vars.DOCKERHUB_SLUG != '' }}
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ vars.DOCKERHUB_SLUG }}@sha256:%s ' *)
|
||||
|
||||
- name: Create manifest list and push gchr
|
||||
if: ${{ vars.GHCR_SLUG != '' }}
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ vars.GHCR_SLUG }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image dockerhub
|
||||
if: ${{ vars.DOCKERHUB_SLUG != '' }}
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ vars.DOCKERHUB_SLUG }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
- name: Inspect image ghcr
|
||||
if: ${{ vars.GHCR_SLUG != '' }}
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ vars.GHCR_SLUG }}:${{ steps.meta.outputs.version }}
|
||||
@@ -0,0 +1,65 @@
|
||||
name: Tests and Sanity Run
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
node-version:
|
||||
description: "Node version"
|
||||
required: false
|
||||
default: '20.x'
|
||||
type: string
|
||||
ref:
|
||||
description: "Ref to checkout"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Tests / Build / Sanity Run
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install concurrently
|
||||
run: npm install -g concurrently
|
||||
|
||||
- name: Install dev dependencies
|
||||
run: npm run install:parallel
|
||||
|
||||
- name: Test Backend
|
||||
run: npm run test
|
||||
|
||||
- name: Build
|
||||
run: npm run build:parallel
|
||||
|
||||
# remove modules that might include dev stuff
|
||||
# so that in the next step we are sure that prod-only runs work correctly
|
||||
- name: Install Prod Deps
|
||||
run: |
|
||||
rm -rf node_modules && \
|
||||
rm -rf docsite/node_modules && \
|
||||
NODE_ENV=production npm ci --omit=dev
|
||||
|
||||
# run app for 10 seconds as sanity check to see if it errors for any reason
|
||||
# easy testcase for missing packages and init errors
|
||||
- name: Sanity Run
|
||||
run: |
|
||||
set +e
|
||||
export NODE_ENV=production
|
||||
timeout --preserve-status 10s node node_modules/.bin/tsx src/backend/index.ts
|
||||
exitcode="$?"
|
||||
if [[ "$exitcode" -eq 143 ]] || [[ "$exitcode" -eq 137 ]]; then
|
||||
echo "App stayed up long enough and exited with expected status"
|
||||
exit 0
|
||||
else
|
||||
echo "App exited with unexpected code $exitcode"
|
||||
exit "$exitcode"
|
||||
fi
|
||||
@@ -1,7 +1,7 @@
|
||||
# Created by .ignore support plugin (hsz.mobi)
|
||||
### Node template
|
||||
# Logs
|
||||
logs
|
||||
/logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
@@ -117,6 +117,22 @@ dist
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
*.json
|
||||
config/*.json
|
||||
config/yti-*
|
||||
*.txt
|
||||
.idea/
|
||||
|
||||
src/**/**.js
|
||||
src/**/**.js.map
|
||||
|
||||
*.bak
|
||||
*.p8
|
||||
.flatpak-builder
|
||||
flatpak/generated-sources.json
|
||||
*.secrets
|
||||
|
||||
build
|
||||
!setupProxy.js
|
||||
**/generated-sources.*
|
||||
.github/act/out
|
||||
tmp-*
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"$schema": "https://lnav.org/schemas/format-v1.schema.json",
|
||||
"error_log": {
|
||||
"title": "Multi-Scrobbler Log",
|
||||
"description": "Logs from Multi Scrobbler",
|
||||
"regex": {
|
||||
"std": {
|
||||
"pattern": "^\\[(?<timestamp>\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(?:\\.\\d{3})?)(?:.*?)\\] (?<level>[A-Z]*) : (?<body>.*)"
|
||||
}
|
||||
},
|
||||
"level-field": "level",
|
||||
"level": {
|
||||
"info": "^(?i)(?:info)$",
|
||||
"debug": "^(?i)(?:debug)$",
|
||||
"error": "^(?i)(error)$",
|
||||
"warning": "^(?i)(warn)$",
|
||||
"trace": "^(?i)verbose$",
|
||||
"critical": "^(?i)(?:crit|Cr)$",
|
||||
"fatal": "^(?i)(?:alert|fatal|panic|Al|Em)$"
|
||||
},
|
||||
"sample": [
|
||||
{
|
||||
"line": "[2024-12-26 01:58:48.261 -0500] DEBUG : [App] [Sources] [Jellyfin - unnamed] Last activity was at 01:58:48-05:00 | Next check in 10.00s | No new tracks discovered",
|
||||
"level": "debug"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"reporter": "dot",
|
||||
"extension": "ts",
|
||||
"import": "tsx/esm",
|
||||
"spec": "./src/backend/tests/**/*.test.ts",
|
||||
"exit": true
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "@istanbuljs/nyc-config-typescript",
|
||||
"exclude": [
|
||||
"node_modules/",
|
||||
"**/src/backend/common/schema/**",
|
||||
"**/src/backend/tests/**",
|
||||
"register.js",
|
||||
"**/src/server/**/*.d.ts",
|
||||
"**/src/client/**"
|
||||
],
|
||||
"include": [
|
||||
"**/src/backend/**/*.ts",
|
||||
"**/src/**/backend/*.js",
|
||||
"**/src/**/backend/*.js.map"
|
||||
],
|
||||
"extension": [
|
||||
".ts"
|
||||
],
|
||||
"reporter": [
|
||||
"text-summary",
|
||||
"html"
|
||||
],
|
||||
"report-dir": "./coverage"
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "dev",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
// Debug app in VSCode
|
||||
"program": "${workspaceFolder}/src/backend/index.ts",
|
||||
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**",
|
||||
// Ignore all dependencies (optional)
|
||||
"${workspaceFolder}/node_modules/**",
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "tsx",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
// Debug current file in VSCode
|
||||
"program": "${file}",
|
||||
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**",
|
||||
// Ignore all dependencies (optional)
|
||||
"${workspaceFolder}/node_modules/**",
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "schema",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
// Debug app in VSCode
|
||||
"program": "${workspaceFolder}/src/backend/utils/SchemaStaticUtil.ts",
|
||||
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**",
|
||||
// Ignore all dependencies (optional)
|
||||
//"${workspaceFolder}/node_modules/**",
|
||||
],
|
||||
"resolveSourceMapLocations": [
|
||||
"${workspaceFolder}/src/**/*",
|
||||
"${workspaceFolder}/node_modules/**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"args": [
|
||||
"-u",
|
||||
"bdd",
|
||||
"--timeout",
|
||||
"999999",
|
||||
"--reporter",
|
||||
"spec",
|
||||
"--colors",
|
||||
"--config",
|
||||
"${workspaceRoot}/.mocharc.json",
|
||||
"--recursive",
|
||||
"${workspaceFolder}/src/backend/tests/**/*.test.ts"
|
||||
],
|
||||
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
|
||||
"internalConsoleOptions": "openOnSessionStart",
|
||||
"name": "Mocha Tests",
|
||||
"program": "${workspaceFolder}/node_modules/mocha/bin/_mocha",
|
||||
"request": "launch",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**",
|
||||
// Ignore all dependencies (optional)
|
||||
"${workspaceFolder}/node_modules/**",
|
||||
],
|
||||
"type": "node"
|
||||
},
|
||||
{
|
||||
"args": [
|
||||
"-u",
|
||||
"bdd",
|
||||
"--timeout",
|
||||
"999999",
|
||||
"--reporter",
|
||||
"spec",
|
||||
"--colors",
|
||||
"--config", "${workspaceRoot}/.mocharc.json",
|
||||
"${file}"
|
||||
],
|
||||
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
|
||||
"internalConsoleOptions": "openOnSessionStart",
|
||||
"name": "Mocha Test on File",
|
||||
"program": "${workspaceFolder}/node_modules/mocha/bin/_mocha",
|
||||
"request": "launch",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**",
|
||||
// Ignore all dependencies (optional)
|
||||
"${workspaceFolder}/node_modules/**",
|
||||
],
|
||||
"type": "node"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"mochaExplorer.require": "tsx/esm",
|
||||
"mochaExplorer.timeout": 1200000,
|
||||
"mochaExplorer.exit": true
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "typescript",
|
||||
"tsconfig": "src/backend/tsconfig.json",
|
||||
"problemMatcher": [
|
||||
"$tsc"
|
||||
],
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
"label": "tsc: build - src/backend/tsconfig.json"
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"label": "compose-up-dev",
|
||||
"command": "docker-compose -f docker-compose.dev.yml up",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"label": "compose-build-dev",
|
||||
"command": "docker-compose -f docker-compose.dev.yml build",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
},
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Creating a Pull Request
|
||||
|
||||
Please follow these guidelines when contributing code to this repository:
|
||||
|
||||
* Use [conventional commit](https://www.conventionalcommits.org/en/v1.0.0/#summary) format when creating commits.
|
||||
* Preferably, please use a [feature branch](https://stackoverflow.com/a/39586780/1469797) instead of committing directly to `master`.
|
||||
* Ensure that if your code is covered by [an existing test](./src/backend/tests) that you have updated the test accordingly
|
||||
@@ -1,35 +1,116 @@
|
||||
FROM node:fermium-alpine3.10
|
||||
FROM ghcr.io/linuxserver/baseimage-debian:bookworm AS base
|
||||
|
||||
ENV TZ=Etc/GMT
|
||||
ENV NODE_VERSION=20.18.1
|
||||
|
||||
# borrowing openssl header removal trick from offical docker-node
|
||||
# https://github.com/nodejs/docker-node/blob/main/18/bookworm-slim/Dockerfile#L8
|
||||
RUN \
|
||||
ARCH= OPENSSL_ARCH= && dpkgArch="$(dpkg --print-architecture)" \
|
||||
&& case "${dpkgArch##*-}" in \
|
||||
amd64) ARCH='x64' OPENSSL_ARCH='linux-x86_64';; \
|
||||
ppc64el) ARCH='ppc64le' OPENSSL_ARCH='linux-ppc64le';; \
|
||||
s390x) ARCH='s390x' OPENSSL_ARCH='linux*-s390x';; \
|
||||
arm64) ARCH='arm64' OPENSSL_ARCH='linux-aarch64';; \
|
||||
armhf) ARCH='armv7l' OPENSSL_ARCH='linux-armv4';; \
|
||||
i386) ARCH='x86' OPENSSL_ARCH='linux-elf';; \
|
||||
*) echo "unsupported architecture"; exit 1 ;; \
|
||||
esac && \
|
||||
set -ex && \
|
||||
echo "**** install build packages ****" && \
|
||||
apt-get update && \
|
||||
apt-get install --no-install-recommends -y \
|
||||
#ca-certificates \
|
||||
xz-utils \
|
||||
avahi-utils \
|
||||
curl && \
|
||||
echo "**** Fetch and install node****" && \
|
||||
# get node/npm directly from nodejs dist \
|
||||
# https://github.com/nodejs/docker-node/blob/main/18/bookworm-slim/Dockerfile#L41
|
||||
curl -fsSLO --compressed "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-$ARCH.tar.xz" && \
|
||||
tar -xJf "node-v$NODE_VERSION-linux-$ARCH.tar.xz" -C /usr --strip-components=1 --no-same-owner && \
|
||||
rm "node-v$NODE_VERSION-linux-$ARCH.tar.xz" && \
|
||||
ln -s /usr/bin/node /usr/bin/nodejs && \
|
||||
npm update -g npm && \
|
||||
echo "**** cleanup ****" && \
|
||||
# https://github.com/nodejs/docker-node/blob/main/18/bookworm-slim/Dockerfile#L49
|
||||
# Remove unused OpenSSL headers to save ~34MB
|
||||
# (does not affect arm64 issue below)
|
||||
find /usr/include/node/openssl/archs -mindepth 1 -maxdepth 1 ! -name "$OPENSSL_ARCH" -exec rm -rf {} \; && \
|
||||
apt-get purge --auto-remove -y perl xz-utils && \
|
||||
apt-get autoclean && \
|
||||
apt-get autoremove && \
|
||||
rm -rf \
|
||||
/config/.cache \
|
||||
/root/cache \
|
||||
/var/lib/apt/lists/* \
|
||||
/var/tmp/* \
|
||||
/tmp/*
|
||||
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node
|
||||
RUN npm install -g concurrently
|
||||
|
||||
WORKDIR /home/node/app
|
||||
ARG data_dir=/config
|
||||
VOLUME $data_dir
|
||||
ENV CONFIG_DIR=$data_dir
|
||||
|
||||
COPY package*.json ./
|
||||
COPY docker/root /
|
||||
|
||||
USER node
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm install --production
|
||||
FROM base AS build
|
||||
|
||||
COPY --chown=node:node . .
|
||||
COPY --chown=abc:abc package*.json tsconfig.json ./
|
||||
COPY --chown=abc:abc patches ./patches
|
||||
COPY --chown=abc:abc docsite/package*.json tsconfig.json ./docsite/
|
||||
|
||||
ENV NPM_CONFIG_LOGLEVEL debug
|
||||
# for debugging, so the build fails faster when timing out (arm64)
|
||||
#RUN npm config set fetch-retries 1 && \
|
||||
# npm config set fetch-retry-mintimeout 5000 && \
|
||||
# npm config set fetch-retry-maxtimeout 5000
|
||||
|
||||
ARG config_dir=/home/node/config
|
||||
RUN mkdir -p $config_dir
|
||||
VOLUME $config_dir
|
||||
ENV CONFIG_DIR=$config_dir
|
||||
# https://www.npmjs.com/package/tls-test
|
||||
# used to test that the OS supports downloading packages over HTTPS with TLS 1.2 enforced
|
||||
# -- this always succeeds but a good sanity check
|
||||
#RUN npm install -g https://tls-test.npmjs.com/tls-test-1.0.0.tgz
|
||||
|
||||
# This FAILED for node < 20 when building arm64 but not amd64 (and alpine-based Dockerfile has no issues building arm64)
|
||||
# see https://github.com/FoxxMD/multi-scrobbler/issues/126
|
||||
RUN npm run install:parallel \
|
||||
&& chown -R root:root node_modules \
|
||||
&& chown -R root:root docsite/node_modules
|
||||
|
||||
COPY --chown=abc:abc . /app
|
||||
|
||||
# need to set before build so server/client build is optimized and has constants (if needed)
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN npm run build:parallel && rm -rf node_modules && rm -rf docsite/node_modules
|
||||
|
||||
FROM base AS app
|
||||
|
||||
COPY --chown=abc:abc *.json *.js *.ts index.html ./
|
||||
COPY --chown=abc:abc patches ./patches
|
||||
COPY --from=build --chown=abc:abc /app/dist /app/dist
|
||||
COPY --from=build --chown=abc:abc /app/src /app/src
|
||||
COPY --from=build --chown=abc:abc /app/docsite /app/docsite
|
||||
COPY --from=base /usr/bin /usr/bin
|
||||
COPY --from=base /usr/lib /usr/lib
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV IS_DOCKER=true
|
||||
|
||||
# https://stackoverflow.com/a/63640896/1469797
|
||||
ARG APP_BUILD_VERSION
|
||||
ENV APP_VERSION=$APP_BUILD_VERSION
|
||||
|
||||
RUN npm ci --omit=dev --no-audit \
|
||||
&& npm cache clean --force \
|
||||
&& chown -R abc:abc node_modules \
|
||||
&& rm -rf /root/.cache
|
||||
|
||||
ARG log_dir=/home/node/logs
|
||||
RUN mkdir -p $log_dir
|
||||
VOLUME $log_dir
|
||||
ENV LOG_DIR=$log_dir
|
||||
|
||||
ARG webPort=9078
|
||||
ENV PORT=$webPort
|
||||
EXPOSE $PORT
|
||||
|
||||
CMD [ "node", "index.js" ]
|
||||
|
||||
@@ -3,85 +3,113 @@
|
||||
[](https://github.com/FoxxMD/multi-scrobbler/releases)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://hub.docker.com/r/foxxmd/multi-scrobbler)
|
||||
[](https://foxxmd.github.io/multi-scrobbler/)
|
||||
|
||||
A javascript app to scrobble plays from multiple sources to [Maloja](https://github.com/krateng/maloja), [Last.fm](https://www.last.fm), and other clients (eventually!)
|
||||
|
||||
* Supports scrobbling for many sources
|
||||
* [Spotify](/docs/configuration.md#spotify)
|
||||
* [Plex](/docs/configuration.md#plex) or [Tautulli](/docs/configuration.md#tautulli)
|
||||
* [Subsonic-compatible APIs](/docs/configuration.md#subsonic) (like [Airsonic](https://airsonic.github.io/))
|
||||
* Supports scrobbling to many clients
|
||||
* [Maloja](/docs/configuration.md#maloja)
|
||||
* [Last.fm](/docs/configuration.md#lastfm)
|
||||
<img src="/assets/icon.png" align="right"
|
||||
alt="multi-scrobbler logo" width="180" height="180">
|
||||
|
||||
A javascript app to scrobble music you listened to, to [Maloja](https://github.com/krateng/maloja), [Last.fm](https://www.last.fm), and [ListenBrainz](https://listenbrainz.org)
|
||||
|
||||
* Supports scrobbling from many **Sources**
|
||||
* [Spotify](https://foxxmd.github.io/multi-scrobbler/docs/configuration#spotify)
|
||||
* [Plex](https://foxxmd.github.io/multi-scrobbler/docs/configuration#plex) or [~~Tautulli~~](https://foxxmd.github.io/multi-scrobbler/docs/configuration#tautulli)
|
||||
* [Subsonic-compatible APIs](https://foxxmd.github.io/multi-scrobbler/docs/configuration#subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
|
||||
* [Jellyfin](https://foxxmd.github.io/multi-scrobbler/docs/configuration#jellyfin)
|
||||
* [WebScrobbler](https://foxxmd.github.io/multi-scrobbler/docs/configuration#webscrobbler)
|
||||
* [Youtube Music](https://foxxmd.github.io/multi-scrobbler/docs/configuration#youtube-music)
|
||||
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm-source)
|
||||
* [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-source)
|
||||
* [~~Deezer~~](https://foxxmd.github.io/multi-scrobbler/docs/configuration#deezer)
|
||||
* [MPRIS (Linux Desktop)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpris)
|
||||
* [Mopidy](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mopidy)
|
||||
* [JRiver](https://foxxmd.github.io/multi-scrobbler/docs/configuration#jriver)
|
||||
* [Kodi](https://foxxmd.github.io/multi-scrobbler/docs/configuration#kodi)
|
||||
* [Google Cast (Chromecast)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#google-cast-chromecast)
|
||||
* [Musikcube](https://foxxmd.github.io/multi-scrobbler/docs/configuration#muikcube)
|
||||
* [MPD (Music Player Daemon)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpd-music-player-daemon)
|
||||
* [VLC](https://foxxmd.github.io/multi-scrobbler/docs/configuration#vlc)
|
||||
* Supports scrobbling to many **Clients**
|
||||
* [Maloja](https://foxxmd.github.io/multi-scrobbler/docs/configuration#maloja)
|
||||
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm)
|
||||
* [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz)
|
||||
* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#webhook-configurations) or [healthcheck endpoint](https://foxxmd.github.io/multi-scrobbler/docs/configuration#health-endpoint)
|
||||
* Supports configuring for single or multiple users (scrobbling for your friends and family!)
|
||||
* Web server interface for stats, basic control, and detailed logs
|
||||
* Graceful network and client failure handling (queued scrobbles that auto-retry)
|
||||
* Smart handling of credentials (persistent, authorization through app)
|
||||
* Easy configuration through ENVs or JSON
|
||||
* Built for Docker and unattended use!
|
||||
* Modify data before scrobbling with [regular expression or search patterns](https://foxxmd.github.io/multi-scrobbler/docs/configuration/transforms)
|
||||
* Install using [Docker images for x86/ARM](https://foxxmd.github.io/multi-scrobbler/docs/installation#docker), [flatpak](https://foxxmd.github.io/multi-scrobbler/docs/installation#flatpak), or [locally with NodeJS](https://foxxmd.github.io/multi-scrobbler/docs/installation#nodejs)
|
||||
|
||||
[**Quick Start Guide**](https://foxxmd.github.io/multi-scrobbler/docs/quickstart)
|
||||
|
||||
<img src="/assets/status-ui.png" width="800">
|
||||
|
||||
**Why should I use this over a browser extension and/or mobile app scrobbler?**
|
||||
|
||||
* **Platform independent** -- Because multi-scrobbler communicates directly with service APIs it will scrobble everything you play regardless of where you play it. No more need for apps on every platform you use!
|
||||
* **Open-source** -- Get peace of mind knowing exactly how your personal data is being handled.
|
||||
* **Consolidate play sources** -- Scrobble from many sources to one client with ease and without duplicating tracks.
|
||||
* **Track your activity regardless of where you listen** -- Scrobble from many Sources to one Client with ease and without duplicating tracks.
|
||||
* **Manage scrobbling for others** -- Scrobble for your friends and family without any setup on their part. Easily silo sources to specific clients to keep plays separate.
|
||||
|
||||
**But I already scrobble my music to Last.fm/ListenBrainz, is multi-scrobbler for me?**
|
||||
|
||||
Yes! You can use [Last.fm as a **Source**](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm-source) or [Listenbrainz as a **Source**](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-source) to forward scrobbles from your profile to any other Client! That way you can keep your current scrobble setup as-is but still get the benefit of capturing your data to a self-hosted location.
|
||||
|
||||
## How Does multi-scrobbler (MS) Work?
|
||||
|
||||
You set up configurations for one or more **Sources** and one or more **Clients**. MS monitors all of your configured **Sources**. When new tracks are played by a Source it grabs that information and then sends it (scrobbles it) to all **Clients** that Source is configured to scrobble to.
|
||||
|
||||
### Source
|
||||
|
||||
A **Source** is a data source that contains information about tracks you are playing like a music player or platform. Examples are **Spotify, Jellyfin, Plex, Youtube Music, Airsonic**, etc...
|
||||
|
||||
Source configurations consist of:
|
||||
|
||||
* A friendly name.
|
||||
* Any data needed to communicate or authenticate with the Source.
|
||||
* An optional list of Client names that the Source should scrobble to. If omitted the Source also scrobbles to all configured Clients.
|
||||
|
||||
### Client
|
||||
|
||||
A **Client** is an application that stores the historical information about what songs you have played (scrobbles). Examples are **Maloja, Last.fm, Listenbrainz**...
|
||||
|
||||
Client configurations consist of:
|
||||
|
||||
* A friendly name.
|
||||
* Any data needed to communicate or authenticate with the Client.
|
||||
|
||||
## Quick Start
|
||||
|
||||
[See the **Quick Start Guide**](https://foxxmd.github.io/multi-scrobbler/docs/quickstart)
|
||||
|
||||
## Installation
|
||||
|
||||
[See the **Installation** documentation](https://foxxmd.github.io/multi-scrobbler/docs/installation)
|
||||
|
||||
### Locally
|
||||
## Configuration
|
||||
|
||||
Clone this repository somewhere and then install from the working directory
|
||||
|
||||
```bash
|
||||
git clone https://github.com/FoxxMD/multi-scrobbler.git .
|
||||
cd multi-scrobbler
|
||||
npm install
|
||||
```
|
||||
|
||||
### [Docker](https://hub.docker.com/r/foxxmd/multi-scrobbler)
|
||||
|
||||
```
|
||||
foxxmd/spotify-scrobbler:latest
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
Some setup is required! See the [configuration](docs/configuration.md) docs for a full reference.
|
||||
|
||||
### TLDR, Minimal Example
|
||||
|
||||
You want to use multi-scrobbler to scrobble your plays from Spotify to Maloja:
|
||||
|
||||
#### Local
|
||||
```bash
|
||||
SPOTIFY_CLIENT_ID=yourId SPOTIFY_CLIENT_SECRET=yourSecret MALOJA_URL=http://domain.tld MALOJA_API_KEY=1234 node index.js
|
||||
```
|
||||
|
||||
#### Docker
|
||||
|
||||
```bash
|
||||
docker run -e "SPOTIFY_CLIENT_ID=yourId" -e "SPOTIFY_CLIENT_SECRET=yourSecret" -e "MALOJA_URL=http://domain.tld" -e "MALOJA_API_KEY=1234" -v /path/on/host/config:/home/node/app/config foxxmd/spotify-scrobbler
|
||||
```
|
||||
|
||||
**But I want to use json for configuration?**
|
||||
|
||||
Then use [config.json.example](/config/config.json.example) and drop it in your `CONFIG_DIR` directory
|
||||
|
||||
**Is there an example configuration using everything?**
|
||||
|
||||
Yes, check out the [kitchen sink example](/docs/kitchensink.md)
|
||||
[See the **Configuration** documentation](https://foxxmd.github.io/multi-scrobbler/docs/configuration)
|
||||
|
||||
## Usage
|
||||
|
||||
A status page with statistics, recent logs, and some runtime configuration options can be found at
|
||||
|
||||
```
|
||||
https://localhost:9078
|
||||
http://localhost:9078
|
||||
```
|
||||
Output is also provided to stdout/stderr as well as file if specified in configuration.
|
||||
|
||||
On first startup you may need to authorize Spotify by visiting the callback URL (which can also be accessed from the status page). Visit the status page above to find the applicable link to trigger this.
|
||||
On first startup you may need to authorize Spotify and/or Last.fm by visiting the callback URL (which can also be accessed from the status page). Visit the status page above to find the applicable link to trigger this.
|
||||
|
||||
## Help/FAQ
|
||||
|
||||
Having issues with connections or configuration? Check the [FAQ](https://foxxmd.github.io/multi-scrobbler/docs/FAQ) before creating an issue!
|
||||
|
||||
## Development
|
||||
|
||||
[Detailed architecture and development guides for Sources/Clients](https://foxxmd.github.io/multi-scrobbler/docs/development/dev-common)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
FROM lsiobase/alpine:3.18 as base
|
||||
|
||||
ENV TZ=Etc/GMT
|
||||
|
||||
RUN \
|
||||
echo "**** install build packages ****" && \
|
||||
apk add --no-cache \
|
||||
avahi \
|
||||
avahi-tools \
|
||||
nodejs \
|
||||
npm \
|
||||
&& \
|
||||
echo "**** cleanup ****" && \
|
||||
rm -rf \
|
||||
/root/.cache \
|
||||
/tmp/*
|
||||
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
ARG data_dir=/config
|
||||
VOLUME $data_dir
|
||||
ENV CONFIG_DIR=$data_dir
|
||||
|
||||
COPY docker/root/ /
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
FROM base as build
|
||||
|
||||
# copy dep/TS config and install dev dependencies
|
||||
COPY --chown=abc:abc package*.json tsconfig.json ./
|
||||
COPY --chown=abc:abc patches ./patches
|
||||
|
||||
RUN npm ci \
|
||||
&& chown -R root:root node_modules
|
||||
#RUN yarn install
|
||||
|
||||
COPY --chown=abc:abc . /app
|
||||
|
||||
# need to set before build so server/client build is optimized and has constants (if needed)
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN npm run docs:install && npm run build && rm -rf node_modules && rm -rf docsite/node_modules
|
||||
|
||||
FROM base as app
|
||||
|
||||
#COPY --chown=abc:abc package.json yarn.lock ./
|
||||
COPY --chown=abc:abc package*.json ./
|
||||
COPY --chown=abc:abc patches ./patches
|
||||
COPY --from=build --chown=abc:abc /app/dist /app/dist
|
||||
COPY --from=build --chown=abc:abc /app/src /app/src
|
||||
COPY --from=build --chown=abc:abc /app/docsite /app/docsite
|
||||
COPY --from=base /usr/local/bin /usr/local/bin
|
||||
COPY --from=base /usr/local/lib /usr/local/lib
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV IS_DOCKER=true
|
||||
ENV COLORED_STD=true
|
||||
|
||||
# https://stackoverflow.com/a/63640896/1469797
|
||||
ARG APP_BUILD_VERSION
|
||||
ENV APP_VERSION=$APP_BUILD_VERSION
|
||||
|
||||
RUN npm ci --omit=dev \
|
||||
&& npm cache clean --force \
|
||||
&& chown -R abc:abc node_modules \
|
||||
&& rm -rf node_modules/@types
|
||||
|
||||
ARG webPort=9078
|
||||
ENV PORT=$webPort
|
||||
EXPOSE $PORT
|
||||
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 421 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #c34a36;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M76.94,65.49c.87-7.85-5.27-14.51-12.94-14.51-4.56,0-8.57,2.36-10.9,5.91h-4.55c2.36-5.11,7.17-8.87,12.95-9.71V20c0-11.05-8.95-20-20-20H5C2.24,0,0,2.24,0,5V123c0,2.76,2.24,5,5,5H41.5c11.05,0,20-8.95,20-20v-27.17c-5.78-.84-10.59-4.6-12.95-9.72h4.55c2.59,3.96,7.27,6.43,12.46,5.82,6.08-.71,10.71-5.37,11.39-11.45ZM15.3,94.39c0,1.1-.9,2-2,2s-2-.9-2-2V33.61c0-1.1,.9-2,2-2s2,.9,2,2v60.78Zm11.12,14.55c0,1.1-.9,2-2,2s-2-.9-2-2V19.06c0-1.1,.9-2,2-2s2,.9,2,2V108.94Zm37.58-39.53c-2.28,0-4.23-1.41-5.02-3.41h-21.43v28.39c0,1.1-.9,2-2,2s-2-.9-2-2V33.61c0-1.1,.9-2,2-2s2,.9,2,2v28.39h21.43c.93-2.33,3.42-3.86,6.18-3.29,2.05,.42,3.7,2.08,4.13,4.12,.73,3.5-1.92,6.58-5.29,6.58Z"/>
|
||||
<path class="cls-1" d="M123,0h-36.5c-11.05,0-20,8.95-20,20v27.18c7.55,1.12,13.5,7.2,14.4,14.82h9.55v-28.28c0-1,.68-1.92,1.66-2.08,1.25-.2,2.34,.76,2.34,1.97v60.67c0,1-.68,1.92-1.66,2.08-1.25,.2-2.34-.76-2.34-1.97v-28.39h-9.55c-.9,7.62-6.84,13.7-14.4,14.82v27.18c0,11.05,8.95,20,20,20h36.5c2.76,0,5-2.24,5-5V5c0-2.76-2.24-5-5-5Zm-17.42,108.94c0,1.1-.9,2-2,2s-2-.9-2-2V19.06c0-1.1,.9-2,2-2s2,.9,2,2V108.94Zm11.12-14.55c0,1.1-.9,2-2,2s-2-.9-2-2V33.61c0-1.1,.9-2,2-2s2,.9,2,2v60.78Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 699 KiB |
@@ -1,137 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import {buildTrackString, capitalize, createLabelledLogger, playObjDataMatch} from "../utils.js";
|
||||
|
||||
export default class AbstractScrobbleClient {
|
||||
|
||||
name;
|
||||
type;
|
||||
initialized = false;
|
||||
|
||||
recentScrobbles = [];
|
||||
scrobbledPlayObjs = [];
|
||||
newestScrobbleTime;
|
||||
oldestScrobbleTime = dayjs();
|
||||
tracksScrobbled = 0;
|
||||
|
||||
lastScrobbleCheck = dayjs();
|
||||
refreshEnabled;
|
||||
checkExistingScrobbles;
|
||||
verboseOptions;
|
||||
|
||||
config;
|
||||
logger;
|
||||
|
||||
constructor(type, name, config = {}) {
|
||||
this.type = type;
|
||||
this.name = name;
|
||||
const identifier = `${capitalize(this.type)} - ${name}`;
|
||||
this.logger = createLabelledLogger(identifier, identifier);
|
||||
|
||||
const {
|
||||
options: {
|
||||
refreshEnabled = true,
|
||||
checkExistingScrobbles = true,
|
||||
verbose = {},
|
||||
} = {},
|
||||
...rest
|
||||
} = config;
|
||||
this.config = rest;
|
||||
this.refreshEnabled = refreshEnabled;
|
||||
this.checkExistingScrobbles = checkExistingScrobbles;
|
||||
|
||||
const {
|
||||
match: {
|
||||
onNoMatch = false,
|
||||
onMatch = false,
|
||||
confidenceBreakdown = false,
|
||||
} = {},
|
||||
...vRest
|
||||
} = verbose
|
||||
if (onMatch || onNoMatch) {
|
||||
this.logger.warn('Setting verbose matching may produce noisy logs! Use with care.');
|
||||
}
|
||||
this.verboseOptions = {
|
||||
...vRest,
|
||||
match: {
|
||||
onNoMatch,
|
||||
onMatch,
|
||||
confidenceBreakdown
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
scrobblesLastCheckedAt = () => {
|
||||
return this.lastScrobbleCheck;
|
||||
}
|
||||
|
||||
formatPlayObj = obj => {
|
||||
this.logger.warn('formatPlayObj should be defined by concrete class!');
|
||||
return obj;
|
||||
}
|
||||
|
||||
// time frame is valid as long as the play date for the source track is newer than the oldest play time from the scrobble client
|
||||
// ...this is assuming the scrobble client is returning "most recent" scrobbles
|
||||
timeFrameIsValid = (playObj, log = false) => {
|
||||
const {
|
||||
data: {
|
||||
playDate,
|
||||
} = {},
|
||||
} = playObj;
|
||||
const validTime = playDate.isAfter(this.oldestScrobbleTime);
|
||||
if (log && !validTime) {
|
||||
this.logger.debug(`${buildTrackString(playObj)} was in an invalid time frame (played before the oldest scrobble found)`);
|
||||
}
|
||||
return validTime;
|
||||
}
|
||||
|
||||
addScrobbledTrack = (playObj, scrobbleResp) => {
|
||||
this.scrobbledPlayObjs.push({play: playObj, scrobble: this.formatPlayObj(scrobbleResp)});
|
||||
}
|
||||
|
||||
cleanSourceSearchTitle = (playObj) => {
|
||||
const {
|
||||
data: {
|
||||
track,
|
||||
} = {},
|
||||
} = playObj;
|
||||
|
||||
return track;
|
||||
};
|
||||
|
||||
findExistingSubmittedPlayObj = (playObj) => {
|
||||
const {
|
||||
data: {
|
||||
playDate
|
||||
} = {},
|
||||
meta: {
|
||||
source,
|
||||
} = {}
|
||||
} = playObj;
|
||||
|
||||
const dtInvariantMatches = this.scrobbledPlayObjs.filter(x => playObjDataMatch(playObj, x.play));
|
||||
|
||||
if (dtInvariantMatches.length === 0) {
|
||||
return [undefined, undefined];
|
||||
}
|
||||
|
||||
const matchPlayDate = dtInvariantMatches.find((x) => {
|
||||
const {
|
||||
play: {
|
||||
data: {
|
||||
playDate: sPlayDate
|
||||
} = {},
|
||||
meta: {
|
||||
source: playSource
|
||||
} = {},
|
||||
} = {},
|
||||
} = x;
|
||||
// need to account for inaccurate DT from subsonic
|
||||
if(source === 'Subsonic' && playSource === 'Subsonic') {
|
||||
return playDate.isSame(sPlayDate) || playDate.diff(sPlayDate, 'minute') <= 1;
|
||||
}
|
||||
return playDate.isSame(sPlayDate);
|
||||
});
|
||||
|
||||
return [matchPlayDate, dtInvariantMatches];
|
||||
}
|
||||
}
|
||||
@@ -1,398 +0,0 @@
|
||||
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
|
||||
import dayjs from 'dayjs';
|
||||
import LastFm from 'lastfm-node-client';
|
||||
import {
|
||||
buildTrackString, parseRetryAfterSecsFromObj,
|
||||
playObjDataMatch,
|
||||
readJson,
|
||||
setIntersection, sleep,
|
||||
sortByPlayDate,
|
||||
truncateStringToLength, writeFile
|
||||
} from "../utils.js";
|
||||
|
||||
const badErrors = [
|
||||
'api key suspended',
|
||||
'invalid session key',
|
||||
'invalid api key',
|
||||
'authentication failed'
|
||||
];
|
||||
|
||||
const retryErrors = [
|
||||
'operation failed',
|
||||
'service offline',
|
||||
'temporarily unavailable',
|
||||
'rate limit'
|
||||
]
|
||||
|
||||
export default class LastfmScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
client;
|
||||
redirectUri;
|
||||
workingCredsPath;
|
||||
initialized = false;
|
||||
user;
|
||||
|
||||
constructor(name, config = {}, options = {}) {
|
||||
super('lastfm', name, config, options);
|
||||
const {redirectUri, apiKey, secret, session, configDir} = config;
|
||||
this.redirectUri = `${redirectUri}?state=${name}`;
|
||||
if (apiKey === undefined) {
|
||||
this.logger.warn("'apiKey' not found in config! Client will most likely fail when trying to scrobble");
|
||||
}
|
||||
this.workingCredsPath = `${configDir}/currentCreds-lastfm-${name}.json`;
|
||||
this.client = new LastFm(apiKey, secret, session);
|
||||
}
|
||||
|
||||
static formatPlayObj(obj) {
|
||||
const {
|
||||
artist: {
|
||||
'#text': artists
|
||||
},
|
||||
name: title,
|
||||
album: {
|
||||
'#text': album,
|
||||
},
|
||||
duration,
|
||||
date: {
|
||||
uts: time,
|
||||
},
|
||||
} = obj;
|
||||
let artistStrings = artists.split(',');
|
||||
return {
|
||||
data: {
|
||||
artists: [...new Set(artistStrings)],
|
||||
track: title,
|
||||
album,
|
||||
duration,
|
||||
playDate: dayjs.unix(time),
|
||||
},
|
||||
meta: {
|
||||
source: 'Lastfm',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
formatPlayObj = obj => LastfmScrobbler.formatPlayObj(obj);
|
||||
|
||||
callApi = async (func, retries = 0) => {
|
||||
const {
|
||||
maxRequestRetries = 2,
|
||||
retryMultiplier = 1.5
|
||||
} = this.config;
|
||||
|
||||
try {
|
||||
return await func(this.client);
|
||||
} catch (e) {
|
||||
const {
|
||||
message,
|
||||
} = e;
|
||||
// for now check for exceptional errors by matching error code text
|
||||
const retryError = retryErrors.find(x => message.toLocaleLowerCase().includes(x));
|
||||
if(undefined !== retryError) {
|
||||
if(retries < maxRequestRetries) {
|
||||
const delay = (retries + 1) * retryMultiplier;
|
||||
this.logger.warn(`API call was not good but recoverable (${retryError}), retrying in ${delay} seconds...`);
|
||||
await sleep(delay * 1000);
|
||||
return this.callApi(func, retries + 1);
|
||||
} else {
|
||||
this.logger.warn('Could not recover!');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
getAuthUrl = () => {
|
||||
const redir = `${this.config.redirectUri}?state=${this.name}`;
|
||||
return `http://www.last.fm/api/auth/?api_key=${this.config.apiKey}&cb=${encodeURIComponent(redir)}`
|
||||
}
|
||||
|
||||
authenticate = async (token) => {
|
||||
const sessionRes = await this.client.authGetSession({token});
|
||||
const {
|
||||
session: {
|
||||
key: sessionKey,
|
||||
name, // username
|
||||
} = {}
|
||||
} = sessionRes;
|
||||
this.client.sessionKey = sessionKey;
|
||||
|
||||
await writeFile(this.workingCredsPath, JSON.stringify({
|
||||
sessionKey,
|
||||
}));
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
|
||||
try {
|
||||
const creds = await readJson(this.workingCredsPath, {throwOnNotFound: false});
|
||||
const {sessionKey} = creds || {};
|
||||
if (this.client.sessionKey === undefined && sessionKey !== undefined) {
|
||||
this.client.sessionKey = sessionKey;
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.warn('Current lastfm credentials file exists but could not be parsed', {path: this.workingCredsPath});
|
||||
}
|
||||
|
||||
if (this.client.sessionKey === undefined) {
|
||||
this.logger.info('No session key found. User interaction for authentication required.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const infoResp = await this.callApi(client => client.userGetInfo());
|
||||
const {
|
||||
user: {
|
||||
name,
|
||||
} = {}
|
||||
} = infoResp;
|
||||
this.user = name;
|
||||
this.initialized = true;
|
||||
this.logger.info(`Client authorized for user ${name}`)
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.logger.error('Testing connection failed');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
refreshScrobbles = async () => {
|
||||
if (this.refreshEnabled) {
|
||||
this.logger.debug('Refreshing recent scrobbles');
|
||||
const resp = await this.callApi(client => client.userGetRecentTracks({user: this.user, limit: 20}));
|
||||
const {
|
||||
recenttracks: {
|
||||
track: list = [],
|
||||
}
|
||||
} = resp;
|
||||
this.recentScrobbles = list.map(x => LastfmScrobbler.formatPlayObj(x)).sort(sortByPlayDate);
|
||||
if (this.recentScrobbles.length > 0) {
|
||||
const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1);
|
||||
const [{data: {playDate: oldestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(0, 1);
|
||||
this.newestScrobbleTime = newestScrobbleTime;
|
||||
this.oldestScrobbleTime = oldestScrobbleTime;
|
||||
|
||||
this.scrobbledPlayObjs = this.scrobbledPlayObjs.filter(x => this.timeFrameIsValid(x.play));
|
||||
}
|
||||
}
|
||||
this.lastScrobbleCheck = dayjs();
|
||||
}
|
||||
|
||||
cleanSourceSearchTitle = (playObj) => {
|
||||
const {
|
||||
data: {
|
||||
track,
|
||||
} = {},
|
||||
} = playObj;
|
||||
return track.toLocaleLowerCase().trim();
|
||||
}
|
||||
|
||||
alreadyScrobbled = (playObj, log = false) => {
|
||||
return this.existingScrobble(playObj, (log || this.verboseOptions.match.onMatch)) !== undefined;
|
||||
}
|
||||
|
||||
existingScrobble = (playObj, logMatch = false) => {
|
||||
const tr = truncateStringToLength(27);
|
||||
const scoreTrackOpts = {include: ['track', 'time'], transformers: {track: t => tr(t).padEnd(30)}};
|
||||
|
||||
// return early if we don't care about checking existing
|
||||
if (false === this.checkExistingScrobbles) {
|
||||
if (this.verboseOptions.match.onNoMatch) {
|
||||
this.logger.debug(`(Existing Check) Source: ${buildTrackString(playObj, scoreTrackOpts)} => No Match because existing scrobble check is FALSE`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let existingScrobble;
|
||||
let closestMatch = {score: 0, breakdowns: ['None']};
|
||||
|
||||
// then check if we have already recorded this
|
||||
const [existingExactSubmitted, existingDataSubmitted = []] = this.findExistingSubmittedPlayObj(playObj);
|
||||
|
||||
// if we have an submitted play with matching data and play date then we can just return the response from the original scrobble
|
||||
if (existingExactSubmitted !== undefined) {
|
||||
existingScrobble = existingExactSubmitted.scrobble;
|
||||
|
||||
closestMatch = {
|
||||
score: 1,
|
||||
breakdowns: ['Exact Match found in previously successfully scrobbled']
|
||||
}
|
||||
}
|
||||
// if not though then we need to check recent scrobbles from scrobble api.
|
||||
// this will be less accurate than checking existing submitted (obv) but will happen if backlogging or on a fresh server start
|
||||
|
||||
// if no recent scrobbles found then assume we haven't submitted it
|
||||
// (either user doesnt want to check history or there is no history to check!)
|
||||
if (this.recentScrobbles.length === 0) {
|
||||
if (this.verboseOptions.match.onNoMatch) {
|
||||
this.logger.debug(`(Existing Check) ${buildTrackString(playObj, scoreTrackOpts)} => No Match because no recent scrobbles returned from API`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (existingScrobble === undefined) {
|
||||
|
||||
// we have have found an existing submission but without an exact date
|
||||
// in which case we can check the scrobble api response against recent scrobbles (also from api) for a more accurate comparison
|
||||
const referenceApiScrobbleResponse = existingDataSubmitted.length > 0 ? existingDataSubmitted[0].scrobble : undefined;
|
||||
|
||||
const {
|
||||
data: {
|
||||
artists: sourceArtists = [],
|
||||
playDate
|
||||
} = {},
|
||||
meta: {
|
||||
trackLength,
|
||||
source,
|
||||
} = {},
|
||||
} = playObj;
|
||||
|
||||
// clean source title so it matches title from the scrobble api response as closely as we can get it
|
||||
let cleanSourceTitle = this.cleanSourceSearchTitle(playObj);
|
||||
|
||||
existingScrobble = this.recentScrobbles.find((x) => {
|
||||
|
||||
const referenceMatch = referenceApiScrobbleResponse !== undefined && playObjDataMatch(x, referenceApiScrobbleResponse);
|
||||
|
||||
const {data: {playDate: scrobbleTime, track: scrobbleTitle, artists = []} = {}} = x;
|
||||
|
||||
const playDiffThreshold = source === 'Subsonic' ? 60 : 10;
|
||||
let closeTime = false;
|
||||
// check if scrobble time is same as play date (when the track finished playing AKA entered recent tracks)
|
||||
let scrobblePlayDiff = Math.abs(playDate.unix() - scrobbleTime.unix());
|
||||
let scrobblePlayStartDiff;
|
||||
if (scrobblePlayDiff <= playDiffThreshold) {
|
||||
//this.logger.debug(`Scrobble with same name (${scrobbleTitle}) found and the play (finish time) vs. scrobble time diff was smaller than 10 seconds`);
|
||||
closeTime = true;
|
||||
}
|
||||
// also need to check that scrobble time isn't the BEGINNING of the track -- if the source supports durations
|
||||
if (closeTime === false && trackLength !== undefined) {
|
||||
scrobblePlayStartDiff = Math.abs(playDate.unix() - (scrobbleTime.unix() - trackLength));
|
||||
if (scrobblePlayStartDiff <= playDiffThreshold) {
|
||||
//this.logger.debug(`Scrobble with same name (${scrobbleTitle}) found and the play (start time) vs. scrobble time diff was smaller than 10 seconds`);
|
||||
closeTime = true;
|
||||
}
|
||||
}
|
||||
|
||||
let titleMatch;
|
||||
const lowerScrobbleTitle = scrobbleTitle.toLocaleLowerCase().trim();
|
||||
// because of all this replacing we need a more position-agnostic way of comparing titles so use intersection on title split by spaces
|
||||
// and compare against length of scrobble title
|
||||
const sourceTitleTerms = new Set(cleanSourceTitle.split(' ').filter(x => x !== ''));
|
||||
const commonTerms = setIntersection(new Set(lowerScrobbleTitle.split(' ')), sourceTitleTerms);
|
||||
|
||||
titleMatch = commonTerms.size / sourceTitleTerms.size;
|
||||
|
||||
let artistMatch;
|
||||
const lowerSourceArtists = sourceArtists.map(x => x.toLocaleLowerCase());
|
||||
const lowerScrobbleArtists = artists.map(x => x.toLocaleLowerCase());
|
||||
artistMatch = setIntersection(new Set(lowerScrobbleArtists), new Set(lowerSourceArtists)).size / artists.length;
|
||||
|
||||
const artistScore = .2 * artistMatch;
|
||||
const titleScore = .3 * titleMatch;
|
||||
const timeScore = .5 * (closeTime ? 1 : 0);
|
||||
const referenceScore = .5 * (referenceMatch ? 1 : 0);
|
||||
const score = artistScore + titleScore + timeScore;
|
||||
|
||||
let scoreBreakdowns = [
|
||||
`Reference: ${(referenceMatch ? 1 : 0)} * .5 = ${referenceScore.toFixed(2)}`,
|
||||
`Artist ${artistMatch.toFixed(2)} * .2 = ${artistScore.toFixed(2)}`,
|
||||
`Title: ${titleMatch.toFixed(2)} * .3 = ${titleScore.toFixed(2)}`,
|
||||
`Time: ${closeTime ? 1 : 0} * .5 = ${timeScore.toFixed(2)}`,
|
||||
`Score ${score.toFixed(2)} => ${score >= .7 ? 'Matched!' : 'No Match'}`
|
||||
];
|
||||
|
||||
const confidence = `Score ${score.toFixed(2)} => ${score >= .7 ? 'Matched!' : 'No Match'}`
|
||||
|
||||
const scoreInfo = {
|
||||
score,
|
||||
scrobble: x,
|
||||
breakdowns: this.verboseOptions.match.confidenceBreakdown ? scoreBreakdowns : [confidence]
|
||||
}
|
||||
|
||||
if (closestMatch.score <= score && score > 0) {
|
||||
closestMatch = scoreInfo
|
||||
}
|
||||
|
||||
return score >= .7;
|
||||
});
|
||||
}
|
||||
|
||||
if ((existingScrobble !== undefined && this.verboseOptions.match.onMatch) || (existingScrobble === undefined && this.verboseOptions.match.onNoMatch)) {
|
||||
const closestScrobble = closestMatch.scrobble === undefined ? closestMatch.breakdowns.join(' | ') : `Closest Scrobble: ${buildTrackString(closestMatch.scrobble, scoreTrackOpts)} => ${closestMatch.breakdowns.join(' | ')}`;
|
||||
this.logger.debug(`(Existing Check) Source: ${buildTrackString(playObj, scoreTrackOpts)} => ${closestScrobble}`);
|
||||
}
|
||||
return existingScrobble;
|
||||
}
|
||||
|
||||
scrobble = async (playObj) => {
|
||||
const {
|
||||
data: {
|
||||
artists,
|
||||
album,
|
||||
track,
|
||||
duration,
|
||||
playDate
|
||||
} = {},
|
||||
data = {},
|
||||
meta: {
|
||||
source,
|
||||
newFromSource = false,
|
||||
} = {}
|
||||
} = playObj;
|
||||
|
||||
const sType = newFromSource ? 'New' : 'Backlog';
|
||||
|
||||
try {
|
||||
const response = await this.callApi(client => client.trackScrobble(
|
||||
{
|
||||
artist: artists.join(', '),
|
||||
duration,
|
||||
track,
|
||||
album,
|
||||
timestamp: playDate.unix(),
|
||||
}));
|
||||
const {
|
||||
scrobbles: {
|
||||
'@attr': {
|
||||
accepted = 0,
|
||||
ignored = 0,
|
||||
code,
|
||||
},
|
||||
scrobble: {
|
||||
track: {
|
||||
'#text': trackName,
|
||||
} = {},
|
||||
timestamp,
|
||||
ignoredMessage: {
|
||||
code: ignoreCode,
|
||||
'#text': ignoreMsg,
|
||||
} = {},
|
||||
...rest
|
||||
} = {}
|
||||
} = {},
|
||||
} = response;
|
||||
if(code === 5) {
|
||||
this.initialized = false;
|
||||
throw new Error('Service reported daily scrobble limit exceeded! 😬 Disabling client');
|
||||
}
|
||||
this.addScrobbledTrack(playObj, {...rest, date: { uts: timestamp}, name: trackName});
|
||||
if (newFromSource) {
|
||||
this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`);
|
||||
} else {
|
||||
this.logger.info(`Scrobbled (Backlog) => (${source}) ${buildTrackString(playObj)}`);
|
||||
}
|
||||
if(ignoreMsg !== '') {
|
||||
this.logger.warn(`Service ignored this scrobble 😬 => (Code ${ignoreCode}) ${ignoreMsg}`)
|
||||
}
|
||||
// last fm has rate limits but i can't find a specific example of what that limit is. going to default to 1 scrobble/sec to be safe
|
||||
await sleep(1000);
|
||||
} catch (e) {
|
||||
this.logger.error(`Scrobble Error (${sType})`, {playInfo: buildTrackString(playObj)});
|
||||
throw e;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
|
||||
import request from 'superagent';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
buildTrackString,
|
||||
playObjDataMatch,
|
||||
setIntersection,
|
||||
sleep,
|
||||
sortByPlayDate,
|
||||
truncateStringToLength,
|
||||
parseRetryAfterSecsFromObj
|
||||
} from "../utils.js";
|
||||
|
||||
const feat = ["ft.", "ft", "feat.", "feat", "featuring", "Ft.", "Ft", "Feat.", "Feat", "Featuring"];
|
||||
|
||||
export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
constructor(name, config = {}, options = {}) {
|
||||
super('maloja', name, config, options);
|
||||
const {url, apiKey} = config;
|
||||
if (apiKey === undefined) {
|
||||
this.logger.warn("'apiKey' not found in config! Client will most likely fail when trying to scrobble");
|
||||
}
|
||||
if (url === undefined) {
|
||||
throw new Error("Missing 'url' for Maloja config");
|
||||
}
|
||||
}
|
||||
|
||||
static formatPlayObj(obj) {
|
||||
const {
|
||||
artists,
|
||||
title,
|
||||
album,
|
||||
duration,
|
||||
time,
|
||||
} = obj;
|
||||
let artistStrings = artists.reduce((acc, curr) => {
|
||||
let aString;
|
||||
if (typeof curr === 'string') {
|
||||
aString = curr;
|
||||
} else if (typeof curr === 'object') {
|
||||
aString = curr.name;
|
||||
}
|
||||
const aStrings = aString.split(',');
|
||||
return [...acc, ...aStrings];
|
||||
}, []);
|
||||
return {
|
||||
data: {
|
||||
artists: [...new Set(artistStrings)],
|
||||
track: title,
|
||||
album,
|
||||
duration,
|
||||
playDate: dayjs.unix(time),
|
||||
},
|
||||
meta: {
|
||||
source: 'Maloja',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
formatPlayObj = obj => MalojaScrobbler.formatPlayObj(obj);
|
||||
|
||||
callApi = async (req, retries = 0) => {
|
||||
const {
|
||||
maxRequestRetries = 1,
|
||||
retryMultiplier = 1.5
|
||||
} = this.config;
|
||||
|
||||
try {
|
||||
return await req;
|
||||
} catch (e) {
|
||||
if(retries < maxRequestRetries) {
|
||||
const retryAfter = parseRetryAfterSecsFromObj(e) ?? (retryMultiplier * (retries + 1));
|
||||
this.logger.warn(`Request failed but retries (${retries}) less than max (${maxRequestRetries}), retrying request after ${retryAfter} seconds...`);
|
||||
await sleep(retryAfter * 1000);
|
||||
return await this.callApi(req, retries + 1)
|
||||
}
|
||||
const {
|
||||
message,
|
||||
response: {
|
||||
status,
|
||||
body,
|
||||
text,
|
||||
} = {},
|
||||
response,
|
||||
} = e;
|
||||
let msg = response !== undefined ? `API Call failed: Server Response => ${message}` : `API Call failed: ${message}`;
|
||||
const responseMeta = body ?? text;
|
||||
this.logger.error(msg, {status, response: responseMeta});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
testConnection = async () => {
|
||||
|
||||
const {url, apiKey} = this.config;
|
||||
try {
|
||||
const serverInfoResp = await this.callApi(request.get(`${url}/apis/mlj_1/serverinfo`));
|
||||
const {
|
||||
body: {
|
||||
version = [],
|
||||
versionstring = '',
|
||||
} = {},
|
||||
} = serverInfoResp;
|
||||
if (version.length === 0) {
|
||||
this.logger.error('Server did not respond with a version. Either the base URL is incorrect or this Maloja server is too old :(');
|
||||
return false;
|
||||
}
|
||||
this.logger.info(`Maloja Server Version: ${versionstring}`);
|
||||
if (version[0] < 2 || version[1] < 7) {
|
||||
this.logger.warn('Maloja Server Version is less than 2.7, please upgrade to ensure compatibility');
|
||||
}
|
||||
|
||||
const resp = await this.callApi(request
|
||||
.get(`${url}/apis/mlj_1/test`)
|
||||
.query({key: apiKey}));
|
||||
|
||||
const {
|
||||
status,
|
||||
body: {
|
||||
status: bodyStatus,
|
||||
} = {},
|
||||
body = {},
|
||||
text = '',
|
||||
} = resp;
|
||||
if (bodyStatus.toLocaleLowerCase() === 'ok') {
|
||||
this.logger.info('Test connection succeeded!');
|
||||
this.initialized = true;
|
||||
return true;
|
||||
}
|
||||
this.logger.error('Testing connection failed => Server Response body was malformed -- should have returned "status: ok"...is the URL correct?', {
|
||||
status,
|
||||
body,
|
||||
text: text.slice(0, 50)
|
||||
});
|
||||
return false;
|
||||
} catch (e) {
|
||||
this.logger.error('Testing connection failed');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
refreshScrobbles = async () => {
|
||||
if (this.refreshEnabled) {
|
||||
this.logger.debug('Refreshing recent scrobbles');
|
||||
const {url} = this.config;
|
||||
const resp = await this.callApi(request.get(`${url}/apis/mlj_1/scrobbles?max=20`));
|
||||
const {
|
||||
body: {
|
||||
list = [],
|
||||
} = {},
|
||||
} = resp;
|
||||
this.recentScrobbles = list.map(x => MalojaScrobbler.formatPlayObj(x)).sort(sortByPlayDate);
|
||||
if (this.recentScrobbles.length > 0) {
|
||||
const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1);
|
||||
const [{data: {playDate: oldestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(0, 1);
|
||||
this.newestScrobbleTime = newestScrobbleTime;
|
||||
this.oldestScrobbleTime = oldestScrobbleTime;
|
||||
|
||||
this.scrobbledPlayObjs = this.scrobbledPlayObjs.filter(x => this.timeFrameIsValid(x.play));
|
||||
}
|
||||
}
|
||||
this.lastScrobbleCheck = dayjs();
|
||||
}
|
||||
|
||||
cleanSourceSearchTitle = (playObj) => {
|
||||
const {
|
||||
data: {
|
||||
track,
|
||||
artists: sourceArtists = [],
|
||||
} = {},
|
||||
} = playObj;
|
||||
let lowerTitle = track.toLocaleLowerCase();
|
||||
lowerTitle = feat.reduce((acc, curr) => acc.replace(curr, ''), lowerTitle);
|
||||
// also remove [artist] from the track if found since that gets removed as well
|
||||
const lowerArtists = sourceArtists.map(x => x.toLocaleLowerCase());
|
||||
lowerTitle = lowerArtists.reduce((acc, curr) => acc.replace(curr, ''), lowerTitle);
|
||||
|
||||
// remove any whitespace in parenthesis
|
||||
lowerTitle = lowerTitle.replace("\\s+(?=[^()]*\\))", '')
|
||||
// replace parenthesis
|
||||
.replace('()', '')
|
||||
.replace('( )', '')
|
||||
.trim();
|
||||
|
||||
return lowerTitle;
|
||||
}
|
||||
|
||||
alreadyScrobbled = (playObj, log = false) => {
|
||||
return this.existingScrobble(playObj, (log || this.verboseOptions.match.onMatch)) !== undefined;
|
||||
}
|
||||
|
||||
existingScrobble = (playObj, logMatch = false) => {
|
||||
const tr = truncateStringToLength(27);
|
||||
const scoreTrackOpts = {include: ['track', 'time'], transformers: {track: t => tr(t).padEnd(30)}};
|
||||
|
||||
// return early if we don't care about checking existing
|
||||
if (false === this.checkExistingScrobbles) {
|
||||
if (this.verboseOptions.match.onNoMatch) {
|
||||
this.logger.debug(`(Existing Check) Source: ${buildTrackString(playObj, scoreTrackOpts)} => No Match because existing scrobble check is FALSE`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let existingScrobble;
|
||||
let closestMatch = {score: 0, breakdowns: ['None']};
|
||||
|
||||
// then check if we have already recorded this
|
||||
const [existingExactSubmitted, existingDataSubmitted = []] = this.findExistingSubmittedPlayObj(playObj);
|
||||
|
||||
// if we have an submitted play with matching data and play date then we can just return the response from the original scrobble
|
||||
if (existingExactSubmitted !== undefined) {
|
||||
existingScrobble = existingExactSubmitted.scrobble;
|
||||
|
||||
closestMatch = {
|
||||
score: 1,
|
||||
breakdowns: ['Exact Match found in previously successfully scrobbled']
|
||||
}
|
||||
}
|
||||
// if not though then we need to check recent scrobbles from scrobble api.
|
||||
// this will be less accurate than checking existing submitted (obv) but will happen if backlogging or on a fresh server start
|
||||
|
||||
// if no recent scrobbles found then assume we haven't submitted it
|
||||
// (either user doesnt want to check history or there is no history to check!)
|
||||
if (this.recentScrobbles.length === 0) {
|
||||
if (this.verboseOptions.match.onNoMatch) {
|
||||
this.logger.debug(`(Existing Check) ${buildTrackString(playObj, scoreTrackOpts)} => No Match because no recent scrobbles returned from API`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (existingScrobble === undefined) {
|
||||
|
||||
// we have have found an existing submission but without an exact date
|
||||
// in which case we can check the scrobble api response against recent scrobbles (also from api) for a more accurate comparison
|
||||
const referenceApiScrobbleResponse = existingDataSubmitted.length > 0 ? existingDataSubmitted[0].scrobble : undefined;
|
||||
|
||||
const {
|
||||
data: {
|
||||
artists: sourceArtists = [],
|
||||
playDate
|
||||
} = {},
|
||||
meta: {
|
||||
trackLength,
|
||||
source,
|
||||
} = {},
|
||||
} = playObj;
|
||||
|
||||
// clean source title so it matches title from the scrobble api response as closely as we can get it
|
||||
let cleanSourceTitle = this.cleanSourceSearchTitle(playObj);
|
||||
|
||||
existingScrobble = this.recentScrobbles.find((x) => {
|
||||
|
||||
const referenceMatch = referenceApiScrobbleResponse !== undefined && playObjDataMatch(x, referenceApiScrobbleResponse);
|
||||
|
||||
const {data: {playDate: scrobbleTime, track: scrobbleTitle, artists = []} = {}} = x;
|
||||
|
||||
const playDiffThreshold = source === 'Subsonic' ? 60 : 10;
|
||||
let closeTime = false;
|
||||
// check if scrobble time is same as play date (when the track finished playing AKA entered recent tracks)
|
||||
let scrobblePlayDiff = Math.abs(playDate.unix() - scrobbleTime.unix());
|
||||
let scrobblePlayStartDiff;
|
||||
if (scrobblePlayDiff <= playDiffThreshold) {
|
||||
//this.logger.debug(`Scrobble with same name (${scrobbleTitle}) found and the play (finish time) vs. scrobble time diff was smaller than 10 seconds`);
|
||||
closeTime = true;
|
||||
}
|
||||
// also need to check that scrobble time isn't the BEGINNING of the track -- if the source supports durations
|
||||
if (closeTime === false && trackLength !== undefined) {
|
||||
scrobblePlayStartDiff = Math.abs(playDate.unix() - (scrobbleTime.unix() - trackLength));
|
||||
if (scrobblePlayStartDiff <= playDiffThreshold) {
|
||||
//this.logger.debug(`Scrobble with same name (${scrobbleTitle}) found and the play (start time) vs. scrobble time diff was smaller than 10 seconds`);
|
||||
closeTime = true;
|
||||
}
|
||||
}
|
||||
|
||||
let titleMatch;
|
||||
const lowerScrobbleTitle = scrobbleTitle.toLocaleLowerCase().trim();
|
||||
// because of all this replacing we need a more position-agnostic way of comparing titles so use intersection on title split by spaces
|
||||
// and compare against length of scrobble title
|
||||
const sourceTitleTerms = new Set(cleanSourceTitle.split(' ').filter(x => x !== ''));
|
||||
const commonTerms = setIntersection(new Set(lowerScrobbleTitle.split(' ')), sourceTitleTerms);
|
||||
|
||||
titleMatch = commonTerms.size / sourceTitleTerms.size;
|
||||
|
||||
let artistMatch;
|
||||
const lowerSourceArtists = sourceArtists.map(x => x.toLocaleLowerCase());
|
||||
const lowerScrobbleArtists = artists.map(x => x.toLocaleLowerCase());
|
||||
artistMatch = setIntersection(new Set(lowerScrobbleArtists), new Set(lowerSourceArtists)).size / artists.length;
|
||||
|
||||
const artistScore = .2 * artistMatch;
|
||||
const titleScore = .3 * titleMatch;
|
||||
const timeScore = .5 * (closeTime ? 1 : 0);
|
||||
const referenceScore = .5 * (referenceMatch ? 1 : 0);
|
||||
const score = artistScore + titleScore + timeScore;
|
||||
|
||||
let scoreBreakdowns = [
|
||||
`Reference: ${(referenceMatch ? 1 : 0)} * .5 = ${referenceScore.toFixed(2)}`,
|
||||
`Artist ${artistMatch.toFixed(2)} * .2 = ${artistScore.toFixed(2)}`,
|
||||
`Title: ${titleMatch.toFixed(2)} * .3 = ${titleScore.toFixed(2)}`,
|
||||
`Time: ${closeTime ? 1 : 0} * .5 = ${timeScore.toFixed(2)}`,
|
||||
`Score ${score.toFixed(2)} => ${score >= .7 ? 'Matched!' : 'No Match'}`
|
||||
];
|
||||
|
||||
const confidence = `Score ${score.toFixed(2)} => ${score >= .7 ? 'Matched!' : 'No Match'}`
|
||||
|
||||
const scoreInfo = {
|
||||
score,
|
||||
scrobble: x,
|
||||
breakdowns: this.verboseOptions.match.confidenceBreakdown ? scoreBreakdowns : [confidence]
|
||||
}
|
||||
|
||||
if (closestMatch.score <= score && score > 0) {
|
||||
closestMatch = scoreInfo
|
||||
}
|
||||
|
||||
return score >= .7;
|
||||
});
|
||||
}
|
||||
|
||||
if ((existingScrobble !== undefined && this.verboseOptions.match.onMatch) || (existingScrobble === undefined && this.verboseOptions.match.onNoMatch)) {
|
||||
const closestScrobble = closestMatch.scrobble === undefined ? closestMatch.breakdowns.join(' | ') : `Closest Scrobble: ${buildTrackString(closestMatch.scrobble, scoreTrackOpts)} => ${closestMatch.breakdowns.join(' | ')}`;
|
||||
this.logger.debug(`(Existing Check) Source: ${buildTrackString(playObj, scoreTrackOpts)} => ${closestScrobble}`);
|
||||
}
|
||||
return existingScrobble;
|
||||
}
|
||||
|
||||
scrobble = async (playObj) => {
|
||||
const {url, apiKey} = this.config;
|
||||
|
||||
const {
|
||||
data: {
|
||||
artists,
|
||||
album,
|
||||
track,
|
||||
duration,
|
||||
playDate
|
||||
} = {},
|
||||
meta: {
|
||||
source,
|
||||
newFromSource = false,
|
||||
} = {}
|
||||
} = playObj;
|
||||
|
||||
const sType = newFromSource ? 'New' : 'Backlog';
|
||||
|
||||
try {
|
||||
const response = await this.callApi(request.post(`${url}/apis/mlj_1/newscrobble`)
|
||||
.type('json')
|
||||
.send({
|
||||
// maloja seems to detect this deliminator much better than commas
|
||||
// also less likely artist has a forward slash in their name than a comma
|
||||
artist: artists.join(' / '),
|
||||
seconds: duration,
|
||||
title: track,
|
||||
album,
|
||||
key: apiKey,
|
||||
time: playDate.unix(),
|
||||
}));
|
||||
const {body: {
|
||||
track: {
|
||||
time: mTime = playDate.unix(),
|
||||
duration: mDuration = duration,
|
||||
album: mAlbum = album,
|
||||
...rest
|
||||
}
|
||||
} = {}} = response;
|
||||
this.addScrobbledTrack(playObj, {...rest, album: mAlbum, time: mTime, duration: mDuration});
|
||||
if (newFromSource) {
|
||||
this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`);
|
||||
} else {
|
||||
this.logger.info(`Scrobbled (Backlog) => (${source}) ${buildTrackString(playObj)}`);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error(`Scrobble Error (${sType})`, {playInfo: buildTrackString(playObj)});
|
||||
throw e;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
createLabelledLogger,
|
||||
isValidConfigStructure,
|
||||
playObjDataMatch,
|
||||
readJson,
|
||||
returnDuplicateStrings
|
||||
} from "../utils.js";
|
||||
import MalojaScrobbler from "./MalojaScrobbler.js";
|
||||
import LastfmScrobbler from "./LastfmScrobbler.js";
|
||||
|
||||
export default class ScrobbleClients {
|
||||
|
||||
clients = [];
|
||||
logger;
|
||||
configDir;
|
||||
|
||||
clientTypes = ['maloja','lastfm'];
|
||||
|
||||
constructor(configDir) {
|
||||
this.configDir = configDir;
|
||||
this.logger = createLabelledLogger('scrobblers', 'Scrobblers');
|
||||
}
|
||||
|
||||
getByName = (name) => {
|
||||
return this.clients.find(x => x.name === name);
|
||||
}
|
||||
|
||||
buildClientsFromConfig = async () => {
|
||||
let configs = [];
|
||||
|
||||
let configFile;
|
||||
try {
|
||||
configFile = await readJson(`${this.configDir}/config.json`, {throwOnNotFound: false});
|
||||
} catch (e) {
|
||||
throw new Error('config.json could not be parsed');
|
||||
}
|
||||
let clientDefaults = {};
|
||||
if (configFile !== undefined) {
|
||||
const {
|
||||
clients: mainConfigClientConfigs = [],
|
||||
clientDefaults: cd = {},
|
||||
} = configFile;
|
||||
clientDefaults = cd;
|
||||
if (!mainConfigClientConfigs.every(x => x !== null && typeof x === 'object')) {
|
||||
throw new Error('All clients from config.json must be objects');
|
||||
}
|
||||
for (const c of mainConfigClientConfigs) {
|
||||
const {name = 'unnamed'} = c;
|
||||
configs.push({...c, name, source: 'config.json'});
|
||||
}
|
||||
}
|
||||
|
||||
for (const clientType of this.clientTypes) {
|
||||
switch (clientType) {
|
||||
case 'maloja':
|
||||
// env builder for single user mode
|
||||
const url = process.env.MALOJA_URL;
|
||||
const apiKey = process.env.MALOJA_API_KEY;
|
||||
if (url !== undefined || apiKey !== undefined) {
|
||||
configs.push({
|
||||
type: 'maloja',
|
||||
name: 'unnamed',
|
||||
source: 'ENV',
|
||||
mode: 'single',
|
||||
data: {
|
||||
url,
|
||||
apiKey
|
||||
}
|
||||
})
|
||||
}
|
||||
break;
|
||||
case 'lastfm':
|
||||
const lfm = {
|
||||
apiKey: process.env.LASTFM_API_KEY,
|
||||
secret: process.env.LASTFM_SECRET,
|
||||
redirectUri: process.env.LASTFM_REDIRECT_URI,
|
||||
session: process.env.LASTFM_SESSION,
|
||||
};
|
||||
if (!Object.values(lfm).every(x => x === undefined)) {
|
||||
configs.push({
|
||||
type: 'lastfm',
|
||||
name: 'unnamed',
|
||||
source: 'ENV',
|
||||
mode: 'single',
|
||||
data: lfm
|
||||
})
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
let rawClientConfigs;
|
||||
try {
|
||||
rawClientConfigs = await readJson(`${this.configDir}/${clientType}.json`, {throwOnNotFound: false});
|
||||
} catch (e) {
|
||||
throw new Error(`${clientType}.json config file could not be parsed`);
|
||||
}
|
||||
if (rawClientConfigs !== undefined) {
|
||||
let clientConfigs = [];
|
||||
if (Array.isArray(rawClientConfigs)) {
|
||||
clientConfigs = rawClientConfigs;
|
||||
} else if (rawClientConfigs === null || typeof rawClientConfigs === 'object') {
|
||||
// backwards compatibility, assuming its single-user mode
|
||||
if (rawClientConfigs.data === undefined) {
|
||||
clientConfigs = [{data: rawClientConfigs, mode: 'single', name: 'unnamed'}];
|
||||
} else {
|
||||
clientConfigs = [rawClientConfigs];
|
||||
}
|
||||
} else {
|
||||
throw new Error(`All top level data from ${clientType}.json must be an object or array of objects`);
|
||||
}
|
||||
for (const m of clientConfigs) {
|
||||
if (m === null || typeof m !== 'object') {
|
||||
throw new Error(`All top-level data from ${clientType}.json must be an object or array of objects`);
|
||||
}
|
||||
m.source = `${clientType}.json`;
|
||||
m.type = clientType;
|
||||
configs.push(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// we have all possible client configurations so we'll check they are minimally valid
|
||||
const configErrors = configs.reduce((acc, c) => {
|
||||
const isValid = isValidConfigStructure(c, {type: true, data: true});
|
||||
if (isValid !== true) {
|
||||
const msg = `Client config from ${c.source} with name [${c.name || 'unnamed'}] of type [${c.type || 'unknown'}] has errors: ${isValid.join(' | ')}`;
|
||||
return acc.concat(msg);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
if (configErrors.length > 0) {
|
||||
for (const m of configErrors) {
|
||||
this.logger.error(m);
|
||||
}
|
||||
throw new Error('Could not build clients due to above errors');
|
||||
}
|
||||
|
||||
// all client configs are minimally valid
|
||||
// now check that names are unique
|
||||
const nameGroupedConfigs = configs.reduce((acc, curr) => {
|
||||
const {name = 'unnamed'} = curr;
|
||||
const {[name]: n = []} = acc;
|
||||
return {...acc, [name]: [...n, curr]};
|
||||
}, {});
|
||||
let nameErrors = false;
|
||||
for (const [name, configs] of Object.entries(nameGroupedConfigs)) {
|
||||
if (configs.length > 1) {
|
||||
const sources = configs.map(c => `Config object from ${c.source} of type [${c.type}]`);
|
||||
this.logger.error(`Client config naming conflicts -- the following configs have the same name "${name}":
|
||||
${sources.join('\n')}`);
|
||||
nameErrors = true;
|
||||
if (name === 'unnamed') {
|
||||
this.logger.info('HINT: "unnamed" configs occur when using ENVs, if a multi-user mode config does not have a "name" property, or if a config is built in single-user mode');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (nameErrors) {
|
||||
throw new Error('Could not build clients due to naming conflicts');
|
||||
}
|
||||
|
||||
// finally! all configs are valid, structurally, and can now be passed to addClient
|
||||
// just need to re-map unnnamed to default
|
||||
const finalConfigs = configs.map(({name = 'unnamed', ...x}) => ({
|
||||
...x,
|
||||
name
|
||||
}));
|
||||
for (const c of finalConfigs) {
|
||||
await this.addClient(c, clientDefaults);
|
||||
}
|
||||
}
|
||||
|
||||
addClient = async (clientConfig, defaults = {}) => {
|
||||
const isValidConfig = isValidConfigStructure(clientConfig, {name: true, data: true, type: true});
|
||||
if (isValidConfig !== true) {
|
||||
throw new Error(`Config object from ${clientConfig.source || 'unknown'} with name [${clientConfig.name || 'unnamed'}] of type [${clientConfig.type || 'unknown'}] has errors: ${isValidConfig.join(' | ')}`)
|
||||
}
|
||||
const {type, name, data: d = {}} = clientConfig;
|
||||
// add defaults
|
||||
const data = {...defaults, ...d};
|
||||
switch (type) {
|
||||
case 'maloja':
|
||||
this.logger.debug(`(${name}) Attempting Maloja initialization...`);
|
||||
const mj = new MalojaScrobbler(name, data);
|
||||
const testSuccess = await mj.testConnection();
|
||||
if (testSuccess === false) {
|
||||
throw new Error(`(${name}) Maloja client not initialized due to failure during connection testing`);
|
||||
} else {
|
||||
this.logger.info(`(${name}) Maloja client initialized`);
|
||||
this.clients.push(mj)
|
||||
}
|
||||
break;
|
||||
case 'lastfm':
|
||||
this.logger.debug(`(${name}) Attempting Lastfm initialization...`);
|
||||
const lfm = new LastfmScrobbler(name, {...data, configDir: this.configDir});
|
||||
try {
|
||||
await lfm.initialize()
|
||||
this.logger.info(`(${name}) Lastfm client initialized`);
|
||||
this.clients.push(lfm)
|
||||
} catch(e) {
|
||||
this.logger.info(`(${name}) Could not initialize Lastfm client`)
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {*} data
|
||||
* @param {{scrobbleFrom, scrobbleTo, forceRefresh: boolean}|{scrobbleFrom, scrobbleTo}} options
|
||||
* @returns {Array}
|
||||
*/
|
||||
scrobble = async (data, options = {}) => {
|
||||
const playObjs = Array.isArray(data) ? data : [data];
|
||||
const {
|
||||
forceRefresh = false,
|
||||
checkTime = dayjs(),
|
||||
scrobbleTo = [],
|
||||
scrobbleFrom = 'source',
|
||||
} = options;
|
||||
|
||||
const tracksScrobbled = [];
|
||||
|
||||
if (this.clients.length === 0) {
|
||||
this.logger.warn('Cannot scrobble! No clients are configured.');
|
||||
}
|
||||
|
||||
for (const client of this.clients) {
|
||||
if (scrobbleTo.length > 0 && !scrobbleTo.includes(client.name)) {
|
||||
this.logger.debug(`Client '${client.name}' was filtered out by '${scrobbleFrom}'`);
|
||||
continue;
|
||||
}
|
||||
if(client.initialized === false) {
|
||||
this.logger.debug(`Client '${client.name}' is not yet initialized (check authorization?)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (forceRefresh || client.scrobblesLastCheckedAt().unix() < checkTime.unix()) {
|
||||
try {
|
||||
await client.refreshScrobbles();
|
||||
} catch(e) {
|
||||
this.logger.error(`Encountered error while refreshing scrobbles for ${client.name}`);
|
||||
this.logger.error(e);
|
||||
}
|
||||
}
|
||||
for (const playObj of playObjs) {
|
||||
try {
|
||||
const {
|
||||
meta: {
|
||||
newFromSource = false,
|
||||
} = {}
|
||||
} = playObj;
|
||||
if (client.timeFrameIsValid(playObj, newFromSource) && !client.alreadyScrobbled(playObj, newFromSource)) {
|
||||
await client.scrobble(playObj)
|
||||
client.tracksScrobbled++;
|
||||
// since this is what we return to the source only add to tracksScrobbled if not already in array
|
||||
// (source should only know that a track was scrobbled (binary) -- doesn't care if it was scrobbled more than once
|
||||
if(!tracksScrobbled.some(x => playObjDataMatch(x, playObj) && x.data.playDate === playObj.data.playDate)) {
|
||||
tracksScrobbled.push(playObj);
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
this.logger.error(`Encountered error while in scrobble loop for ${client.name}`);
|
||||
this.logger.error(e);
|
||||
// for now just stop scrobbling plays for this client and move on. the client should deal with logging the issue
|
||||
if(e.continueScrobbling !== true) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return tracksScrobbled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
# configuration file for git-cliff (0.1.0)
|
||||
|
||||
[changelog]
|
||||
# changelog header
|
||||
header = """
|
||||
# Changelog
|
||||
All notable changes to this project will be documented in this file.\n
|
||||
"""
|
||||
# template for the changelog body
|
||||
# https://tera.netlify.app/docs/#introduction
|
||||
body = """
|
||||
{% if version %}\
|
||||
## [{{ version | replace(from="v", to="") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||
{% else %}\
|
||||
## [unreleased]
|
||||
{% endif %}\
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | upper_first }}
|
||||
{% for commit in commits
|
||||
| filter(attribute="scope")
|
||||
| sort(attribute="scope") %}
|
||||
- *({{commit.scope}})* {{ commit.message | upper_first }}
|
||||
{%- if commit.breaking %}
|
||||
{% raw %} {% endraw %}- **BREAKING**: {{commit.breaking_description}}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{%- for commit in commits %}
|
||||
{%- if commit.scope -%}
|
||||
{% else -%}
|
||||
- *(No Category)* {{ commit.message | upper_first }}
|
||||
{% if commit.breaking -%}
|
||||
{% raw %} {% endraw %}- **BREAKING**: {{commit.breaking_description}}
|
||||
{% endif -%}
|
||||
{% endif -%}
|
||||
{% endfor -%}
|
||||
{% endfor %}
|
||||
"""
|
||||
# remove the leading and trailing whitespaces from the template
|
||||
trim = true
|
||||
# changelog footer
|
||||
footer = """
|
||||
<!-- generated by git-cliff -->
|
||||
"""
|
||||
|
||||
[git]
|
||||
# allow only conventional commits
|
||||
# https://www.conventionalcommits.org
|
||||
conventional_commits = true
|
||||
# regex for parsing and grouping commits
|
||||
commit_parsers = [
|
||||
{ message = "^feat", group = "Features"},
|
||||
{ message = "^fix", group = "Bug Fixes"},
|
||||
{ message = "^doc", group = "Documentation"},
|
||||
{ message = "^perf", group = "Performance"},
|
||||
{ message = "^refactor", group = "Refactor"},
|
||||
{ message = "^style", group = "Styling"},
|
||||
{ message = "^test", group = "Testing"},
|
||||
{ message = "^chore\\(release\\): prepare for", skip = true},
|
||||
{ message = "^chore", group = "Miscellaneous Tasks"},
|
||||
{ body = ".*security", group = "Security"},
|
||||
]
|
||||
# filter out the commits that are not matched by commit parsers
|
||||
filter_commits = false
|
||||
# glob pattern for matching git tags
|
||||
tag_pattern = "[0-9]*"
|
||||
# regex for skipping tags
|
||||
skip_tags = "v0.1.0-beta.1"
|
||||
@@ -0,0 +1,12 @@
|
||||
These are **example configurations** for all Source/Client types and AIO config.
|
||||
|
||||
These can be used as-is by renaming them to `.json` and filling or replacing sample data.
|
||||
|
||||
For docker installations these examples are copied to your configuration directory on first-time use.
|
||||
|
||||
These are **NOT** exhaustive examples. You should consult the **configuration** documentation and the **schema explorer links** for each source/config type to see a complete list of options and descriptions for all properties.
|
||||
|
||||
Documentation at
|
||||
|
||||
* [internal docs](../docsite/docs/configuration/configuration.mdx)
|
||||
* External Link: https://foxxmd.github.io/multi-scrobbler/docs/configuration
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"name": "MyCast",
|
||||
"data": {
|
||||
"blacklistedDevices": [],
|
||||
"whitelistedDevices": [],
|
||||
"blacklistedApps": [],
|
||||
"whitelistedApps": []
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,32 +1,70 @@
|
||||
{
|
||||
"debugMode": false,
|
||||
"disableWeb": false,
|
||||
"sourceDefaults": {
|
||||
"maxPollRetries": 0, // optional, default # of automatic polling restarts on error. can be overridden by property in individual config
|
||||
"maxRequestRetries": 1, // optional, default # of http request retries a source can make before error is thrown. can be overridden by property in individual config
|
||||
"retryMultiplier": 1.5, // optional, default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying). can be overridden by property in individual config
|
||||
"logPayload": false,
|
||||
"logFilterFailure": "warn",
|
||||
"logPlayerState": false,
|
||||
"scrobbleThresholds": {
|
||||
"duration": 30,
|
||||
"percent": 50
|
||||
},
|
||||
"maxPollRetries": 1,
|
||||
"maxRequestRetries": 1,
|
||||
"retryMultiplier": 1.5
|
||||
},
|
||||
"clientDefaults": {
|
||||
"maxRequestRetries": 1, // optional, default # of http request retries a client can make before error is thrown. can be overridden by property in individual config
|
||||
"retryMultiplier": 1.5, // optional, default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying). can be overridden by property in individual config
|
||||
"maxRequestRetries": 1,
|
||||
"retryMultiplier": 1.5
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"type": "spotify", // required, source type
|
||||
"clients": ["myConfig"], // optional, a list of Client config names this Source should scrobble to. Using an empty list or not including this property will make this Source scrobble to all Clients.
|
||||
"name": "mySpotifySource", // optional, friendly name for the log
|
||||
"data": { // required, the data for your config
|
||||
"clientId": "example",
|
||||
//...
|
||||
"type": "spotify",
|
||||
"enable": true,
|
||||
"clients": ["myConfig"],
|
||||
"name": "mySpotifySource",
|
||||
"data": {
|
||||
"clientId": "a89cba1569901a0671d5a9875fed4be1",
|
||||
"clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
|
||||
"redirectUri": "http://localhost:9078/callback"
|
||||
}
|
||||
}
|
||||
],
|
||||
"clients": [
|
||||
{
|
||||
"type": "maloja", // required, Client type
|
||||
"name": "myConfig", // required, a name to identifier your Client
|
||||
"data": { // required, the data for your config
|
||||
"url": "http://example.com",
|
||||
//...
|
||||
"type": "maloja",
|
||||
"enable": true,
|
||||
"name": "myConfig",
|
||||
"data": {
|
||||
"url": "http://localhost:42010",
|
||||
"apiKey": "myMalojaKey"
|
||||
}
|
||||
}
|
||||
],
|
||||
"webhooks": [
|
||||
{
|
||||
"name": "FirstGotifyServer",
|
||||
"type": "gotify",
|
||||
"url": "http://localhost:8070",
|
||||
"token": "MyGotifyToken",
|
||||
"priorities": {
|
||||
"info": 5,
|
||||
"warn": 7,
|
||||
"error": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "ntfy",
|
||||
"name": "MyNtfyFriendlyNameForLogs",
|
||||
"url": "http://localhost:9991",
|
||||
"topic": "MyMultiScrobblerTopic",
|
||||
"username": "Optional",
|
||||
"password": "Optional",
|
||||
"priorities": {
|
||||
"info": 3,
|
||||
"warn": 4,
|
||||
"error": 5
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[
|
||||
{
|
||||
"name": "FoxxMDeezer",
|
||||
"enable": true,
|
||||
"clients": [],
|
||||
"data": {
|
||||
"clientId": "a89cba1569901a0671d5a9875fed4be1",
|
||||
"clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
|
||||
"redirectUri": "http://localhost:9078/deezer/callback",
|
||||
"interval": 60
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
[
|
||||
{
|
||||
"name": "MyJellyfin",
|
||||
"enable": true,
|
||||
"clients": [],
|
||||
"data": {
|
||||
"url": "http://localhost:8096",
|
||||
"user": "FoxxMD",
|
||||
"apiKey": "c9fae8756fbf481ebd9c5bb56bd6540c",
|
||||
|
||||
// everything below is optional
|
||||
"usersAllow": ["FoxxMD","SomeOtherUser"],
|
||||
"usersBlock": ["AnotherUser"],
|
||||
"devicesAllow": ["firefox"],
|
||||
"devicesBlock": ["google-home"],
|
||||
"librariesAllow": ["GoodMusic"],
|
||||
"librariesBlock": ["BadMusic"],
|
||||
"additionalAllowedLibraryTypes": ["musicvideos"],
|
||||
"allowUnknown": false,
|
||||
},
|
||||
"options": {
|
||||
"logPayload": true,
|
||||
"logFilterFailure": "debug"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"name": "MyJriver",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"url": "0.0.0.0",
|
||||
"username": "auser",
|
||||
"password": "apassword"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"name": "MyKodi",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"url": "http://localhost:8080/jsonrpc",
|
||||
"username": "myUser",
|
||||
"password": "myPassword"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,14 +1,22 @@
|
||||
[
|
||||
{
|
||||
"name": "myLastFm", // required, a name to identify your Client
|
||||
"name": "myLastFmClient",
|
||||
"enable": true,
|
||||
"configureAs": "client",
|
||||
"data": {
|
||||
"apiKey": "string", // required, Lastfm api key
|
||||
"secret": "string", // required, Lastfm shared secret
|
||||
"session": "string", // optional, session id returned from a complete auth flow.
|
||||
// if not specified will be generated during authentication
|
||||
"redirectUri": "http://localhost:9078/lastfm/callback" // optional, if not different than this default
|
||||
// callback for auth. Must have "lastfm/callback" in the url somewhere
|
||||
// ALSO see config.json.example for default properties that can be overridden here (in clientDefaults)
|
||||
"apiKey": "a89cba1569901a0671d5a9875fed4be1",
|
||||
"secret": "ec42e09d5ae0ee0f0816ca151008412a",
|
||||
"redirectUri": "http://localhost:9078/lastfm/callback"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "myLastFmSource",
|
||||
"enable": true,
|
||||
"configureAs": "source",
|
||||
"data": {
|
||||
"apiKey": "a89cba1569901a0671d5a9875fed4be1",
|
||||
"secret": "ec42e09d5ae0ee0f0816ca151008412a",
|
||||
"redirectUri": "http://localhost:9078/lastfm/callback"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
[
|
||||
{
|
||||
"name": "brainzClient",
|
||||
"enable": true,
|
||||
"configureAs": "client",
|
||||
"data": {
|
||||
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
|
||||
"username": "FoxxMD"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "brainzSource",
|
||||
"enable": true,
|
||||
"configureAs": "source",
|
||||
"data": {
|
||||
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
|
||||
"username": "FoxxMD"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,10 +1,10 @@
|
||||
[
|
||||
{
|
||||
"name": "myMaloja", // required, a name to identify your Client
|
||||
"name": "myMaloja",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"url": "https://domain.tld", // required, the base url of your maloja installation
|
||||
"apiKey": "string" // required, your maloja api key
|
||||
// ALSO see config.json.example for default properties that can be overridden here (in clientDefaults)
|
||||
"url": "http://localhost:42010",
|
||||
"apiKey": "myMalojaKey"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[
|
||||
{
|
||||
"name": "MyMopidy",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"url": "localhost",
|
||||
"uriBlacklist": [],
|
||||
"uriWhitelist": [],
|
||||
"albumBlacklist": []
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
[
|
||||
{
|
||||
"enable": true,
|
||||
"name": "MyMPD",
|
||||
"data": {
|
||||
"url": "192.168.0.100:6600",
|
||||
"password": "MY_PASSWORD"
|
||||
},
|
||||
"options": {
|
||||
"disableDiscovery": false
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"name": "ubuntu",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"whitelist": ["vlc", "mpd"],
|
||||
"blacklist": ["spotify"]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"type": "musikcube",
|
||||
"enable": true,
|
||||
"name": "musikcube",
|
||||
"data": {
|
||||
"url": "ws://localhost:7905",
|
||||
"password": "MY_PASSWORD"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,11 +1,21 @@
|
||||
[
|
||||
{
|
||||
"name": "default", // optional, friendly name for logs
|
||||
"clients": [], // optional, list of scrobble clients (by config name) that this source should scrobble to. Using an empty list or not including this property will make this source scrobble to all clients.
|
||||
"name": "MyPlexApi",
|
||||
"enable": true,
|
||||
"clients": [],
|
||||
"data": {
|
||||
"user": ["username@gmail.com","anotherUser@gmail.com"], // optional, list of users to scrobble tracks for
|
||||
"libraries": ["music","my podcasts"], // optional, list of libraries to scrobble tracks from
|
||||
"servers": ["myServer","anotherServer"] // optional, list of servers to scrobble tracks from
|
||||
}
|
||||
"token": "1234",
|
||||
"url": "http://192.168.0.120:32400",
|
||||
"usersAllow": ["FoxxMD","SomeOtherUser"],
|
||||
"usersBlock": ["AnotherUser"],
|
||||
"devicesAllow": ["firefox"],
|
||||
"devicesBlock": ["google-home"],
|
||||
"librariesAllow": ["GoodMusic"],
|
||||
"librariesBlock": ["BadMusic"]
|
||||
},
|
||||
"options": {
|
||||
"logPayload": true,
|
||||
"logFilterFailure": "debug"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[
|
||||
// DEPRECATED, use API Source instead
|
||||
// rename files to plex.json to use
|
||||
{
|
||||
"name": "MyPlex",
|
||||
"enable": true,
|
||||
"clients": [],
|
||||
"data": {
|
||||
"user": ["username@gmail.com","anotherUser@gmail.com"],
|
||||
"libraries": ["music","my podcasts"],
|
||||
"servers": ["myServer","anotherServer"],
|
||||
"options": {
|
||||
"logFilterFailure": "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,15 +1,13 @@
|
||||
[
|
||||
{
|
||||
"name": "default", // optional, friendly name for logs
|
||||
"clients": [], // optional, list of scrobble clients (by config name) that this source should scrobble to. Using an empty list or not including this property will make this source scrobble to all clients.
|
||||
"name": "MySpotify",
|
||||
"enable": true,
|
||||
"clients": [],
|
||||
"data": {
|
||||
"clientId": "string", // spotify client id -- required if not providing access token
|
||||
"clientSecret": "string", // spotify client secret -- required if not providing access token
|
||||
"accessToken": "string", // spotify access token -- required if not providing client id/secret
|
||||
"refreshToken": "string", // spotify refresh token -- recommended to provide if not providing client id/secret
|
||||
"redirectUri": "http://localhost:9078/callback",// spotify redirect URI -- required only if not the default shown here. URI must end in "callback"
|
||||
"interval": 60, // optional, how long to wait before calling spotify for new tracks
|
||||
// ALSO see config.json.example for default properties that can be overridden here (in sourceDefaults)
|
||||
"clientId": "a89cba1569901a0671d5a9875fed4be1",
|
||||
"clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
|
||||
"redirectUri": "http://localhost:9078/callback",
|
||||
"interval": 60
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
[
|
||||
{
|
||||
"name": "default", // optional, friendly name for logs
|
||||
"name": "MySubsonic",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"url": "http://localhost:4040/airsonic",// required, the url you would visit to listen to music on the web
|
||||
"user": "yourUser", // required, username to login with
|
||||
"password": "yourPassword", // required, password to login with
|
||||
// ALSO see config.json.example for default properties that can be overridden here (in sourceDefaults)
|
||||
"url": "http://localhost:4040/airsonic",
|
||||
"user": "yourUser",
|
||||
"password": "yourPassword"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
[
|
||||
{
|
||||
"name": "default", // optional, friendly name for logs
|
||||
"clients": [], // optional, list of scrobble clients (by config name) that this source should scrobble to. Using an empty list or not including this property will make this source scrobble to all clients.
|
||||
"name": "MyTautuilli",
|
||||
"enable": true,
|
||||
"clients": [],
|
||||
"data": {
|
||||
"user": ["username@gmail.com","anotherUser@gmail.com"], // optional, list of users to scrobble tracks for
|
||||
"libraries": ["music","my podcasts"], // optional, list of libraries to scrobble tracks from
|
||||
"servers": ["myServer","anotherServer"] // optional, list of servers to scrobble tracks from
|
||||
"user": ["username@gmail.com","anotherUser@gmail.com"],
|
||||
"libraries": ["music","my podcasts"],
|
||||
"servers": ["myServer","anotherServer"],
|
||||
"options": {
|
||||
"logFilterFailure": "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"enable": true,
|
||||
"name": "MyVlc",
|
||||
"data": {
|
||||
"url": "localhost:8080",
|
||||
"password": "changeme"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"name": "MyWebScrobbler",
|
||||
"data": {
|
||||
"slug": "MyOptionalSlug",
|
||||
"whitelist": [],
|
||||
"blacklist": []
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
[
|
||||
{
|
||||
"name": "MyYTMusic",
|
||||
"enable": true,
|
||||
"clients": [],
|
||||
"data": {
|
||||
"cookie": "__Secure-1PSIDTS=sidts-CjEB3EgAEvCd-......",
|
||||
// either cookie or id/secret needs to be provided
|
||||
"clientId": "891098404....apps.googleusercontent.com",
|
||||
"clientSecret": "GOCS..."
|
||||
|
||||
// optional
|
||||
//"redirectUri": "http://my.custom.tld/api/ytmusic/callback?name=MyYTMusic"
|
||||
},
|
||||
"options": {
|
||||
"logDiff": true
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
version: '3.7'
|
||||
|
||||
services:
|
||||
multi-scrobbler:
|
||||
build:
|
||||
context: .
|
||||
env_file:
|
||||
- path: ./.env
|
||||
required: false
|
||||
#environment:
|
||||
#- TZ=Etc/GMT # Specify timezone from TZ Database name found here https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||
#- SPOTIFY_CLIENT_ID=
|
||||
#- SPOTIFY_CLIENT_SECRET=
|
||||
#- SPOTIFY_REDIRECT_URI=http://multi-scrobbler-host-IP:9078/callback ## Need to be whitelisted in Spotify dashboard and is used for creating the connection first time
|
||||
#- MALOJA_URL=http://maloja:42010
|
||||
#- MALOJA_API_KEY=
|
||||
#- PUID=1000 # required if running docker on linux host, see main README Docker setup instructions
|
||||
#- PGID=1000 # required if running docker on linux host, see main README Docker setup instructions
|
||||
|
||||
# set if using a source/client with redirect URI that you have not explicitly set and MS is NOT running on the same machine that you will view the dashboard from
|
||||
# EX: You will view MS dashboard at 'http://192.168.0.101:9078' -> set BASE_URL=http://192.168.0.101:9078
|
||||
#- BASE_URL=http://MyHostIP:9078
|
||||
volumes:
|
||||
- "./config:/config"
|
||||
#networks:
|
||||
# - (optional to add container to the same bridge network that maloja is inside to be able to use docker internal networking & dns to resolve and connect to maloja URL via http://maloja:port)
|
||||
ports:
|
||||
- 9078:9078 # first port is the HOST port multi-scrobbler will serve UI and API on
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
multi-scrobbler:
|
||||
image: foxxmd/multi-scrobbler
|
||||
container_name: multi-scrobbler
|
||||
environment:
|
||||
- TZ=Etc/GMT # Specify timezone from TZ Database name found here https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||
#- SPOTIFY_CLIENT_ID=
|
||||
#- SPOTIFY_CLIENT_SECRET=
|
||||
#- SPOTIFY_REDIRECT_URI=http://multi-scrobbler-host-IP:9078/callback ## Need to be whitelisted in Spotify dashboard and is used for creating the connection first time
|
||||
#- MALOJA_URL=http://maloja:42010
|
||||
#- MALOJA_API_KEY=
|
||||
#- PUID=1000 # required if running docker on linux host, see main README Docker setup instructions
|
||||
#- PGID=1000 # required if running docker on linux host, see main README Docker setup instructions
|
||||
|
||||
# set if using a source/client with redirect URI that you have not explicitly set and MS is NOT running on the same machine that you will view the dashboard from
|
||||
# EX: You will view MS dashboard at 'http://192.168.0.101:9078' -> set BASE_URL=http://192.168.0.101:9078
|
||||
#- BASE_URL=http://MyHostIP:9078
|
||||
volumes:
|
||||
- "./config:/config"
|
||||
#networks:
|
||||
# - (optional to add container to the same bridge network that maloja is inside to be able to use docker internal networking & dns to resolve and connect to maloja URL via http://maloja:port)
|
||||
ports:
|
||||
- 9078:9078 # first port is the HOST port multi-scrobbler will serve UI and API on
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/with-contenv bash
|
||||
|
||||
# used https://github.com/linuxserver/docker-plex as a template
|
||||
|
||||
POPULATE_EXAMPLES=false
|
||||
|
||||
echo "-------------------------------------"
|
||||
echo -e "Setting up multi-scrobbler config directory based on CONFIG_DIR env: ${CONFIG_DIR}\n"
|
||||
|
||||
# make config folder if it does not exist
|
||||
if [ ! -d "${CONFIG_DIR}" ]; then
|
||||
echo "Directory does not exist! Creating..."
|
||||
POPULATE_EXAMPLES=true
|
||||
mkdir -p "${CONFIG_DIR}"
|
||||
else
|
||||
if [ "$(ls -A ${CONFIG_DIR})" ]; then
|
||||
echo "Directory is not empty, not creating examples."
|
||||
else
|
||||
POPULATE_EXAMPLES=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# add example configs
|
||||
if [ "$POPULATE_EXAMPLES" = true ]; then
|
||||
echo "Directory is empty, adding examples..."
|
||||
cp -r /app/config/. "${CONFIG_DIR}"/
|
||||
fi
|
||||
|
||||
# permissions
|
||||
echo "chown'ing directory to ensure correct permissions."
|
||||
chown -R abc:abc "${CONFIG_DIR}"
|
||||
echo "Done!"
|
||||
echo -e "-------------------------------------\n"
|
||||
@@ -0,0 +1 @@
|
||||
oneshot
|
||||
@@ -0,0 +1 @@
|
||||
/etc/s6-overlay/s6-rc.d/init-ms-config/run
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/with-contenv bash
|
||||
|
||||
# used https://github.com/linuxserver/docker-wikijs/blob/master/root/etc/s6-overlay/s6-rc.d/svc-wikijs/run as a template
|
||||
|
||||
# NODE_ARGS can be passed by ENV in docker command like "docker run foxxmd/multi-scrobbler -e NODE_ARGS=--optimize_for_size"
|
||||
echo -e "\nmulti-scrobbler is starting!"
|
||||
export NODE_ENV=production
|
||||
cd /app || exit
|
||||
exec \
|
||||
s6-setuidgid abc /usr/bin/node $NODE_ARGS /app/node_modules/.bin/tsx /app/src/backend/index.ts
|
||||
@@ -0,0 +1 @@
|
||||
longrun
|
||||
@@ -1,182 +0,0 @@
|
||||
# General
|
||||
|
||||
General configuration options. These must be set through environmental variables because they affect initial startup of
|
||||
the app. **These variables are also available to Docker containers.**
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|----------------------------|-----------|--------------|-------------------------------------------------------------------------------------------|
|
||||
| `CONFIG_DIR` | No | `CWD/config` | Directory to look for all other configuration files |
|
||||
| `LOG_PATH` | No | `CWD/logs` | If `false` no logs will be written. If `string` will be the directory logs are written to |
|
||||
| `PORT` | No | 9078 | Port to run web server on |
|
||||
|
||||
**The app must have permission to write to `CONFIG_DIR` in order to store the current spotify access token.**
|
||||
|
||||
|
||||
# Sources and (Scrobble) Clients
|
||||
|
||||
The app has two types of configurations:
|
||||
|
||||
* **Sources** -- Where plays are parsed from
|
||||
* **Clients** -- Scrobble clients that plays are scrobbled to
|
||||
|
||||
All configurations can be configured through:
|
||||
* environmental variables
|
||||
* individual **json** files for each source/client type found in the `CONFIG_DIR` directory IE `config/spotify.json`
|
||||
* or through the main `config.json` (also found in `CONFIG_DIR` directory) using the `clients` or `sources` property under `data`:
|
||||
|
||||
```json5
|
||||
// in config.json
|
||||
{
|
||||
//...
|
||||
"sources": [
|
||||
{
|
||||
"name": "myConfig",
|
||||
"type": "spotify",
|
||||
"clients": [
|
||||
"myMalojaClient"
|
||||
],
|
||||
"data": {
|
||||
"clientId": "anExample"
|
||||
//...
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
See [config.json.example](../config/config.json.example) for a short example of this or check out [the kitchen sink example](kitchensink.md).
|
||||
|
||||
### ENV-Based or JSON-Based?
|
||||
|
||||
multi-scrobbler can be configured differently depending on how you will use it. See which use-case fits you the best and then use that approach when setting up each configuration:
|
||||
|
||||
#### ENV-Based (Single User)
|
||||
|
||||
* You are the only person for whom the application is scrobbling
|
||||
* You may have many sources (Plex, Spotify, Tautulli...) but you only have one of each type of source
|
||||
* You have only one scrobble client
|
||||
* **Easier for small setups. Difficult for larger, multi-sourced setups (may want to switch to json)**
|
||||
* **Will not work for multi-user setups**
|
||||
|
||||
#### JSON-Based (Multi User)
|
||||
|
||||
* You are a single user but want to set up many sources
|
||||
* You want to use multi-scrobbler to scrobble for yourself and others IE family, friends, etc.
|
||||
* You may have many of each type of **Source** you want to scrobble from IE 2x Plex accounts, 3x Spotify accounts, 1x
|
||||
Funkwhale...
|
||||
* You have more than one scrobble **Client** you want to scrobble to IE multiple Maloja servers, one for each person
|
||||
* You want only some **Sources** to scrobble to some **Clients** IE Fred's Spotify account scrobbles to Fred's Maloja
|
||||
server, but not Mary's Maloja server
|
||||
|
||||
Note: While you may mix and match configuration approaches it is recommended to **only use ENV-based configs if you are
|
||||
doing everything in ENV-based configurations.**
|
||||
|
||||
# Sources
|
||||
|
||||
## [Spotify](https://www.spotify.com)
|
||||
|
||||
To access your Spotify history you must [register an application](https://developer.spotify.com/dashboard) to get a
|
||||
Client ID/Secret. Make sure to also whitelist your redirect URI in the application settings.
|
||||
|
||||
### ENV-Based
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|----------------------------|-----------|----------------------------------|----------------------------------------------------|
|
||||
| `SPOTIFY_CLIENT_ID` | Yes | | |
|
||||
| `SPOTIFY_CLIENT_SECRET` | Yes | | |
|
||||
| `SPOTIFY_ACCESS_TOKEN` | No | | Must include either this token or client id/secret |
|
||||
| `SPOTIFY_REFRESH_TOKEN` | No | | If using access token this is also recommended |
|
||||
| `SPOTIFY_REDIRECT_URI` | No | `http://localhost:{PORT}/callback` | URI must end in `callback` |
|
||||
|
||||
### JSON-Based
|
||||
|
||||
See [`spotify.json.example`](../config/spotify.json.example)
|
||||
|
||||
## [Plex](https://plex.tv)
|
||||
|
||||
Check the [instructions](plex.md) on how to setup a [webhooks](https://support.plex.tv/articles/115002267687-webhooks) to scrobble your plays.
|
||||
|
||||
### ENV-Based
|
||||
|
||||
| Environmental Variable | Required | Default | Description |
|
||||
|------------------------|----------|---------|-------------------------------------------------|
|
||||
| `PLEX_USER` | No | | The a comma-delimited list of usernames to scrobble tracks for. No usernames specified means all tracks by all users will be scrobbled. |
|
||||
|
||||
### JSON-Based
|
||||
|
||||
See [`plex.json.example`](../config/plex.json.example)
|
||||
|
||||
## [Tautulli](https://tautulli.com)
|
||||
|
||||
Check the [instructions](plex.md) on how to setup a notification agent.
|
||||
|
||||
### ENV-Based
|
||||
|
||||
| Environmental Variable | Required | Default | Description |
|
||||
|------------------------|----------|---------|-------------------------------------------------|
|
||||
| `TAUTULLI_USER` | No | | The a comma-delimited list of usernames to scrobble tracks for. No usernames specified means all tracks by all users will be scrobbled. |
|
||||
|
||||
### JSON-Based
|
||||
|
||||
See [`tautulli.json.example`](../config/tautulli.json.example)
|
||||
|
||||
## [Subsonic](http://www.subsonic.org/)
|
||||
|
||||
Can use this source for any application that implements the [Subsonic API](http://www.subsonic.org/pages/api.jsp) (such as [Airsonic](https://airsonic.github.io/))
|
||||
|
||||
**Known Issues:**
|
||||
* "Time played at" is somewhat inaccurate since the api only reports "played X minutes ago" so...
|
||||
* All scrobble times are therefore "on the minute" and you may experience occasional duplicate scrobbles
|
||||
* "played X minutes ago" sometimes is also not reported correctly
|
||||
* Multiple artists are reported as one value and cannot be separated
|
||||
* If using [Airsonic Advanced](https://github.com/airsonic-advanced/airsonic-advanced) the password used (under **Credentials**) must be **Decodable**
|
||||
|
||||
### ENV-Based
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|----------------------------|-----------|----------------------------------|----------------------------------------------------|
|
||||
| `SUBSONIC_USER` | Yes | | |
|
||||
| `SUBSONIC_PASSWORD` | Yes | | |
|
||||
| `SUBSONIC_URL` | Yes | | Base url of your subsonic-api server |
|
||||
|
||||
### JSON-Based
|
||||
|
||||
See [`subsonic.json.example`](../config/subsonic.json.example)
|
||||
|
||||
# Clients
|
||||
|
||||
## [Maloja](https://github.com/krateng/maloja)
|
||||
|
||||
### ENV-Based
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|----------------------------|-----------|---------|-------------------------------|
|
||||
| `MALOJA_URL` | Yes | | Base URL of your installation |
|
||||
| `MALOJA_API_KEY` | Yes | | Api Key |
|
||||
|
||||
### JSON-Based
|
||||
|
||||
See [`maloja.json.example`](../config/maloja.json.example)
|
||||
|
||||
## [Last.fm](https://www.last.fm)
|
||||
|
||||
[Register for an API account here.](https://www.last.fm/api/account/create)
|
||||
|
||||
The Callback URL is actually specified by multi-scrobbler but to keep things consistent you should use
|
||||
```
|
||||
http://localhost:9078/lastfm/callback
|
||||
```
|
||||
or replace `localhost:9078` with your own base URL
|
||||
|
||||
### ENV-Based
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|----------------------------|-----------|---------|-------------------------------|
|
||||
| `LASTFM_API_KEY` | Yes | | Api Key from your API Account |
|
||||
| `LASTFM_SECRET` | Yes | | Shared secret from your API Account |
|
||||
| `LASTFM_REDIRECT_URI` | No | `http://localhost:{PORT}/lastfm/callback` | Url to use for authentication. Must include `lastfm/callback` somewhere in it |
|
||||
| `LASTFM_SESSION` | No | | Session id. Will be generated by authentication flow if not provided. |
|
||||
|
||||
### JSON-Based
|
||||
|
||||
See [`lastfm.json.example`](../config/lastfm.json.example)
|
||||
@@ -0,0 +1,20 @@
|
||||
# Dependencies
|
||||
/node_modules
|
||||
|
||||
# Production
|
||||
/build
|
||||
|
||||
# Generated files
|
||||
.docusaurus
|
||||
.cache-loader
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
@@ -0,0 +1,41 @@
|
||||
# Website
|
||||
|
||||
This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator.
|
||||
|
||||
### Installation
|
||||
|
||||
```
|
||||
$ yarn
|
||||
```
|
||||
|
||||
### Local Development
|
||||
|
||||
```
|
||||
$ yarn start
|
||||
```
|
||||
|
||||
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
|
||||
|
||||
### Build
|
||||
|
||||
```
|
||||
$ yarn build
|
||||
```
|
||||
|
||||
This command generates static content into the `build` directory and can be served using any static contents hosting service.
|
||||
|
||||
### Deployment
|
||||
|
||||
Using SSH:
|
||||
|
||||
```
|
||||
$ USE_SSH=true yarn deploy
|
||||
```
|
||||
|
||||
Not using SSH:
|
||||
|
||||
```
|
||||
$ GIT_USER=<Your GitHub username> yarn deploy
|
||||
```
|
||||
|
||||
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
|
||||
@@ -0,0 +1,273 @@
|
||||
---
|
||||
toc_min_heading_level: 2
|
||||
toc_max_heading_level: 5
|
||||
---
|
||||
|
||||
## Connection Issues
|
||||
|
||||
### Plex (Webhook)/Tautulli/Jellyfin(Webhook)/Webscrobbler don't connect
|
||||
|
||||
These sources are **ingress-based** which means that multi-scrobbler waits for the Plex/Tautulli/Jellyfin server (or Webscrobbler extension) to contact multi-scrobbler, as opposed to multi-scrobbler contacting them.
|
||||
|
||||
multi-scrobbler will log information about any server that connects to it for these services. In the logs it looks something like this:
|
||||
|
||||
```
|
||||
2023-02-22T10:55:56-05:00 info : [Ingress - Plex ] Received request from a new remote address: ::ffff:192.168.0.140 (UA: PlexMediaServer/1.24.5.5173-8dcc73a59)
|
||||
2023-02-22T10:55:56-05:00 info : [Ingress - Plex ] ::ffff:192.168.0.140 (UA: PlexMediaServer/1.24.5.5173-8dcc73a59) Received valid data from server examplePlex for the first time.
|
||||
2023-02-22T10:55:56-05:00 warn : [Plex Request ] Received valid Plex webhook payload but no Plex sources are configured
|
||||
```
|
||||
It also logs if a server tries to connect to a URL that it does not recognize:
|
||||
```
|
||||
2023-02-22T11:16:12-05:00 debug : [App ] Server received POST request from ::ffff:192.168.0.140 (UA: PlexMediaServer/1.24.5.5173-8dcc73a59) to unknown route: /plkex
|
||||
```
|
||||
**So, if you do not see either of these in your logs then Plex/Tautulli/Jellyfin/Webscrobbler is not able to connect to your multi-scrobbler instance at all.**
|
||||
|
||||
This is not something multi-scrobbler can fix and means you have an issue in your network.
|
||||
|
||||
#### Troubleshooting
|
||||
|
||||
Check or try all these steps before submitting an issue:
|
||||
|
||||
##### Turn on Debug Logging
|
||||
|
||||
First, turn on **debug** logging for multi-scrobbler by setting the environmental variable `DEBUG_MODE=true`. Then, check the output for any additional information.
|
||||
|
||||
##### Check Host name and URL
|
||||
|
||||
The URLs examples in the [configuration](configuration/configuration.mdx) documentation assume you are running Plex/Tautulli/Jellyfin/Webscrobbler on the same server as multi-scrobbler. If these are not the same machine then you need to determine the IP address or domain name that multi-scrobbler is reachable at and use that instead of `localhost` when configuring these sources. **This is likely the same host name that you would use to access the web interface for multi-scrobbler.**
|
||||
|
||||
EX `http://localhost:9078/plex` -> `http://192.168.0.140:9078/plex`
|
||||
|
||||
##### Check Firewall and Port Forwarding
|
||||
|
||||
If the machine multi-scrobbler is running on has a firewall ensure that port **9078** is open. Or if it is in another network entirely make sure your router is forwarding this port and it is open to the correct machine.
|
||||
|
||||
##### Check Source Service Logs
|
||||
|
||||
Plex/Tautulli/Jellyfin all have logs that will log if they cannot connect to multi-scrobbler. Check these for further information.
|
||||
|
||||
###### Plex
|
||||
|
||||
Settings -> Manage -> Console
|
||||
|
||||
###### Tautulli
|
||||
|
||||
Check the command-line output of the application or docker logs.
|
||||
|
||||
###### Jellyfin
|
||||
|
||||
Administration -> Dashboard -> Advanced -> Logs
|
||||
|
||||
###### Webscrobbler
|
||||
|
||||
See [Debugging the extension](https://github.com/web-scrobbler/web-scrobbler/wiki/Debug-the-extension) to get logs which should have information about failed requests.
|
||||
|
||||
### Jellyfin (Webhook) has warnings about undefined or missing data
|
||||
|
||||
Make sure you have
|
||||
* [Configured the webhook plugin correctly](configuration/configuration.mdx#jellyfin)
|
||||
* Checked the **Send All Properties(ignores template)** option in the webhook settings and **Saved**
|
||||
|
||||
multi-scrobbler is known to work on Jellyfin `10.8.9` with Webhook version `11.0.0.0`.
|
||||
|
||||
You can verify the payload sent from the webhook by enabling Debug Mode with the env `DEBUG_MODE=true` which will output the raw payload to DEBUG level logging.
|
||||
|
||||
If your issue persists and you open an Issue for it please include the raw payload logs in your report.
|
||||
|
||||
### Jellyfin (Webhook) has warnings about missing headers
|
||||
|
||||
If you experience issues trying to scrobble with Jellyfin and find this in your MS logs
|
||||
|
||||
```
|
||||
[API] Jellyfin is not sending a request with valid headers...
|
||||
```
|
||||
|
||||
A workaround that may fix this:
|
||||
|
||||
* In Webhook settings:
|
||||
* [In the webhook you have already configured...](configuration/configuration.mdx#jellyfin)
|
||||
* Add Request Header...
|
||||
* **Key:** `Content-Type`
|
||||
* **Value:** `application/json`
|
||||
* Then Save
|
||||
|
||||
### Spotify/Deezer/LastFM won't authenticate
|
||||
|
||||
Ensure any **client id** or **secrets** are correct in your configuration.
|
||||
|
||||
The callback/redirect URL for these services must be:
|
||||
|
||||
* the same address you would use to access the multi-scrobbler web interface
|
||||
* the web-interface must be accessible from the browser you are completing authentication from.
|
||||
|
||||
If multi-scrobbler is not running on the same machine your browser is on then the default/example addresses (`http://localhost...`) **will not work.** You must determine the address you can reach the web interface at (such as `http://192.168.0.140:9078`) then use that in place of `localhost` in the callback URLs.
|
||||
|
||||
EX `http://localhost:9078/lastfm/callback` -> `http://192.168.0.220:9078/lastfm/callback`
|
||||
|
||||
### Deezer is not working
|
||||
|
||||
Deezer has discontinued support for their API and the Deezer Source is now [**deprecated.**](configuration/configuration.mdx#deezer) See [this issue for more discussion.](https://github.com/FoxxMD/multi-scrobbler/issues/175#issuecomment-2296776625)
|
||||
|
||||
### Youtube Music fails after some time
|
||||
|
||||
If you see errors in multi-scrobbler for YTM that contain **401** or **403** like
|
||||
|
||||
```
|
||||
Error: Could not send the specified request to browse. Status code: 401
|
||||
```
|
||||
|
||||
then YTM has invalidated your authentication.
|
||||
|
||||
First, ensure you are NOT using [YoutubeTV authentication.](configuration/configuration.mdx?ytmAuth=ytt#youtube-music) If you completed authentication by entering a "User Code" you are using YoutubeTV which has stopped working. You should reauthenticate using **Cookies** or **Custom OAuth.**
|
||||
|
||||
#### When using OAuth Client Authentication
|
||||
|
||||
Refresh your authentication by using the **(Re)authenticate** link from MS's web dashboard.
|
||||
|
||||
#### When using Cookies Authentication
|
||||
|
||||
The library MS uses relies on scraping the YTM site by using cookies from your actual browser to pretend it is a browser. It does its best to keep these up to date but since this is not an official way to access the service YTM may invalidate your access _to the authenticated session_ at any time. How this is triggered is unknown and not something multi-scrobbler can control. You can help limit the chance of your session being invalidated by [getting the cookie from an Incognito/Private Session](https://github.com/LuanRT/YouTube.js/issues/803#issuecomment-2504032666) and then immediately closing the browser afterwards.
|
||||
|
||||
To re-authenticate MS [follow the YTM instructions to retrieve a new set of cookies for multi-scrobbler](configuration/configuration.mdx?ytmAuth=cookie#youtube-music) and then restart MS to potentially resolve the problem.
|
||||
|
||||
## Configuration Issues
|
||||
|
||||
### Config could not be parsed
|
||||
|
||||
If you see something like this in your logs:
|
||||
|
||||
```
|
||||
2023-02-19T10:05:42-06:00 warn : [App] App config file exists but could not be parsed!
|
||||
2023-02-19T10:05:42-06:00 error : [App] Exited with uncaught error
|
||||
2023-02-19T10:05:42-06:00 error : [App] Error: config.json could not be parsed
|
||||
```
|
||||
|
||||
It means the JSON in your configuration file is not valid. Copy and paste your configuration into a site like [JSONLint](https://jsonlint.com/) to find out where errors you have and fix them.
|
||||
|
||||
## Scrobbling Issues
|
||||
|
||||
### Last.fm does not scrobble tracks with multiple artists correctly
|
||||
|
||||
This is a limitation of the [Last.fm API](https://www.last.fm/api/show/track.scrobble) where the **artist** field is only one string and Last.fm does not recognize (play well) with "combined" artists.
|
||||
|
||||
Multi-scrobbler works the same was the official Spotify-Last.fm integration works -- it only scrobbles the **first** artist on a multi-artist track.
|
||||
|
||||
### Jellyfin (Webhook) does not scrobble tracks with multiple artists correctly
|
||||
|
||||
This is a limitation caused by the [Jellyfin webhook plugin](https://github.com/FoxxMD/multi-scrobbler/issues/70#issuecomment-1443804712) only sending the first artist to multi-scrobbler. This issues needs to be [fixed upstream on the Jellyfin webhook repository.](https://github.com/jellyfin/jellyfin-plugin-webhook/issues/166)
|
||||
|
||||
You can avoid this entirely by using the newer [Jellyfin API Source](configuration/configuration.mdx#jellyfin) which correctly handles multiple artists.
|
||||
|
||||
### Google Cast track information is missing/incorrect or MS player has weird times
|
||||
|
||||
The Google Cast integration relies on a few common fields in the data it receives from your casting device. Every platform that can cast (Spotify, Pandora, etc...) *should* use these fields the same but there are slight differences between their implementations that may confuse multi-scrobbler. Specific platforms may also return more information in non-common fields that are undocumented.
|
||||
|
||||
To diagnose these issues you [**must enable payload logging**](configuration/configuration.mdx#cast-troubleshooting) for your google cast Source, run MS, and then include logs with this output from that run. Without the raw data logged from your cast device it will be nearly impossible to resolve your issue.
|
||||
|
||||
### Google Cast device does not track media
|
||||
|
||||
It is likely the app playing on the cast device is incorrectly reporting the media type as **not music**.
|
||||
|
||||
MS logs will tell you what type the media is reported as with lines like:
|
||||
|
||||
```
|
||||
My Artist - Example Track has 'unknown' media type and allowUnknownMedia=false, will not track
|
||||
```
|
||||
|
||||
Refer to [Allow Unknown Media Type](configuration/configuration.mdx#allow-unknown-media-type) section to fix this
|
||||
|
||||
```
|
||||
My Artist - Example Track has 'movie' media type so will not track
|
||||
```
|
||||
|
||||
Refer to [Force Media Tracking](configuration/configuration.mdx#forcing-media-tracking) section to fix this
|
||||
|
||||
### VLC is not scrobbling fields correctly
|
||||
|
||||
Before reporting an issue turn on metadata logging in the MS VLC configuration, [see the VLC documentation.](configuration/configuration.mdx#vlc-information-reporting)
|
||||
|
||||
### Youtube Music misses or duplicates scrobbles
|
||||
|
||||
<details>
|
||||
|
||||
<summary>How MS detects YTM history</summary>
|
||||
|
||||
In order for multi-scrobbler to accurately determine if a song has been scrobbled it needs **a source of truth.** For YTM this is a "history" list scraped from the YTM website. Unfortunately, the data in this list can be (often) inconsistent which makes it hard for multi-scrobbler to "trust" that it is correct and determine when/if new scrobbles occur. This inconsistency is not something multi-scrobbler can control -- it is a side-effect of having to use an unofficial method to access YTM (scraping).
|
||||
|
||||
To compensate for this multi-scrobbler resets when it considers this list the "source of truth" based on if the list changes in an inconsistent way between consecutive checks. New scrobbles can only be detected when this list is "OK" as a source of truth for N+1 checks. Therefore, any new tracks that appear when the list is inconsistent will be ignored.
|
||||
|
||||
Duplicate scrobbles can also occur if the change between two checks is technically consistent. For instance, if you listen to a track twice in some period, separated by other music, YTM will sometimes "remove" the track from the earlier time (further down in your history) and "re-add" it at the top of the history.
|
||||
|
||||
</details>
|
||||
|
||||
#### Reporting YTM scrobble issues
|
||||
|
||||
If you experience scrobble detection issues you can help improve MS's YTM heureistic by providing thorough feedback as [an issue.](https://github.com/FoxxMD/multi-scrobbler/issues/new?assignees=&labels=bug&projects=&template=01-bug-report.yml&title=bug%3A+) **Please do the following to provide the most useful report:**
|
||||
|
||||
##### Turn on Change Detection
|
||||
|
||||
In your YTM configuration (`ytmusic.json`) add `logDiff` under `options` like this:
|
||||
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ytmusic",
|
||||
"name": "MyYTM",
|
||||
"data": { ... },
|
||||
"options": {
|
||||
"logDiff": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
or set either ENVs:
|
||||
|
||||
* `YTM_LOG_DIFF=true`
|
||||
* [`DEBUG_MODE=true`](configuration/configuration.mdx?#debug-mode)
|
||||
|
||||
This will cause MS to log YTM history changes similar to this:
|
||||
|
||||
```
|
||||
[Ytmusic - MyYTM] Changes from last seen list:
|
||||
1. (tuhe1CpHRxY) KNOWER - I’m The President --- undefined => Moved - Originally at 6
|
||||
2. (Mtg8V6Xa2nc) Vulfpeck - Romanian Drinking Song --- Schvitz => Moved - Originally at 1
|
||||
3. (rxbCaiyYSXM) Nightmares On Wax - You Wish --- In A Space Outta Sound => Moved - Originally at 2
|
||||
4. (tMt_YXr90AM) Gorillaz - O Green World --- undefined => Moved - Originally at 3
|
||||
...
|
||||
```
|
||||
|
||||
Which are essential to troubleshooting this behavior.
|
||||
|
||||
|
||||
##### Turn on File Logging
|
||||
|
||||
Optionally, if your logs are busy due to many other sources, you can log **only** YTM logs to a file. Turn this on with the `logToFile` config option:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ytmusic",
|
||||
"name": "MyYTM",
|
||||
"data": { ... },
|
||||
"options": {
|
||||
"logDiff": true,
|
||||
"logToFile": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This will cause MS to write YTM logs to a file in your `logs` folder named `ytmusic-MyYTM.log` (based on your source name).
|
||||
|
||||
##### Provide Detail and Context
|
||||
|
||||
Provide a detailed account of how you were using YTM when the issue occurred, including things like:
|
||||
|
||||
* the platform listening on (desktop, mobile, 3rd party client, etc...)
|
||||
* any changes in platform
|
||||
* > I switched from desktop to listening on my phone...
|
||||
* how you were listening to music
|
||||
* > I was playing an album start to finish
|
||||
* > I listened to two songs in a row, then browsed for a new song in library by artist, then went back to a song in the queue...
|
||||
|
||||
Explain the expected behavior (it should have scrobbled songs x, y, then z) and what actually happened (it scrobbled songs x, then y, then x again, then z)
|
||||
|
||||
Provide ALL logs from the time when the issue occurred including logs from BEFORE (ideally 2-3 minutes of logs) and AFTER the issue.
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"label": "Configuration",
|
||||
"position": 3,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Configuring Multi-Scrobbler and examples"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
title: Kitchen Sink
|
||||
---
|
||||
|
||||
# Example Config using all Possible Features
|
||||
|
||||
Scenario:
|
||||
@@ -6,17 +11,17 @@ Scenario:
|
||||
* Each person has their own Maloja server
|
||||
* Each person has their own Spotify account
|
||||
* You have your own Airsonic (subsonic) server you to scrobble from
|
||||
* You have your own Youtube Music account you want to scrobble from
|
||||
* Mary has her own Last.fm account she also wants to scrobble to
|
||||
* Fred has his own Spotify application and provides you with just his access and refresh token because he doesn't trust you (wtf Fred)
|
||||
* Fred has a Plex server and wants to scrobble everything he plays
|
||||
* Mary uses Fred's Plex server but only wants to scrobble her plays from the `podcast` library
|
||||
* The three of you have a shared library on Plex called `party` that you only play when you are hanging out. You want plays from that library to be scrobbled to everyone's servers.
|
||||
* Fred also has his own Jellyfin server and wants to scrobble everything he plays
|
||||
|
||||
### All-in-one Config
|
||||
|
||||
Using just one config file located at `CONFIG_DIR/config.json`:
|
||||
|
||||
```json5
|
||||
```json5 title="CONFIG_DIR/config.json"
|
||||
{
|
||||
"sourceDefaults": {
|
||||
"maxPollRetries": 0, // optional, default # of automatic polling restarts on error. can be overridden by property in individual config
|
||||
@@ -35,6 +40,8 @@ Using just one config file located at `CONFIG_DIR/config.json`:
|
||||
"data": {
|
||||
"clientId": "foxxSpotifyAppId",
|
||||
"clientSecret": "foxxSpotifyAppSecret",
|
||||
},
|
||||
"options": {
|
||||
"maxRequestRetries": 2, // override default max retries because spotify can...spotty
|
||||
}
|
||||
},
|
||||
@@ -62,7 +69,9 @@ Using just one config file located at `CONFIG_DIR/config.json`:
|
||||
"name": "fredPlex",
|
||||
"clients": ["fredMaloja"],
|
||||
"data": {
|
||||
"user": ["fred@email.com"]
|
||||
"token": "JtRnwQWD__XMJF8sT3jc",
|
||||
"url": "192.168.0.233:32400",
|
||||
"usersallow": ["fredUser"]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -70,8 +79,10 @@ Using just one config file located at `CONFIG_DIR/config.json`:
|
||||
"name": "maryPlex",
|
||||
"clients": ["maryMaloja"],
|
||||
"data": {
|
||||
"user": ["mary@email.com"], // still need to specify mary as user so not all users who play from 'podcasts' get scrobbled
|
||||
"libraries": ["podcasts"]
|
||||
"token": "JtRnwQWD__XMJF8sT3jc",
|
||||
"url": "192.168.0.233:32400",
|
||||
"usersallow": ["maryUser"], // still need to specify mary as user so not all users who play from 'podcasts' get scrobbled
|
||||
"librariesAllow": ["podcasts"]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -79,7 +90,20 @@ Using just one config file located at `CONFIG_DIR/config.json`:
|
||||
"name": "partyPlex",
|
||||
// omitting clients (or making it empty) will make this Source scrobble to all Clients
|
||||
"data": {
|
||||
"libraries": ["party"],
|
||||
"token": "JtRnwQWD__XMJF8sT3jc",
|
||||
"url": "192.168.0.233:32400",
|
||||
"librariesAllow": ["party"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "jellyfin",
|
||||
"name": "FredJelly",
|
||||
// omitting clients (or making it empty) will make this Source scrobble to all Clients
|
||||
"data": {
|
||||
"url": "http://localhost:8096",
|
||||
"user": "FoxxMD",
|
||||
"apiKey": "c9fae8756fbf481ebd9c5bb56bd6540c",
|
||||
"usersAllow": ["fredUser"]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -92,6 +116,14 @@ Using just one config file located at `CONFIG_DIR/config.json`:
|
||||
"url": "https://airsonic.foxx.example"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "ytmusic",
|
||||
"name": "foxxYoutube",
|
||||
"clients": ["foxxMaloja"],
|
||||
"data": {
|
||||
"cookie": "__Secure-3PAPISID=3AxsXpy0MKGu75Qb/AkISXGqOnSDn1jEKn; DEVICE_INFO=ChxOekU0Tmpjek5EWTBPRGd3TlRBMk16QXpNdz09EJbS8Z0GGJbS8Z0G; ...",
|
||||
}
|
||||
},
|
||||
],
|
||||
"clients": [
|
||||
{
|
||||
@@ -124,6 +156,7 @@ Using just one config file located at `CONFIG_DIR/config.json`:
|
||||
"data": {
|
||||
"apiKey": "maryApiKey",
|
||||
"secret": "marySecret",
|
||||
"redirectUri": "http://localhost:9078/lastfm/callback"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -133,9 +166,7 @@ Using just one config file located at `CONFIG_DIR/config.json`:
|
||||
|
||||
### Separate JSON files
|
||||
|
||||
In `CONFIG_DIR/spotify.json`:
|
||||
|
||||
```json5
|
||||
```json5 title="CONFIG_DIR/spotify.json"
|
||||
[
|
||||
{
|
||||
// may omit 'type' property since app knows this is file is for spotify configs
|
||||
@@ -143,7 +174,7 @@ In `CONFIG_DIR/spotify.json`:
|
||||
"clients": ["foxxMaloja"],
|
||||
"data": {
|
||||
"clientId": "foxxSpotifyAppId",
|
||||
"clientSecret": "foxxSpotifyAppSecret",
|
||||
"clientSecret": "foxxSpotifyAppSecret"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -151,7 +182,7 @@ In `CONFIG_DIR/spotify.json`:
|
||||
"clients": ["maryMaloja"],
|
||||
"data": {
|
||||
"clientId": "foxxSpotifyAppId",
|
||||
"clientSecret": "foxxSpotifyAppSecret",
|
||||
"clientSecret": "foxxSpotifyAppSecret"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -160,43 +191,72 @@ In `CONFIG_DIR/spotify.json`:
|
||||
"data": {
|
||||
"accessToken": "fredsToken",
|
||||
"refreshToken": "fredsRefreshToken",
|
||||
"interval": 120,
|
||||
"interval": 120
|
||||
}
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
In `CONFIG_DIR/plex.json`
|
||||
|
||||
```json5
|
||||
```json5 title="CONFIG_DIR/plex.json"
|
||||
[
|
||||
{
|
||||
"name": "fredPlex",
|
||||
"clients": ["fredMaloja"],
|
||||
"data": {
|
||||
"user": ["fred@email.com"]
|
||||
"token": "JtRnwQWD__XMJF8sT3jc",
|
||||
"url": "192.168.0.233:32400",
|
||||
"usersallow": ["fredUser"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maryPlex",
|
||||
"clients": ["maryMaloja"],
|
||||
"data": {
|
||||
"user": ["mary@email.com"],
|
||||
"libraries": ["podcasts"]
|
||||
"token": "JtRnwQWD__XMJF8sT3jc",
|
||||
"url": "192.168.0.233:32400",
|
||||
"usersallow": ["maryUser"],
|
||||
"librariesAllow": ["podcasts"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "partyPlex",
|
||||
"data": {
|
||||
"libraries": ["party"],
|
||||
"token": "JtRnwQWD__XMJF8sT3jc",
|
||||
"url": "192.168.0.233:32400",
|
||||
"librariesAllow": ["party"]
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
In `CONFIG_DIR/maloja.json`:
|
||||
```json5 title="CONFIG_DIR/jellyfin.json"
|
||||
[
|
||||
{
|
||||
"name": "FredJelly",
|
||||
"data": {
|
||||
"url": "http://localhost:8096",
|
||||
"user": "FoxxMD",
|
||||
"apiKey": "c9fae8756fbf481ebd9c5bb56bd6540c",
|
||||
"usersAllow": ["fredUser"]
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
```json5
|
||||
```json5 title="CONFIG_DIR/ytmusic.json"
|
||||
[
|
||||
{
|
||||
"type": "ytmusic",
|
||||
"name": "foxxYoutube",
|
||||
"clients": ["foxxMaloja"],
|
||||
"data": {
|
||||
"cookie": "__Secure-3PAPISID=3AxsXpy0MKGu75Qb/AkISXGqOnSDn1jEKn; DEVICE_INFO=ChxOekU0Tmpjek5EWTBPRGd3TlRBMk16QXpNdz09EJbS8Z0GGJbS8Z0G; ...",
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
```json5 title="CONFIG_DIR/maloja.json"
|
||||
[
|
||||
{
|
||||
"name": "foxxMaloja",
|
||||
@@ -222,15 +282,14 @@ In `CONFIG_DIR/maloja.json`:
|
||||
]
|
||||
```
|
||||
|
||||
In `CONFIG_DIR/lastfm.json`:
|
||||
|
||||
```json5
|
||||
```json5 title="CONFIG_DIR/lastfm.json"
|
||||
[
|
||||
{
|
||||
"name": "maryLFM",
|
||||
"data": {
|
||||
"apiKey": "maryApiKey",
|
||||
"secret": "marySecret",
|
||||
"redirectUri": "http://localhost:9078/lastfm/callback"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 52 KiB |
@@ -1,3 +1,16 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: Plex/Tautulli (Deprecated)
|
||||
---
|
||||
|
||||
:::warning[Deprecated]
|
||||
|
||||
Multi-scrobbler >= 0.8.8 supports a Plex Source that [directly uses the API](configuration/configuration.mdx?plexType=api#plex) which removes the need to use Tautulli since it does not require Plex Pass.
|
||||
|
||||
The Tautulli source **and** Plex Webhook source are deprecated and will be removed in an upcoming release. Existing users should migrate to the API source as soon as possible.
|
||||
|
||||
:::
|
||||
|
||||
Tracks played on [Plex](https://plex.tv/) can be scrobbled either by:
|
||||
* A [Tautulli](https://tautulli.com/) notification agent with a webhook.
|
||||
* Using Plex [Webhooks](https://support.plex.tv/articles/115002267687-webhooks) (restricted to Plex Pass users)
|
||||
@@ -40,8 +53,17 @@ Expand the **Watched** dropdown and add the following code block to the **JSON D
|
||||
"album_name": "{album_name}",
|
||||
"media_type": "{media_type}",
|
||||
"title": "{title}",
|
||||
"duration": "{duration}",
|
||||
"username": "{username}"
|
||||
"duration": "{duration_sec}",
|
||||
"username": "{username}",
|
||||
"server": "{server_name}",
|
||||
"version": "{server_version}",
|
||||
"library": "{library_name}",
|
||||
"player": "{player}",
|
||||
"device": "{device}",
|
||||
"platform": "{platform}",
|
||||
"action": "{action}",
|
||||
"machine_id": "{machine_id}",
|
||||
"session_key": "{session_key}"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
---
|
||||
sidebar_position: 4
|
||||
title: Scrobble Modification
|
||||
toc_max_heading_level: 4
|
||||
---
|
||||
|
||||
Multi-scrobbler configs support the ability to modify scrobble data in an automated fashion by matching and replacing strings in **title, artists, and album** at many different times in multi-scrobbler's lifecycle.
|
||||
|
||||
### Why?
|
||||
|
||||
You may need to "clean up" data from a Source or before sending to a scrobble Client due to any number of reasons:
|
||||
|
||||
* ID3 tags in your music collection are dirty or have repeating garbage IE `[YourMusicSource.com] My Artist - My Title`
|
||||
* A Source's service often incorrectly adds data to some field IE `My Artist - My Title (Album Version)` when the title should just be `My Title`
|
||||
* An Artist you listen to often is spelled different between a Source and a Client which causes duplicate scrobbles
|
||||
|
||||
In any scenario where a repeating pattern can be found in the data it would be nice to be able to fix it before the data gets downstream or to help prevent duplicate scrobbling. Multi-scrobbler can help you do this.
|
||||
|
||||
## Overview
|
||||
|
||||
### Journey of a Scrobble
|
||||
|
||||
First, let's recap the lifecycle of a scrobble in multi-scrobbler:
|
||||
|
||||
**Sources** are the beginning of the journey for a **Play** (song you've listened to long enough to be scrobblable)
|
||||
|
||||
* A Source finds a new valid **Play**
|
||||
* The Source **compares** this new Play to all the other Plays it has already seen, if the Play is unique (title/artist/album/listened datetime) then...
|
||||
* The Source **discovers** the Play, adds it to Plays it has seen already, and broadcasts the Play should be scrobbled to all Clients
|
||||
|
||||
Scrobble **Clients** listen for discovered Plays from Sources, then...
|
||||
|
||||
* A Client receives a **Play** from a Source
|
||||
* The Client **compares** this Play to all the other scrobbles it has already seen, if the Play is unique (title/artist/album/listened datetime) then...
|
||||
* The Client **scrobbles** the Play downstream to the scrobble service and adds it as a Scrobble it has seen already
|
||||
|
||||
### Lifecyle Hooks
|
||||
|
||||
You'll notice there is a pattern above that looks like this:
|
||||
|
||||
* **Before** data is compared
|
||||
* Data is **compared**
|
||||
* **After** data is compared
|
||||
|
||||
These points, during both Source and Client processes, are when you can hook into the scrobble lifecycle and modify it.
|
||||
|
||||
#### TLDR
|
||||
|
||||
In more concrete terms this is the structure of hooks within a configuration (can be used in any **Source** or **Client**):
|
||||
|
||||
```json5 title="lastfm.json" {10-14}
|
||||
[
|
||||
{
|
||||
"name": "myLastFm",
|
||||
"enable": true,
|
||||
"configureAs": "source",
|
||||
"data": {
|
||||
// ...
|
||||
},
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"preCompare": {/* ... */},
|
||||
"compare": {/* ... */},
|
||||
"postCompare": {/* ... */}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
##### Hook
|
||||
|
||||
For **Sources**:
|
||||
|
||||
* `preCompare` - modify Play data immediately when received
|
||||
* `compare` - temporarily modify Play data when it is being compared to see if Play was already discovered
|
||||
* `postCompare` - modify Play data before sending to scrobble **Clients**
|
||||
|
||||
For **Clients**:
|
||||
|
||||
* `preCompare` - modify Play data immediately when received
|
||||
* `compare` - temporarily modify Play data when it is being compared to see if it was already scrobbled
|
||||
* `postCompare` - modify Play data before scrobbling it to downstream service and adding to already seen scrobbles
|
||||
|
||||
:::tip
|
||||
|
||||
Keep in mind that modifying Scrobble/Play data earlier in the lifecycle will affect that data at all times later in the lifecycle.
|
||||
|
||||
For example, to modify the track so it's the same anywhere it is processed in multi-scrobbler you only need to modify it in the **Source's** `preCompare` hook because all later processes will receive the data with the modified track.
|
||||
|
||||
:::
|
||||
|
||||
### Modification Parts
|
||||
|
||||
|
||||
Each [**hook**](#hook) (`preCompare` etc...) is an object that specifies what part of the **Play** to modify:
|
||||
|
||||
```json5
|
||||
{
|
||||
"title": [/* ... */],
|
||||
"artists": [/* ... */],
|
||||
"album": [/* ... */]
|
||||
}
|
||||
```
|
||||
|
||||
##### Expression
|
||||
|
||||
and then a **list** what pattern/replacements (expressions) to use for the modification by using either simple strings or `search-replace` objects:
|
||||
|
||||
```json5
|
||||
[
|
||||
"badTerm", // remove all instances of 'badTerm'
|
||||
{
|
||||
"search": "anotherBadTerm", // and also match all instances of 'anotherBadTerm'
|
||||
"replace": "goodTerm" // replace with the string 'goodTerm'
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Putting it all together:
|
||||
|
||||
```json5 title="lastfm.json"
|
||||
[
|
||||
{
|
||||
"name": "myLastFm",
|
||||
"enable": true,
|
||||
"configureAs": "source",
|
||||
"data": {
|
||||
// ...
|
||||
},
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"preCompare": {
|
||||
"title": [
|
||||
[
|
||||
"badTerm",
|
||||
{
|
||||
"search": "badTerm",
|
||||
"replace": "goodTerm"
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
:::note
|
||||
|
||||
If the value of the field (title, an artist, album) is an empty string after transforming then the field is **removed.**
|
||||
|
||||
:::
|
||||
|
||||
:::tip
|
||||
|
||||
Modifications can also be applied to **all Sources** or **all Clients** when using the [AIO Config](./configuration.mdx?configType=aio#configuration-types) `config.json` by setting `playTransform` in `sourceDefaults` or `clientDefaults`:
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
```json5 title="config.json"
|
||||
{
|
||||
"sourceDefaults": { // will apply playTransform to all sources
|
||||
"playTransform": {
|
||||
"preCompare": {
|
||||
"title": [
|
||||
"(Album Version)"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"sources": [/* ... */],
|
||||
"clients": [/* ... */]
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
:::
|
||||
|
||||
#### Compare Hook
|
||||
|
||||
The `compare` [hook](#hook) is slightly different than `preCompare` and `postCompare`. It consists of an object where you define which side(s) of the comparison should be modified. It also **does not modify downstream data!** Instead, the modifications are made only for use in the comparison.
|
||||
|
||||
```json5 title="lastfm.json"
|
||||
[
|
||||
{
|
||||
"name": "myLastFm",
|
||||
// ...
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"compare": {
|
||||
"candidate": {/* ... */}, // modify the "new" Play being compared
|
||||
"existing": {/* ... */}, // modify all "existing" Play/Scrobbles the new Play is being compared against
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### Regular Expressions
|
||||
|
||||
In addition to plain strings [expressions](#expression) that are matched and removed you can also use Regular Expressions. Write your regex like you normally would, but as a string, and it'll automatically be parsed:
|
||||
|
||||
```json5
|
||||
[
|
||||
"/^\(\w+.com)/i", // matches any string that starts with '(YourMusic.com)' and removes it
|
||||
{
|
||||
"search": "/^\(\w+.com)/i", // matches any string that starts with '(YourMusic.com)'
|
||||
"replace": "[MySite.com]" // replace with the string '[MySite.com]'
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
The `replace` property uses javascript's [`replace()` function and so can use any special string characters.](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#specifying_a_string_as_the_replacement)
|
||||
|
||||
### Conditional Modification
|
||||
|
||||
#### "When" Condition
|
||||
|
||||
Top-level hooks **and** individual rules also support a `when` key for testing **if they should be run.**
|
||||
|
||||
The `when` key is similar to a normal [modification](#modification-parts) except:
|
||||
|
||||
* the keys accept a single string instead of an array
|
||||
* the `when` key data is an array instead of a single object
|
||||
|
||||
All parts of an individual `when` clause must test true to "pass" but if **any** `when` clauses pass the hook/rule is processed. Example `when` data:
|
||||
|
||||
```json5
|
||||
{
|
||||
"when": [
|
||||
{
|
||||
"artist": "Elephant Gym", // both of these must match the Play object (AND)
|
||||
"album": "Dreams" // both of these must match the Play object (AND)
|
||||
},
|
||||
// OR
|
||||
{
|
||||
"title": "/(Remastered)$/", // both of these must match the Play object (AND)
|
||||
"album": "Various Artists" // both of these must match the Play object (AND)
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
More succinctly:
|
||||
|
||||
* All parts (`artist` `album` `title`) of a `when` are `AND` conditions
|
||||
* All part-objects in the `when` array are `OR` conditions
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example of top-level hook with when condition</summary>
|
||||
|
||||
```json5
|
||||
{
|
||||
// IF the artist is Elephant Gym
|
||||
// THEN Run preCompare hook ELSE skip this hook
|
||||
//
|
||||
// Run search-replace on album
|
||||
// Run regex title remove
|
||||
"sourceDefaults": {
|
||||
"playTransform": {
|
||||
"preCompare": {
|
||||
"when": [
|
||||
{
|
||||
"artist": "/Elephant Gym/"
|
||||
}
|
||||
],
|
||||
"album": [
|
||||
{
|
||||
"search": "Dreams",
|
||||
"replace": "夢境"
|
||||
}
|
||||
],
|
||||
"title": ["/\s\-\s滾石40\s滾石撞樂隊\s40團拚經典(.+)$/i"]
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example of individual rule with when condition</summary>
|
||||
|
||||
```json5
|
||||
{
|
||||
// Always run preCompare
|
||||
//
|
||||
// On search-replace in title...
|
||||
// IF artist matches "Elephant Gym"
|
||||
// THEN Run regex search-replace ELSE skip this rule
|
||||
//
|
||||
// Run live|remastered regex remove
|
||||
"sourceDefaults": {
|
||||
"playTransform": {
|
||||
"preCompare": {
|
||||
"title": [
|
||||
{
|
||||
"search": "/\\s\\-\\s滾石40\\s滾石撞樂隊\\s40團拚經典(.+)$/i",
|
||||
"replace": "",
|
||||
"when": [
|
||||
{
|
||||
"artist": "/Elephant Gym/"
|
||||
}
|
||||
]
|
||||
},
|
||||
"/(\\s\\-\\s|\\s)(feat\\.(.+)|live|remastered(.+))$/i"
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
#### Top-level Hook array
|
||||
|
||||
Top-level hooks can also be an array of hooks. This makes creating multiple scenarios for top-level `when`-gated hooks easier. All hooks in the array will be run (assuming their `when`'s pass, if they exist) and their **input will be the Play object output of the previous hook in the array.**
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
```json5
|
||||
{
|
||||
"sourceDefaults": {
|
||||
"playTransform": {
|
||||
"preCompare": [
|
||||
// first lifecycle hook of preCompare to run
|
||||
{
|
||||
"title": [
|
||||
{
|
||||
"search": "something",
|
||||
"replace": "else unique"
|
||||
}
|
||||
]
|
||||
},
|
||||
// second lifecycle hook of preCompare to run
|
||||
{
|
||||
"title": [
|
||||
{
|
||||
"search": "else unique",
|
||||
"replace": "very demure"
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Logging
|
||||
|
||||
MS can log the output of hook transformations if/when they occur. In the `playTransform` object of a Source/Client config use `log`:
|
||||
|
||||
* `"log": true` => Output original play + final transformed output of last hook in the array
|
||||
* `"log": "all"` => Output original play + final transformed output of **each** hook in the array
|
||||
|
||||
```json5
|
||||
{
|
||||
"name": "myThing",
|
||||
"data": {/*...*/},
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"preCompare": {/*...*/},
|
||||
"log": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Remove phrase from Title in all new Plays
|
||||
|
||||
Removes the phrase `(Album Version)` from the Title of a Play
|
||||
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
```json5 title="config.json"
|
||||
{
|
||||
"sourceDefaults": {
|
||||
"playTransform": {
|
||||
"preCompare": {
|
||||
"title": [
|
||||
"(Album Version)"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
</details>
|
||||
|
||||
### Remove all parenthesized content from the end of a title
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
```json5 title="lastfm.json"
|
||||
[
|
||||
{
|
||||
"name": "myLastFm",
|
||||
// ...
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"compare": {
|
||||
"candidate": {
|
||||
"title": [
|
||||
"/(\(.+\))\s*$/"
|
||||
]
|
||||
},
|
||||
"existing": {
|
||||
"title": [
|
||||
"/(\(.+\))\s*$/"
|
||||
]
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
</details>
|
||||
|
||||
### Rename misspelled artist in all new Plays
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
```json5 title="config.json"
|
||||
{
|
||||
"sourceDefaults": {
|
||||
"playTransform": {
|
||||
"preCompare": {
|
||||
"artists": [
|
||||
{
|
||||
"search": "Boz Skaggs",
|
||||
"replace": "Boz Scaggs"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
### Remove "Various Artists" albums in all new Plays
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
```json5 title="config.json"
|
||||
{
|
||||
"sourceDefaults": {
|
||||
"playTransform": {
|
||||
"preCompare": {
|
||||
"album": [
|
||||
{
|
||||
"search": "Various Artists",
|
||||
"replace": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</details>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"label": "Development",
|
||||
"position": 4,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Developing for Multi-Scrobbler and tutorials"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
toc_min_heading_level: 2
|
||||
toc_max_heading_level: 5
|
||||
sidebar_position: 3
|
||||
title: Client Development/Tutorial
|
||||
---
|
||||
|
||||
To do...
|
||||
@@ -0,0 +1,180 @@
|
||||
---
|
||||
toc_min_heading_level: 2
|
||||
toc_max_heading_level: 5
|
||||
sidebar_position: 1
|
||||
title: Common Development
|
||||
description: Start here for MS development
|
||||
---
|
||||
|
||||
# Development
|
||||
|
||||
## Architecture
|
||||
|
||||
Multi-scrobbler is written entirely in [Typescript](https://www.typescriptlang.org/). It consists of a backend and frontend. The backend handles all Source/Client logic, mounts web server endpoints that listen for Auth callbacks and Source ingress using [expressjs](https://expressjs.com/), and serves the frontend. The frontend is a standalone [Vitejs](https://vitejs.dev/) app that communicates via API to the backend in order to render the dashboard.
|
||||
|
||||
## Project Setup
|
||||
|
||||
Development requires [Node v18.19.1](https://nodejs.org/en) or higher is installed on your system.
|
||||
|
||||
:::tip
|
||||
|
||||
When running locally (not with a devcontainer) you can use [nvm](https://github.com/nvm-sh/nvm) to manage the installed node version.
|
||||
|
||||
:::
|
||||
|
||||
Clone this repository somewhere and then install from the working directory
|
||||
|
||||
```shell
|
||||
git clone https://github.com/FoxxMD/multi-scrobbler.git .
|
||||
cd multi-scrobbler
|
||||
nvm use # optional, sets correct node version when running without devcontainer
|
||||
npm install
|
||||
npm run start
|
||||
```
|
||||
|
||||
### VSCode
|
||||
|
||||
This repository contains [workspace settings](https://github.com/FoxxMD/multi-scrobbler/blob/master/.devcontainer) for development with VSCode. These include:
|
||||
|
||||
* Run/Debug [Launch configurations](https://code.visualstudio.com/Docs/editor/debugging#_launch-configurations) for the application and tests
|
||||
* [Devcontainer](https://code.visualstudio.com/docs/devcontainers/containers) for development with all dependencies already installed
|
||||
* Useful extensions for linting and running tests
|
||||
|
||||
To use the Devcontainer simple open the repository in VSCode and "Use Devcontainer" when the notification is presented. `npm install` will be run when a new container is created.
|
||||
|
||||
## Common Development
|
||||
|
||||
:::info
|
||||
|
||||
In this document, when referring to aspects of Sources and Clients that are shared between both, the Source/Client will be referred to as a **Component.**
|
||||
|
||||
:::
|
||||
|
||||
A Component is composed of two parts:
|
||||
|
||||
* Typescript interfaces describing structure of configuration for that Component
|
||||
* A concrete class inheriting from a common "startup" abstract class that enforces how the Component is built and operates
|
||||
|
||||
In both parts Source/Clients share some common properties/behavior before diverging in how they operate.
|
||||
|
||||
### Config
|
||||
|
||||
The configuration for a Component should always have this minimum shape, enforced respectively by the interfaces [CommonSourceConfig](https://github.com/FoxxMD/multi-scrobbler/blob/master/src/backend/common/infrastructure/config/source/index.ts#L105) and [CommonClientConfig](https://github.com/FoxxMD/multi-scrobbler/blob/ce1c70a4e1e87fb5bea7cca960eaafbd15881a1f/src/backend/common/infrastructure/config/client/index.ts#L68):
|
||||
|
||||
```ts
|
||||
interface MyConfig {
|
||||
name: string
|
||||
data?: object
|
||||
options?: object
|
||||
}
|
||||
```
|
||||
|
||||
* `data` contains data that is required for a Component to operate such as credentials, callback urls, api keys, endpoints, etc...
|
||||
* `options` are **optional** settings that can be used to fine-tune the usage of the Component but are not required or do not majorly affect behavior. EX additional logging toggles
|
||||
|
||||
### Concrete Class
|
||||
|
||||
Components inherit from an abstract base class, [`AbstractComponent`](https://github.com/FoxxMD/multi-scrobbler/blob/master/src/backend/common/AbstractComponent.ts), that defines different "stages" of how a Component is built and initialized when MS first starts as well as when restarting the Component in the event it stops due to an error/network failure/etc...
|
||||
|
||||
#### Stages
|
||||
|
||||
Stages below are invoked in the order listed. All stages are asynchronous to allow fetching network requests or reading files.
|
||||
|
||||
The stage function (described in each stage below) should return a value or throw:
|
||||
|
||||
* return `null` if the stage is not required
|
||||
* return `true` if the stage succeeded
|
||||
* return a `string` if the stage succeeded and you wish to append a result to the log output for this stage
|
||||
* throw an `Exception` if the stage failed for any reason and the Component should not continue to run/start up
|
||||
|
||||
##### Stage: Build Data
|
||||
|
||||
This stage should be used to validate user configuration, parse any additional data from async sources (file, network), and finalize the shape of any configuration/data needed for the Component to operate.
|
||||
|
||||
:::info
|
||||
|
||||
Implement [`doBuildInitData`](https://github.com/FoxxMD/multi-scrobbler/blob/master/src/backend/common/AbstractComponent.ts#L71) in your child class to invoke this stage.
|
||||
|
||||
::::
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Examples</summary>
|
||||
|
||||
* Parse a full URL like `http://SOME_IP:7000/subfolder/api` from user config containing a base url like `data.baseUrl: 'SOME_IP'` and then store this in the class config
|
||||
* Validate that config `data` contains required properties `user` `password` `salt`
|
||||
* Read stored credentials from `${this.configDir}/currentCreds-MySource-${name}.json`;
|
||||
|
||||
</details>
|
||||
|
||||
##### Stage: Check Connection
|
||||
|
||||
This stage is used to validate that MS can communicate with the service the Component is interacting with. This stage is invoked on MS startup as well as any time the Component tries to restart after a failure.
|
||||
|
||||
If the Component depends on **ingress** (like Jellyfin/Plex webhook) this stage is not necessary.
|
||||
|
||||
:::info
|
||||
|
||||
Implement [`doCheckConnection`](https://github.com/FoxxMD/multi-scrobbler/blob/master/src/backend/common/AbstractComponent.ts#L103) in your child class to invoke this stage.
|
||||
|
||||
::::
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Examples</summary>
|
||||
|
||||
* Make a [`request`](https://nodejs.org/docs/latest-v18.x/api/http.html#httprequesturl-options-callback) to the service's server to ensure it is accessible
|
||||
* Open a websocket connection and check for a ping-pong
|
||||
|
||||
</details>
|
||||
|
||||
##### Stage: Test Auth
|
||||
|
||||
MS determines if Auth is required for a Component based on two class properties. You should set these properties during `constructor` initialization for your Component class:
|
||||
|
||||
* `requiresAuth` - (default `false`) Set to `true` if MS should check/test Auth for this Component
|
||||
* `requiresAuthInteraction` - (default `false`) Set to `true` if user interaction is required to complete auth IE user needs to visit a callback URL
|
||||
|
||||
If the Component requires authentication in order to communicate with a service then any required data should be built in this stage and a request made to the service to ensure the authentication data is valid.
|
||||
|
||||
This stage should return:
|
||||
|
||||
* `true` if auth succeeded
|
||||
* `false` if auth failed without unexpected errors
|
||||
* IE the authentication data is not valid and requires user interaction to resolve the failure
|
||||
* throw an exception if network failure or unexpected error occurred
|
||||
|
||||
You _should_ attempt to re-authenticate, if possible. Only throw an exception or return `false` if there is no way to recover from an authentication failure.
|
||||
|
||||
:::info
|
||||
|
||||
Implement [`doAuthentication`](https://github.com/FoxxMD/multi-scrobbler/blob/master/src/backend/common/AbstractComponent.ts#L111) in your child class to invoke this stage.
|
||||
|
||||
::::
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Examples</summary>
|
||||
|
||||
* Generate a Bearer Token for Basic Auth from user/password given in config and store in class properties
|
||||
* Make a request to a known endpoint with Authorization token from read credentials file to see if succeeds or returns 403
|
||||
* Catch a 403 and attempt to reauthenticate at an auth endpoint with user/password given in config
|
||||
|
||||
</details>
|
||||
|
||||
### Play Object
|
||||
|
||||
The **PlayObject** is the standard data structure MS uses to store listen (track) information and data required for scrobbling. It consists of:
|
||||
|
||||
* Track Data -- a standard format for storing track, artists, album, track duration, the date the track was played at, etc...
|
||||
* Listen Metadata -- Optional but useful data related to the specific play or specifics about the Source/Client context for this play such as
|
||||
* Platform specific ID, web URL to track, device/user ID that played this track, etc...
|
||||
|
||||
Both Sources and Clients use the **PlayObject** interface. When a Component receives track info from its corresponding service it must transform this data into a PlayObject before it can be interacted with.
|
||||
|
||||
For more refer to the TS documentation for `PlayObject` or [`AmbPlayObject`](https://github.com/FoxxMD/multi-scrobbler/blob/master/src/core/Atomic.ts#L141) in your project
|
||||
|
||||
## Creating Clients and Sources
|
||||
|
||||
* [Source Development and Tutorial](dev-source.md)
|
||||
* [Client Development and Tutorial](dev-client.md)
|
||||
@@ -0,0 +1,552 @@
|
||||
---
|
||||
toc_min_heading_level: 2
|
||||
toc_max_heading_level: 5
|
||||
sidebar_position: 2
|
||||
title: Source Development/Tutorial
|
||||
---
|
||||
|
||||
This document will provide a step-by-step guide for creating a (trivial) new Source in MS alongside describing what aspects of the Source need to be implemented based on the service you use. Before using this document you should review [Common Development](dev-common.md#common-development).
|
||||
|
||||
## Scenario
|
||||
|
||||
You are the developer of a fancy, new self-hosted web-based media player called **Cool Player.** Cool Player has a slick interface and many bells and whistles, but most importantly it has an API. The API:
|
||||
|
||||
* Has an unauthenticated health endpoint at `/api/health` that returns `200` if the service is running properly
|
||||
* Has authenticated endpoints that require a user-generated token in the header `Authorization MY_TOKEN`
|
||||
* Has a `/api/recent` endpoint that lists recently played tracks with a timestamp
|
||||
* Has a `/api/now-playing` endpoint that returns information about the state of the player like current track, player position in the track, etc...
|
||||
* Cool Player is by default accessed on port `6969`
|
||||
* Your personal instance of Cool Player is hosted at `http://192.168.0.100:6969` and the api is accessed at `http://192.168.0.100:6969/api`
|
||||
|
||||
Because there is an API that MS can actively read this will be a **polling** Source where MS sends requests to Cool Player to get scrobble information -- as opposed to an **ingress** Source like Jellyfin/Plex that uses webhooks from the service to send data to MS.
|
||||
|
||||
## Minimal Implementation
|
||||
|
||||
### Define and Implement Config
|
||||
|
||||
We will create a new config interface for Cool Player using the [Common Config](dev-common.md#config) and tell MS it is a valid config that can be used.
|
||||
|
||||
Create a new file for your config:
|
||||
|
||||
```ts title="/src/backend/common/infrastructure/config/source/coolplayer.ts"
|
||||
import { PollingOptions } from "../common.js";
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
|
||||
// all of the required data for the Build Data and Test Auth stages (from Common Development docs)
|
||||
// should go here
|
||||
export interface CoolPlayerSourceData extends CommonSourceData, PollingOptions {
|
||||
// remember to annotation your properties!
|
||||
|
||||
/**
|
||||
* The user-generated token for Cool Player auth created in Cool Player -> Settings -> User -> Tokens
|
||||
*
|
||||
* @example f243331e-cf5b-49d7-846b-0845bdc965b4
|
||||
* */
|
||||
token: string
|
||||
/**
|
||||
* The host and port where Cool Player is hosted
|
||||
*
|
||||
* @example http://192.168.0.100:6969
|
||||
* */
|
||||
baseUrl: string
|
||||
}
|
||||
|
||||
export interface CoolPlayerSourceConfig extends CommonSourceConfig {
|
||||
data: CoolPlayerSourceData
|
||||
}
|
||||
|
||||
export interface CoolPlayerSourceAIOConfig extends CoolPlayerSourceConfig {
|
||||
// when using the all-in-one 'config.json' this is how users will identify this source
|
||||
type: 'coolplayer'
|
||||
}
|
||||
```
|
||||
|
||||
Add the new interface to the list of valid interfaces for sources:
|
||||
|
||||
```ts title="src/backend/common/infrastructure/config/source/sources.ts"
|
||||
import { ChromecastSourceAIOConfig, ChromecastSourceConfig } from "./chromecast.js";
|
||||
// ...
|
||||
// highlight-next-line
|
||||
import { CoolPlayerSourceAIOConfig, CoolPlayerSourceConfig } from "./coolplayer.js";
|
||||
|
||||
export type SourceConfig =
|
||||
SpotifySourceConfig
|
||||
// ...
|
||||
// highlight-next-line
|
||||
| CoolPlayerSourceConfig;
|
||||
|
||||
export type SourceAIOConfig =
|
||||
SpotifySourceAIOConfig
|
||||
// ...
|
||||
// highlight-next-line
|
||||
| CoolPlayerSourceAIOConfig;
|
||||
```
|
||||
|
||||
Finally, add the source type identifier to the list of valid identifiers
|
||||
|
||||
```ts title="src/backend/common/infrastructure/Atomic.ts"
|
||||
export type SourceType =
|
||||
'spotify'
|
||||
// ...
|
||||
// highlight-next-line
|
||||
| 'coolplayer';
|
||||
|
||||
export const sourceTypes: SourceType[] = [
|
||||
'spotify',
|
||||
// ...
|
||||
// highlight-next-line
|
||||
'coolplayer'
|
||||
];
|
||||
```
|
||||
|
||||
Now we will create a new Source inheriting from [`AbstractComponent`](dev-common.md#concrete-class) that:
|
||||
|
||||
* accepts our config interface
|
||||
* implements a function to transform CoolPlayer's track data into a [**PlayObject**](dev-common.md#play-object)
|
||||
* implements required [stages](dev-common.md#stages)
|
||||
* implements required methods to current player state and/or now playing track
|
||||
|
||||
### Create CoolPlayer Source
|
||||
|
||||
First we create a new Source called `CoolPlayerSource` and setup our constructor to accept the config and [specify Auth behavior.](dev-common.md#stage-test-auth)
|
||||
|
||||
```ts title="src/backend/sources/SpotifySource.ts"
|
||||
import { CoolPlayerSourceConfig } from "../common/infrastructure/config/source/coolplayer.js";
|
||||
import MemorySource from "./MemorySource.js";
|
||||
import {
|
||||
InternalConfig,
|
||||
} from "../common/infrastructure/Atomic.js";
|
||||
|
||||
// MemorySource is the base class used for polling-type Sources
|
||||
export default class CoolPlayerSource extends MemorySource {
|
||||
|
||||
// type hints for TS to know what the base class config looks like
|
||||
declare config: CoolPlayerSourceConfig;
|
||||
|
||||
constructor(name: any, config: CoolPlayerSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
|
||||
super('coolplayer', name, config, internal, emitter);
|
||||
|
||||
// Cool Player required authentication
|
||||
this.requiresAuth = true;
|
||||
// but does not require user interaction for auth to work
|
||||
this.requiresAuthInteraction = false;
|
||||
// tells MS this is a Source that can be activity monitored through API
|
||||
this.canPoll = true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Initialize Source from Config
|
||||
|
||||
When MS starts it reads all configs and determines which Source to build based on the configs found. We need to tell it to build a `CoolPlayerSource` when a `coolplayer` config type is found.
|
||||
|
||||
We modify `ScrobbleSources.ts` to add `CoolPlayerSource` as an option:
|
||||
|
||||
```ts title="src/backend/sources/ScrobbleSources.ts"
|
||||
// ...
|
||||
import { CoolPlayerSource, CoolPlayerData } from "./CoolPlayerSource.js";
|
||||
|
||||
export default class ScrobbleSources {
|
||||
// ...
|
||||
buildSourcesFromConfig = async (additionalConfigs: ParsedConfig[] = []) => {
|
||||
// ...
|
||||
|
||||
// if CoolPlayerSource should be able to be built from ENVs only
|
||||
// then add it as a case statement here
|
||||
for (const sourceType of sourceTypes) {
|
||||
switch (sourceType) {
|
||||
// ...
|
||||
case 'musikcube':
|
||||
// ...
|
||||
break;
|
||||
// highlight-start
|
||||
case 'coolplayer':
|
||||
const cp = {
|
||||
baseUrl: process.env.COOL_URL,
|
||||
token: process.env.COOL_TOKEN
|
||||
}
|
||||
if (!Object.values(cp).every(x => x === undefined)) {
|
||||
configs.push({
|
||||
type: 'coolplayer',
|
||||
name: 'unnamed',
|
||||
source: 'ENV',
|
||||
mode: 'single',
|
||||
configureAs: defaultConfigureAs,
|
||||
data: cp as CoolPlayerData
|
||||
});
|
||||
}
|
||||
break;
|
||||
// highlight-end
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
|
||||
// (required) create new CoolPlayerSource if source config type is 'coolplayer'
|
||||
addSource = async (clientConfig: ParsedConfig, defaults: SourceDefaults = {}) => {
|
||||
// ...
|
||||
let newSource: AbstractSource;
|
||||
switch (type) {
|
||||
// ...
|
||||
case 'musikcube':
|
||||
// ...
|
||||
break;
|
||||
// highlight-start
|
||||
case 'coolplayer':
|
||||
newSource = await new CoolPlayerSource(name, compositeConfig as CoolPlayerSourceConfig, internal, this.emitter);
|
||||
break;
|
||||
// highlight-end
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Implement Play Object Transform
|
||||
|
||||
Now we will create a static function that is used to take the track data returned from Cool Player's API and return a standard [`PlayObject`.](dev-common.md#play-object)
|
||||
|
||||
```ts title="src/backend/sources/CoolPlayerSource.ts"
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
FormatPlayObjectOptions,
|
||||
} from "../common/infrastructure/Atomic.js";
|
||||
import { PlayObject } from "../../core/Atomic.js";
|
||||
|
||||
export default class CoolPlayerSource extends MemorySource {
|
||||
// ...
|
||||
|
||||
// 'obj' should ideally be a real TS interface
|
||||
// if CoolPlayer has a ts/js client we would use that otherwise
|
||||
// we should build our own interfaces to represent track data from Cool Player's API
|
||||
static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}): PlayObject {
|
||||
const {
|
||||
trackName,
|
||||
artistName,
|
||||
albumName,
|
||||
duration,
|
||||
playedAt,
|
||||
} = obj;
|
||||
|
||||
return {
|
||||
data: {
|
||||
artists: [artistName],
|
||||
album: albumName,
|
||||
track: trackName,
|
||||
// assuming seconds
|
||||
duration,
|
||||
// assuming playedAt is an ISO8601 timestamp
|
||||
playDate: dayjs(playedAt)
|
||||
},
|
||||
meta: {
|
||||
source: 'CoolPlayer'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Implement Stages
|
||||
|
||||
Next we will implement the [Stages](dev-common.md#stages) required to get CoolPlayerSource running.
|
||||
|
||||
#### Build Data
|
||||
|
||||
First we implement the [Build Data Stage](dev-common.md#stage-build-data). We will check that the `token` and `baseUrl` properties have been provided by the user. Additionally, we will parse the baseUrl and add default ports/prefix.
|
||||
|
||||
```ts title="src/backend/sources/CoolPlayerSource.ts"
|
||||
import { URL } from "url";
|
||||
// ...
|
||||
|
||||
export default class CoolPlayerSource extends MemorySource {
|
||||
|
||||
baseUrl!: URL;
|
||||
|
||||
// ...
|
||||
|
||||
static parseConnectionUrl(val: string) {
|
||||
const normal = normalizeUrl(val, {removeTrailingSlash: false, normalizeProtocol: true})
|
||||
const url = new URL(normal);
|
||||
|
||||
if (url.port === null || url.port === '') {
|
||||
url.port = '6969';
|
||||
}
|
||||
if (url.pathname === '/') {
|
||||
url.pathname = '/api/';
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
protected async doBuildInitData(): Promise<true | string | undefined> {
|
||||
const {
|
||||
token,
|
||||
baseUrl
|
||||
} = this.config;
|
||||
if (token === null || token === undefined || (typeof token === 'string' && token.trim() === '')) {
|
||||
throw new Error(`'token' must be defined`);
|
||||
}
|
||||
|
||||
if (baseUrl === null || baseUrl === undefined || (typeof baseUrl === 'string' && baseUrl.trim() === '')) {
|
||||
throw new Error(`'baseUrl' must be defined`);
|
||||
}
|
||||
try {
|
||||
this.baseUrl = CoolPlayerSource.parseConnectionUrl(baseUrl);
|
||||
} catch (e) {
|
||||
throw new Error(`Could not parse baseUrl: ${baseUrl}`, {cause: e});
|
||||
}
|
||||
|
||||
this.logger.verbose(`Config URL: ${baseUrl} => Normalized: '${this.url.toString()}'`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Check Connection
|
||||
|
||||
Second we will implement the [Check Connection Stage](dev-common.md#stage-check-connection):
|
||||
|
||||
```ts title="src/backend/sources/CoolPlayerSource.ts"
|
||||
import request from 'superagent';
|
||||
import { UpstreamError } from "../common/errors/UpstreamError.js";
|
||||
// ...
|
||||
export default class CoolPlayerSource extends MemorySource {
|
||||
|
||||
// ...
|
||||
|
||||
protected async doCheckConnection(): Promise<true | string | undefined> {
|
||||
try {
|
||||
const resp = await request.get(`${this.baseUrl}/health`);
|
||||
return true;
|
||||
// if /health returned version info we could instead read response and return a string with version info for visibility to the user
|
||||
// return `Cool Player Version: ${resp.body.version}`;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Test Auth
|
||||
|
||||
Finally, we will implement [Auth Test Stage](dev-common.md#stage-test-auth):
|
||||
|
||||
```ts title="src/backend/sources/CoolPlayerSource.ts"
|
||||
import request from 'superagent';
|
||||
import { UpstreamError } from "../common/errors/UpstreamError.js";
|
||||
// ...
|
||||
export default class CoolPlayerSource extends MemorySource {
|
||||
|
||||
// ...
|
||||
|
||||
doAuthentication = async () => {
|
||||
try {
|
||||
const resp = await request
|
||||
.get(`${this.baseUrl}/recent`)
|
||||
.set('Authorization', `Token ${this.config.token}`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
// if Cool Player returned an error as json we could parse it from error body and throw new Error with the message
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Implement Polling
|
||||
|
||||
The majority of Sources MS monitors primarily operate as a source of truth for a **music player** rather than a **played music history.** Only Listenbrainz and Last.fm operate as a source of music history.
|
||||
|
||||
To this end, MS implements a [state machine](https://www.freecodecamp.org/news/state-machines-basics-of-computer-science-d42855debc66/) that emulates the behavior of a music player in order to keep track of when a song you are listening to should be scrobbled. It does this by monitoring the "currently playing" track reported by a Source's service, with varying degrees of accuracy depending on what information is returned from the service. The state machine is implemented in `MemorySource` which our `CoolPlayerSource` inherits from.
|
||||
|
||||
For a polling Source to work properly we need to implement a function, [`getRecentlyPlayed`](https://github.com/FoxxMD/multi-scrobbler/blob/master/src/backend/sources/AbstractSource.ts#L92), that returns PlayObjects that are "newly" played. These are then checked against previously "discovered" plays and their timestamp to determine if they should be surfaced to Clients to scrobble.
|
||||
|
||||
To take advantage of the `MemorySource` state machine we will additionally use [`processRecentPlays`](https://github.com/FoxxMD/multi-scrobbler/blob/master/src/backend/sources/MemorySource.ts#L113) from `MemorySource` inside `getRecentlyPlayed`. We pass track and/or player state returned from the Source service to `processRecentPlayers`. It then takes care of deriving Source player state based on how this data changes over time. The advantage to using `processRecentPlays` is that our Source service does not necessarily need to pass any player information -- as long as the track info has a **duration** we can more-or-less determine if it has been played long enough to scrobble.
|
||||
|
||||
```ts title="src/backend/sources/CoolPlayerSource.ts"
|
||||
import request from 'superagent';
|
||||
import {
|
||||
SourceData,
|
||||
PlayerStateData,
|
||||
SINGLE_USER_PLATFORM_ID
|
||||
} from "../common/infrastructure/Atomic.js";
|
||||
// ...
|
||||
export default class CoolPlayerSource extends MemorySource {
|
||||
|
||||
// ...
|
||||
|
||||
protected async getRecentlyPlayed(options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> {
|
||||
const plays: SourceData[] = [];
|
||||
try {
|
||||
// currently playing tracks/player state data
|
||||
const resp = await request
|
||||
.get(`${this.baseUrl}/now-playing`)
|
||||
.set('Authorization', `Token ${this.config.token}`);
|
||||
const {
|
||||
body: {
|
||||
playerState, // 'playing' or 'stopped' or 'paused'...
|
||||
position, // number of seconds into the track IE at position 48 -> ( 0:48/3:56 in player UI )
|
||||
play: { /* track data */}
|
||||
}
|
||||
} = resp;
|
||||
|
||||
// transform into standard player state data
|
||||
const playerData: PlayerStateData = {
|
||||
platformId: SINGLE_USER_PLATFORM_ID,
|
||||
play: CoolPlayerSource.formatPlayObj(play),
|
||||
position
|
||||
};
|
||||
|
||||
// if Cool Player does return player state we can also push a regular PlayObject to this list
|
||||
plays.push(playerData);
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
// process player state through state machine
|
||||
// if the track changes or player state changes
|
||||
// and currently played track has been listened to long enough to be scrobbled it will return in newPlays
|
||||
const newPlays = this.processRecentPlays(plays);
|
||||
|
||||
// finally, we return new plays and MS checks to see if they have been previously seen
|
||||
// before signalling to Clients that they can be scrobbled
|
||||
return newPlays;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Congratulations! Your `CoolPlayerSource` has been minimally implemented and can now be used in multi-scrobbler.
|
||||
|
||||
## Further Implementation
|
||||
|
||||
### Backlog
|
||||
|
||||
To have your Source try to scrobble "missed" tracks when MS starts up the Source's service must be able to provide:
|
||||
|
||||
* track information
|
||||
* timestamp of when the track was played
|
||||
|
||||
In your Source implement [`getBackloggedPlays`](https://github.com/FoxxMD/multi-scrobbler/blob/master/src/backend/sources/AbstractSource.ts#L235) and set setting in constructor indicating it has backlogging capabilities:
|
||||
|
||||
```ts title="src/backend/sources/CoolPlayerSource.ts"
|
||||
import request from 'superagent';
|
||||
import {
|
||||
PlayObject,
|
||||
} from "../common/infrastructure/Atomic.js";
|
||||
// ...
|
||||
export default class CoolPlayerSource extends MemorySource {
|
||||
|
||||
constructor(/* ... */) {
|
||||
super(/* ... */);
|
||||
// ...
|
||||
|
||||
// tell MS it should try to get backlogged tracks on startup
|
||||
this.canBacklog = true;
|
||||
}
|
||||
|
||||
// ...
|
||||
|
||||
protected getBackloggedPlays = async (options: RecentlyPlayedOptions): Promise<PlayObject[]> => {
|
||||
try {
|
||||
const resp = await request
|
||||
.get(`${this.baseUrl}/recent`)
|
||||
.set('Authorization', `Token ${this.config.token}`);
|
||||
|
||||
// assuming list from body looks like track info returned in
|
||||
// "Implement Play Object Transform" section
|
||||
const {
|
||||
body = []
|
||||
} = resp;
|
||||
|
||||
return body.map(x => CoolPlayerSource.formatPlayObj(x));
|
||||
} catch (e) {
|
||||
throw new Error('Error occurred while getting recently played', {cause: e});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Other Source Types
|
||||
|
||||
There are some scenarios where polling and/or state machine is not the right tool to handle determining if incoming data should be scrobbled:
|
||||
|
||||
* The Source service handles scrobble threshold internally, the data being received should always be scrobbled (WebScrobbler, Plex, Tautulli, Listenbrainz, Last.fm)
|
||||
* You prefer to handle the scrobble determination yourself
|
||||
|
||||
#### Music History Source
|
||||
|
||||
If the Source is still polling but the track returned should always be scrobbled if not already seen IE the Source service is a **music history source** (Listenbrainz, Last.fm), rather than a music player, then simply indicate to MS the source of truth type by setting it in the constructor. The state machine will always return a track if it is new and not seen, regardless of how recently it was seen:
|
||||
|
||||
|
||||
```ts title="src/backend/sources/CoolPlayerSource.ts"
|
||||
import { SOURCE_SOT } from "../../core/Atomic.js";
|
||||
// ...
|
||||
export default class CoolPlayerSource extends MemorySource {
|
||||
|
||||
constructor(/* ... */) {
|
||||
super(/* ... */);
|
||||
// ...
|
||||
|
||||
// tell MS it should immediately scrobble any new, unseen tracks from the upstream service
|
||||
this.playerSourceOfTruth = SOURCE_SOT.HISTORY;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Non-Polling Source
|
||||
|
||||
**Ingress** Sources (like Plex, Tautulli, Webscrobbler, Jellyfin) do not having a polling mechanism because the upstream service contacts MS when there is an event, rather than MS contacting the upstream service.
|
||||
|
||||
For these Sources you will need to implement endpoints in `src/service/api.ts` and corresponding files. See the existing Sources in the project as references for how to do this.
|
||||
|
||||
You may still wish to use the state machine `MemorySource` (like Jellyfin) if the events received are not "scrobble" events but instead of implementing `getRecentlyPlayed` you will implement your own function in your Source class, like `handle()`, that receives data and then uses `processRecentPlays`.
|
||||
|
||||
After new plays have been determined see the next section for how to scrobble...
|
||||
|
||||
#### Basic Source
|
||||
|
||||
At the core of a Source that implements `AbstractSource`'s functionality is the ability to **Discover** and **Scrobble** plays.
|
||||
|
||||
These functions are not seen in the MVP `CoolPlayerSource` because they are automatically done by the polling functionality after being returned from `getRecentlyPlayed`.
|
||||
|
||||
##### Discovery
|
||||
|
||||
A Source keeps track of all the "plays" that are determined to be valid for scrobbling. When a play is valid it is checked to see if it has already been "discovered" by comparing the track info and timestamp of the play against already discovered plays. This prevents duplicate scrobbling by using the Source's own data and simplifies scrobbling for Sources by allowing your implementation to "always" ingest track data without having to worry about whether its new or not -- `AbstractSource` and `discover()` will take care of that for you.
|
||||
|
||||
```ts title="src/backend/sources/MyBasicSource.ts"
|
||||
export default class MyBasicSource extends AbstractSource {
|
||||
handle(somePlay: PlayObject) {
|
||||
// if the track is "new" and not seen before it is returned in the discovered list
|
||||
// we then know it is OK to be sent to Clients for scrobbling
|
||||
const discovered: PlayObject[] = this.discover([somePlay]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This additionally will be surfaced to the user in the Dashboard in the "Tracks Discovered" page.
|
||||
|
||||
##### Scrobbling
|
||||
|
||||
After a play is verified to be discovered we can then scrobble it. This will emit the plays to the ScrobbleClients service which then disseminates the play to all Clients that were configured to listen in the Source's config.
|
||||
|
||||
```ts title="src/backend/sources/MyBasicSource.ts"
|
||||
export default class MyBasicSourceSource extends AbstractSource {
|
||||
handle(somePlay: PlayObject) {
|
||||
const discovered: PlayObject[] = this.discover([somePlay]);
|
||||
// emit plays that can be scrobbled by clients
|
||||
this.scrobble(discovered);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If your service only emits an event when a play is scrobbled you can _technically_ skip using `discover()` but it is good practice to use it unless you have a very good reason not to.
|
||||
|
||||
:::note
|
||||
|
||||
Using `scrobble()` does not guarantee a track is actually scrobbled! The Scrobble Clients also check the play against their own "recently scrobbled" list to prevent duplicates.
|
||||
|
||||
:::
|
||||