Compare commits
694 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba922bbfce | ||
|
|
b4a2683e65 | ||
|
|
7e225c0f47 | ||
|
|
4e44d841dd | ||
|
|
b552eb15b3 | ||
|
|
190bcd836e | ||
|
|
488a05bc5a | ||
|
|
2daefb851a | ||
|
|
7414216094 | ||
|
|
300a0292ba | ||
|
|
a4abe6ea2b | ||
|
|
2671933791 | ||
|
|
da6556bb78 | ||
|
|
c33c71ae6d | ||
|
|
1ea3d005e8 | ||
|
|
9fb55d4025 | ||
|
|
3e0e11c01e | ||
|
|
fa479f0a9a | ||
|
|
8b6e32588c | ||
|
|
b62c270b7f | ||
|
|
5488a829c7 | ||
|
|
9d87eefd6a | ||
|
|
03afb4ff0e | ||
|
|
63b9353452 | ||
|
|
72d6df15c6 | ||
|
|
18fda0d954 | ||
|
|
34516ccf97 | ||
|
|
720e2357b7 | ||
|
|
1ec105a245 | ||
|
|
0049d8d311 | ||
|
|
2d528bbc87 | ||
|
|
5a259ef3ff | ||
|
|
43f2d82956 | ||
|
|
dff18101bb | ||
|
|
6cf15748c4 | ||
|
|
54a3394559 | ||
|
|
8436e18175 | ||
|
|
a140c222c2 | ||
|
|
64ceb5371b | ||
|
|
7eb99d0b8d | ||
|
|
07f815edfd | ||
|
|
781155ff39 | ||
|
|
0d6717ce69 | ||
|
|
e0198f741f | ||
|
|
cfc9162729 | ||
|
|
48847ae479 | ||
|
|
fbe6ecea95 | ||
|
|
9d098e2302 | ||
|
|
107803e037 | ||
|
|
1c285439ca | ||
|
|
bde8692add | ||
|
|
1d681d92d3 | ||
|
|
157faad028 | ||
|
|
5fdd8b32d7 | ||
|
|
b855fe865e | ||
|
|
501af14186 | ||
|
|
29465d92a7 | ||
|
|
7cc026ac35 | ||
|
|
fefbe0b117 | ||
|
|
e5cbfac483 | ||
|
|
e1cb52689e | ||
|
|
84a50d5dce | ||
|
|
6c1fc5f836 | ||
|
|
a76a52e99a | ||
|
|
52a407b84b | ||
|
|
365dff6435 | ||
|
|
877cdf1d5c | ||
|
|
28cdf1e693 | ||
|
|
9d24106066 | ||
|
|
d8a4db36ef | ||
|
|
8799358a04 | ||
|
|
58fd5326f5 | ||
|
|
e9066690fd | ||
|
|
a427d6c940 | ||
|
|
949bcde9f5 | ||
|
|
6ee45a9ccc | ||
|
|
bc609be3c9 | ||
|
|
4b373560c6 | ||
|
|
bb6197360b | ||
|
|
2f4a5fd9ae | ||
|
|
26f838167e | ||
|
|
c7af3b8256 | ||
|
|
7adacbac0d | ||
|
|
77fc4841e4 | ||
|
|
f43999842f | ||
|
|
64b22688ba | ||
|
|
e9dad3dd67 | ||
|
|
847531391d | ||
|
|
a168f46b95 | ||
|
|
22145e070f | ||
|
|
9a3e75be00 | ||
|
|
618d5fc81f | ||
|
|
9668263235 | ||
|
|
9959862791 | ||
|
|
8e02659441 | ||
|
|
905c685696 | ||
|
|
591a5344ac | ||
|
|
e79922def1 | ||
|
|
a3eb14d7f4 | ||
|
|
3b52df5efd | ||
|
|
031756caf6 | ||
|
|
70470e4c81 | ||
|
|
58a52c31c2 | ||
|
|
1f3bc4d202 | ||
|
|
950ba9f77e | ||
|
|
861b406575 | ||
|
|
b47ec02f02 | ||
|
|
7cc9fbaaf9 | ||
|
|
9807b0b6c0 | ||
|
|
1af78e9503 | ||
|
|
02c228da1b | ||
|
|
f7aa5c452c | ||
|
|
6c53ce4bb3 | ||
|
|
f325907da4 | ||
|
|
2c89e0dc13 | ||
|
|
7d23eca721 | ||
|
|
77705e4d79 | ||
|
|
afbd9a3b37 | ||
|
|
41ec44ae1b | ||
|
|
dc8051ed53 | ||
|
|
c5686c4884 | ||
|
|
9520c30c32 | ||
|
|
069199b2d8 | ||
|
|
05ffdede56 | ||
|
|
b12b3c49bd | ||
|
|
0f29da966e | ||
|
|
81d9750fa6 | ||
|
|
0af54e43dc | ||
|
|
0d35148152 | ||
|
|
7c23bd0890 | ||
|
|
10e52bdd3f | ||
|
|
9e84ce42b5 | ||
|
|
15b289201a | ||
|
|
cd1c693a23 | ||
|
|
2073871fa1 | ||
|
|
dab83c4f6a | ||
|
|
db5b9246dd | ||
|
|
cdae4347a6 | ||
|
|
8c063c4f0c | ||
|
|
14b060a42a | ||
|
|
1804fb3e50 | ||
|
|
ea2f94658a | ||
|
|
4b38a13243 | ||
|
|
29817db9f2 | ||
|
|
fc4ddee122 | ||
|
|
a241865209 | ||
|
|
ea09629803 | ||
|
|
f2fd7ed016 | ||
|
|
4f90fa9924 | ||
|
|
f86bc070de | ||
|
|
1d338417e9 | ||
|
|
d685aefab3 | ||
|
|
e27d917bd4 | ||
|
|
8b92796a5c | ||
|
|
17833cd9d2 | ||
|
|
e2969aa34c | ||
|
|
500da8bc7b | ||
|
|
db3b53ff43 | ||
|
|
291d28887b | ||
|
|
3c6b8d18cd | ||
|
|
0111d3ae60 | ||
|
|
0cde8cbf2e | ||
|
|
b08eac54fd | ||
|
|
f7411558e3 | ||
|
|
a74b365feb | ||
|
|
350f1dc951 | ||
|
|
25ae1c6cdd | ||
|
|
0aaa261a71 | ||
|
|
f2a8308925 | ||
|
|
240149cda4 | ||
|
|
ae58ac6a6c | ||
|
|
a8c5fa6d49 | ||
|
|
9414ce6549 | ||
|
|
7bd31da0d5 | ||
|
|
975579ab26 | ||
|
|
7becc18da9 | ||
|
|
4ca98fb827 | ||
|
|
aae66cfcf0 | ||
|
|
2010fcf4ca | ||
|
|
2ffb28fc2d | ||
|
|
0b729e1cf9 | ||
|
|
ab856e3dd1 | ||
|
|
90c407b7f6 | ||
|
|
f7d1b80b69 | ||
|
|
2b95422e88 | ||
|
|
7d075b1882 | ||
|
|
0e9b0d466c | ||
|
|
e5c7819586 | ||
|
|
a42fb024be | ||
|
|
f5808288ab | ||
|
|
3209430ebd | ||
|
|
d9893cf84d | ||
|
|
9064697123 | ||
|
|
b6c578e3a2 | ||
|
|
cc5eaf4caf | ||
|
|
f29bb211d1 | ||
|
|
3ad36ebd2a | ||
|
|
31d6508b1d | ||
|
|
bc72f41180 | ||
|
|
63171368ed | ||
|
|
5137407377 | ||
|
|
92ba658606 | ||
|
|
763a3ef267 | ||
|
|
a89afb5fcf | ||
|
|
69b2fe92f5 | ||
|
|
3996764486 | ||
|
|
a288e7e858 | ||
|
|
14525cd056 | ||
|
|
2397a7e464 | ||
|
|
b8d47d1db4 | ||
|
|
48a6ba2956 | ||
|
|
3e8bee4f65 | ||
|
|
c8c95bfb47 | ||
|
|
666b058ce4 | ||
|
|
d6066c514d | ||
|
|
3c4903bc4e | ||
|
|
af4609727c | ||
|
|
53b2cdd33d | ||
|
|
088af9004a | ||
|
|
1ee39835dd | ||
|
|
972a94dbf0 | ||
|
|
b87f7b6126 | ||
|
|
f09a6423f7 | ||
|
|
49d28d34b4 | ||
|
|
8b09f0369c | ||
|
|
41cbd3aba7 | ||
|
|
a1dcb9a4e3 | ||
|
|
be715c3696 | ||
|
|
fddded3260 | ||
|
|
cf90f0a245 | ||
|
|
8bfaa0ad9d | ||
|
|
15697a6fa2 | ||
|
|
bcb3e1479f | ||
|
|
44d13bd37c | ||
|
|
9629c26537 | ||
|
|
1c7f859b5e | ||
|
|
8b2a550368 | ||
|
|
b0ea517fdd | ||
|
|
08f96639f4 | ||
|
|
b64bb706f7 | ||
|
|
a4ef31251d | ||
|
|
84cd6b7f34 | ||
|
|
df86a8153e | ||
|
|
b38be69b14 | ||
|
|
48e0d2c99e | ||
|
|
3dac9ae666 | ||
|
|
9d7995fd4d | ||
|
|
7efc32d136 | ||
|
|
153cf8f5af | ||
|
|
b3f373cdb4 | ||
|
|
08399c4854 | ||
|
|
25db696c06 | ||
|
|
bdad927f11 | ||
|
|
b1a9dfee13 | ||
|
|
c09ba509b2 | ||
|
|
0e7163eb2c | ||
|
|
5111cf8c33 | ||
|
|
98af68ac99 | ||
|
|
aee4eb71c4 | ||
|
|
99d454d8b0 | ||
|
|
11012302fd | ||
|
|
9d2426a601 | ||
|
|
8a44f61189 | ||
|
|
7afad2c96e | ||
|
|
08e63c867b | ||
|
|
bf69c5589f | ||
|
|
714100e24b | ||
|
|
8f2fe6f9fa | ||
|
|
08dbf44529 | ||
|
|
84080a0e44 | ||
|
|
1b624b2505 | ||
|
|
a2e76d6898 | ||
|
|
56803d0151 | ||
|
|
2b1a5f579a | ||
|
|
3fc81638c7 | ||
|
|
24b040adf9 | ||
|
|
8d608ac5b2 | ||
|
|
02160465a5 | ||
|
|
b5abd80927 | ||
|
|
6542842938 | ||
|
|
8d7931b3bc | ||
|
|
9224a67a7b | ||
|
|
873cea4046 | ||
|
|
0b977df8dd | ||
|
|
fb1461fd0b | ||
|
|
9cbeddae8f | ||
|
|
c9b119f0a4 | ||
|
|
a6bd9f627e | ||
|
|
861c742b3e | ||
|
|
36596d4fdb | ||
|
|
94f28f6216 | ||
|
|
2f56f1b178 | ||
|
|
f4a88b8319 | ||
|
|
f50aeb0b21 | ||
|
|
fd1604b1d2 | ||
|
|
7fbdcf8ddc | ||
|
|
7f7b0c1f0d | ||
|
|
68e0fe574f | ||
|
|
8ddf4d62af | ||
|
|
9bcd606fe8 | ||
|
|
7819e834c8 | ||
|
|
779d4a1c85 | ||
|
|
e07152b695 | ||
|
|
ee5a0698c0 | ||
|
|
71b77cba2b | ||
|
|
8e584ee020 | ||
|
|
3ea5b85b36 | ||
|
|
cfad35544b | ||
|
|
7583ddac65 | ||
|
|
6b89679e08 | ||
|
|
3535fba9dd | ||
|
|
488db26675 | ||
|
|
1f842b08e2 | ||
|
|
aabef62b11 | ||
|
|
6c0778a867 | ||
|
|
58d6b0a84f | ||
|
|
145a5708ca | ||
|
|
6ccdc2e068 | ||
|
|
6da2f1ba92 | ||
|
|
28bcd3f99e | ||
|
|
1076dda011 | ||
|
|
e30704fe0f | ||
|
|
84384da8d1 | ||
|
|
62fe1cdc43 | ||
|
|
4d6c9482ff | ||
|
|
cdd44a2830 | ||
|
|
ba8d2f5da8 | ||
|
|
00ec6cf042 | ||
|
|
2f394623c8 | ||
|
|
f1a24b971a | ||
|
|
d913108de2 | ||
|
|
32bac11b61 | ||
|
|
78630d427d | ||
|
|
1e57852eff | ||
|
|
464e251d19 | ||
|
|
d9f7a154cf | ||
|
|
9b756faef5 | ||
|
|
515528ee6d | ||
|
|
4bd6012f11 | ||
|
|
216491815c | ||
|
|
4777cf0aba | ||
|
|
0f418a93cd | ||
|
|
d0bf37a8a9 | ||
|
|
313a088f86 | ||
|
|
6152fadd92 | ||
|
|
3037ea01e2 | ||
|
|
acba4b16ee | ||
|
|
8dfa929666 | ||
|
|
c1fb32cedb | ||
|
|
b6a6422fac | ||
|
|
21ed7348c6 | ||
|
|
95cc211659 | ||
|
|
bf5318d776 | ||
|
|
81d7556cdf | ||
|
|
1e56f4da76 | ||
|
|
f3bb51f01b | ||
|
|
197d4024f7 | ||
|
|
7eaa42797a | ||
|
|
d39bd0219a | ||
|
|
9f533b2108 | ||
|
|
1cfa7b2272 | ||
|
|
d24709b521 | ||
|
|
af7eaa2b7a | ||
|
|
c0ec0b28b9 | ||
|
|
6d08a9446d | ||
|
|
04fd72e1fa | ||
|
|
fc19199fbe | ||
|
|
4514a54744 | ||
|
|
f9e0de31b8 | ||
|
|
1cd2f015c2 | ||
|
|
ed84c5a0a3 | ||
|
|
b88f9013dc | ||
|
|
62ed30afed | ||
|
|
6dc21d0595 | ||
|
|
79710fbee0 | ||
|
|
c89b89cd92 | ||
|
|
dcea5eb449 | ||
|
|
b5c68c971d | ||
|
|
fe38f99739 | ||
|
|
ff3a89b15a | ||
|
|
078a7c24e6 | ||
|
|
69e1059705 | ||
|
|
075c28d2e5 | ||
|
|
a45b5a037f | ||
|
|
3cf8b8e97d | ||
|
|
b93a3db267 | ||
|
|
53c1e9ec35 | ||
|
|
12cedee867 | ||
|
|
2f11c2dc8f | ||
|
|
049ac70b2b | ||
|
|
b5e20c1934 | ||
|
|
173dd52fe1 | ||
|
|
6663c079e0 | ||
|
|
64ccb4d188 | ||
|
|
a289a1945f | ||
|
|
a257891b46 | ||
|
|
40fd5bab34 | ||
|
|
e9e09a7480 | ||
|
|
29d8950e5b | ||
|
|
00b6f895bb | ||
|
|
07d96f8308 | ||
|
|
07535e1518 | ||
|
|
19ead8f7e8 | ||
|
|
eb74dad7cd | ||
|
|
61d0bd4729 | ||
|
|
def5db9729 | ||
|
|
3d11bdcfd1 | ||
|
|
0ff89679ba | ||
|
|
0c095f6d5d | ||
|
|
2f8dc794de | ||
|
|
68a9be5e86 | ||
|
|
1ffc8d619e | ||
|
|
2de0a40c6f | ||
|
|
5417031d79 | ||
|
|
ae817da223 | ||
|
|
fd6edf967f | ||
|
|
c60e56828b | ||
|
|
edc9344327 | ||
|
|
fea5d23fc7 | ||
|
|
26d2af17a3 | ||
|
|
f373f5f83e | ||
|
|
92b7ef40af | ||
|
|
39cb3455db | ||
|
|
4ac4806bf8 | ||
|
|
a282f62395 | ||
|
|
3aac03d253 | ||
|
|
cd171c40cb | ||
|
|
78c40ab6b4 | ||
|
|
21f7c1906d | ||
|
|
23fe8cdee6 | ||
|
|
af55b93ac8 | ||
|
|
665b1f6898 | ||
|
|
35f748e0fb | ||
|
|
fc7a027d59 | ||
|
|
38c1999fcd | ||
|
|
180f1354fc | ||
|
|
abd51b2156 | ||
|
|
47976e13b1 | ||
|
|
bbd4503ac8 | ||
|
|
b40df6380e | ||
|
|
2d036b5966 | ||
|
|
f859772723 | ||
|
|
1be79fa945 | ||
|
|
1825b29737 | ||
|
|
13f08d3eae | ||
|
|
52d8aaa865 | ||
|
|
8dfc259857 | ||
|
|
deef8e162d | ||
|
|
b18e3289fb | ||
|
|
4d60f72b7e | ||
|
|
e0fa85be28 | ||
|
|
5b167031d2 | ||
|
|
cf8756b14b | ||
|
|
03867bd8b2 | ||
|
|
943f35f7a5 | ||
|
|
ca283f45ea | ||
|
|
bf93b5614c | ||
|
|
377d8f6b87 | ||
|
|
1eb62ee671 | ||
|
|
39c94d3cd9 | ||
|
|
3fa4ef0166 | ||
|
|
9116529b6d | ||
|
|
bd8b573743 | ||
|
|
a65318a00a | ||
|
|
a817701ee8 | ||
|
|
4a4a8aff34 | ||
|
|
80b8b69cee | ||
|
|
ab0e091736 | ||
|
|
e6d1e67297 | ||
|
|
a99924ea20 | ||
|
|
27adb84177 | ||
|
|
7a3bd935c2 | ||
|
|
cff5c1ee53 | ||
|
|
fd32a28788 | ||
|
|
4f25e9ebf4 | ||
|
|
514117a477 | ||
|
|
07e8f41849 | ||
|
|
133626dcd0 | ||
|
|
56a6fb91ab | ||
|
|
6e518d90d5 | ||
|
|
96b94106e6 | ||
|
|
a1c670b40d | ||
|
|
2230a9052f | ||
|
|
9b1be35c14 | ||
|
|
afe5a5b32a | ||
|
|
9edd7e9025 | ||
|
|
2be9a7dbec | ||
|
|
7305e3aa17 | ||
|
|
aa133e6b00 | ||
|
|
1f72399f44 | ||
|
|
3aef62f201 | ||
|
|
e5535f6aff | ||
|
|
76fc5b1425 | ||
|
|
a38f205c0b | ||
|
|
944107cb3d | ||
|
|
94fd0a10b5 | ||
|
|
669f293f1f | ||
|
|
532833ac7c | ||
|
|
59f1d7e88a | ||
|
|
caeff2862a | ||
|
|
841c1129ff | ||
|
|
ba30f7f8be | ||
|
|
6026638c03 | ||
|
|
cbab2e4eec | ||
|
|
a3ecc41e47 | ||
|
|
4d18212f5d | ||
|
|
5dea258058 | ||
|
|
0802ab73d7 | ||
|
|
865b9cd545 | ||
|
|
e70ec53983 | ||
|
|
2d0031f709 | ||
|
|
78ecda5239 | ||
|
|
a1879ff871 | ||
|
|
34eda3c8fc | ||
|
|
506899b083 | ||
|
|
3a4e2523dd | ||
|
|
674b56a53d | ||
|
|
58a0c44600 | ||
|
|
df4328819d | ||
|
|
b6aa6eb7b2 | ||
|
|
1187ee7cc1 | ||
|
|
0beec552b1 | ||
|
|
6a6d4c3f87 | ||
|
|
1216c9bdb8 | ||
|
|
2a888395fa | ||
|
|
56772f5c62 | ||
|
|
07b5469b4c | ||
|
|
58324b411f | ||
|
|
c0e5b445cf | ||
|
|
6820e120cb | ||
|
|
28aefb4858 | ||
|
|
e50a720818 | ||
|
|
900337081b | ||
|
|
34af6fc671 | ||
|
|
a25044bdf6 | ||
|
|
30e98843ed | ||
|
|
8fe335ed97 | ||
|
|
8549451ee7 | ||
|
|
596a4897a3 | ||
|
|
95eea0e9f8 | ||
|
|
61c286a77e | ||
|
|
15d11a9519 | ||
|
|
35625020e2 | ||
|
|
76e522710a | ||
|
|
aae9d89e8c | ||
|
|
0eae6d2a61 | ||
|
|
f6982fd8ae | ||
|
|
b364170d4f | ||
|
|
0aceda9b89 | ||
|
|
9df405a8ce | ||
|
|
366054e8cc | ||
|
|
8fa5544af7 | ||
|
|
073e40dc87 | ||
|
|
a45c08f217 | ||
|
|
6c8535c54a | ||
|
|
e2e79d6471 | ||
|
|
b5567090ed | ||
|
|
b836871161 | ||
|
|
45e708f591 | ||
|
|
608129963f | ||
|
|
f3d8222ddb | ||
|
|
c83808a445 | ||
|
|
c23e5c291c | ||
|
|
bd1c3d9229 | ||
|
|
48c0e1ca4b | ||
|
|
16397e08fc | ||
|
|
15a06fcd27 | ||
|
|
a2e0acd6a2 | ||
|
|
5f38e70a2b | ||
|
|
c19c599521 | ||
|
|
dd398224e7 | ||
|
|
5ac76ae7e0 | ||
|
|
c14147e6c5 | ||
|
|
59ce940cd6 | ||
|
|
cfecd7c6a2 | ||
|
|
d81a4472a0 | ||
|
|
147d26fb75 | ||
|
|
848318932d | ||
|
|
49153dc1c1 | ||
|
|
ca5da5b0ea | ||
|
|
c2e03c8162 | ||
|
|
f2ebbd26fa | ||
|
|
bbc4f9f91f | ||
|
|
6fe1f84c68 | ||
|
|
d72468003f | ||
|
|
100f6a0645 | ||
|
|
bc2073fbd5 | ||
|
|
278d0ea8f3 | ||
|
|
0e16d7cfbb | ||
|
|
419884db7c | ||
|
|
eacfc41665 | ||
|
|
c271aa24d1 | ||
|
|
22f34b3347 | ||
|
|
eba8395146 | ||
|
|
f16dc5f8f8 | ||
|
|
15c8f4c0ef | ||
|
|
e344f616b3 | ||
|
|
ef81caf3ed | ||
|
|
8513f1a899 | ||
|
|
a9a25713e8 | ||
|
|
a5e1986072 | ||
|
|
97c98e3369 | ||
|
|
6effd603e2 | ||
|
|
8a783ef967 | ||
|
|
b74bd30b72 | ||
|
|
9fa09e41cc | ||
|
|
4ef12f91e0 | ||
|
|
0730c667a2 | ||
|
|
4ec451aecb | ||
|
|
883dd7f728 | ||
|
|
38c19eddc3 | ||
|
|
8e4b2e1c06 | ||
|
|
a541afbfba | ||
|
|
df05760769 | ||
|
|
9a1133601a | ||
|
|
2c370cae28 | ||
|
|
f745b8d223 | ||
|
|
f1b6703ab0 | ||
|
|
28d1428c90 | ||
|
|
696a0feb31 | ||
|
|
f29e1eb248 | ||
|
|
d4e599233e | ||
|
|
aaec8e080b | ||
|
|
09442eccd4 | ||
|
|
21b9f51b71 | ||
|
|
ed726c2126 | ||
|
|
23d69d26e0 | ||
|
|
3d0e70e907 | ||
|
|
34e843a4b3 | ||
|
|
924ada0dab | ||
|
|
2d3ed85311 | ||
|
|
3d4f4b4e2b | ||
|
|
338cbacb79 | ||
|
|
0cf574198e | ||
|
|
3000238a3c | ||
|
|
16c38eb344 | ||
|
|
721a959735 | ||
|
|
3c2b14d362 | ||
|
|
2b59d4b87a | ||
|
|
cefdeee495 | ||
|
|
3383327c51 | ||
|
|
38b341ebc5 | ||
|
|
ef0e5b130d | ||
|
|
3092f83a00 | ||
|
|
8daac43e99 | ||
|
|
d5da23ae42 | ||
|
|
eae46d15bf | ||
|
|
f6c518fd8b | ||
|
|
db8a48bba6 | ||
|
|
d877928f11 | ||
|
|
0403ec2a07 | ||
|
|
8d27c77c2c | ||
|
|
f992b5663f | ||
|
|
4e4fcb2304 | ||
|
|
ddb30ceb11 | ||
|
|
67da83c84d | ||
|
|
f8f16d676d | ||
|
|
58b816c2ed | ||
|
|
9b1d5c196f | ||
|
|
a0bed9beeb | ||
|
|
9f4f2f7381 | ||
|
|
433e31acc8 | ||
|
|
b795ad55a3 | ||
|
|
72efc18158 | ||
|
|
93626129b6 | ||
|
|
60178c264d | ||
|
|
de6afa16ec | ||
|
|
fd2df12263 | ||
|
|
37d66a7d41 | ||
|
|
040c7f1e7d | ||
|
|
d4a5508f6a | ||
|
|
036f9d6730 | ||
|
|
1b7f628759 | ||
|
|
5a891fda9e | ||
|
|
f96e2f6c4f | ||
|
|
7a5285ae47 | ||
|
|
ba347bc0b1 | ||
|
|
1bee98af52 | ||
|
|
ff623a8dce | ||
|
|
f28e8118dc | ||
|
|
167fca86d0 | ||
|
|
b828650cc5 | ||
|
|
e6846de0fa | ||
|
|
6c6254a3c3 | ||
|
|
0a9ad4e73a | ||
|
|
9f6eb4174f | ||
|
|
25cc523006 | ||
|
|
4c0000a809 | ||
|
|
0f7193f85d | ||
|
|
715855280e | ||
|
|
c322253fde | ||
|
|
17cea91e10 | ||
|
|
6caa5ee81f | ||
|
|
d46a8cf89f |
20
.devcontainer/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.148.1/containers/go/.devcontainer/base.Dockerfile
|
||||
|
||||
# [Choice] Go version: 1, 1.15, 1.14
|
||||
ARG VARIANT="1"
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/go:0-${VARIANT}
|
||||
|
||||
# [Option] Install Node.js
|
||||
ARG INSTALL_NODE="true"
|
||||
ARG NODE_VERSION="lts/*"
|
||||
RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
|
||||
|
||||
# [Optional] Uncomment this section to install additional OS packages.
|
||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
&& apt-get -y install --no-install-recommends libtag1-dev ffmpeg
|
||||
|
||||
# [Optional] Uncomment the next line to use go get to install anything else you need
|
||||
# RUN go get -x <your-dependency-or-tool>
|
||||
|
||||
# [Optional] Uncomment this line to install global node packages.
|
||||
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
|
||||
59
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "Go",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
"args": {
|
||||
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
|
||||
"VARIANT": "1.16",
|
||||
// Options
|
||||
"INSTALL_NODE": "true",
|
||||
"NODE_VERSION": "v14"
|
||||
}
|
||||
},
|
||||
"runArgs": [
|
||||
"--cap-add=SYS_PTRACE",
|
||||
"--security-opt",
|
||||
"seccomp=unconfined"
|
||||
],
|
||||
// Set *default* container specific settings.json values on container create.
|
||||
"settings": {
|
||||
"terminal.integrated.shell.linux": "/bin/bash",
|
||||
"go.useGoProxyToCheckForToolUpdates": false,
|
||||
"go.useLanguageServer": true,
|
||||
"go.gopath": "/go",
|
||||
"go.goroot": "/usr/local/go",
|
||||
"go.toolsGopath": "/go/bin",
|
||||
"go.formatTool": "goimports",
|
||||
"go.lintOnSave": "package",
|
||||
"go.lintTool": "golangci-lint",
|
||||
"editor.formatOnSave": true,
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
}
|
||||
},
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": [
|
||||
"golang.Go",
|
||||
"esbenp.prettier-vscode",
|
||||
"tamasfe.even-better-toml"
|
||||
],
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
"forwardPorts": [
|
||||
4533,
|
||||
4633
|
||||
],
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// "postCreateCommand": "make setup-dev",
|
||||
// Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
||||
"remoteUser": "vscode",
|
||||
"remoteEnv": {
|
||||
"ND_MUSICFOLDER": "./music",
|
||||
"ND_DATAFOLDER": "./data"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
.DS_Store
|
||||
ui/node_modules
|
||||
Jamstash-master
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
data
|
||||
@@ -9,5 +8,3 @@ testDB
|
||||
navidrome
|
||||
navidrome.db
|
||||
navidrome.toml
|
||||
assets/*gen.go
|
||||
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
# Upgrade Prettier to 2.0.4. Reformatted all JS files
|
||||
b3f70538a9138bc279a451f4f358605097210d41
|
||||
# Move project to Navidrome GitHub organization
|
||||
6ee45a9ccc5e7ea4290c89030e67c99c0514bd25
|
||||
|
||||
2
.github/FUNDING.yml
vendored
@@ -3,8 +3,8 @@
|
||||
github: deluan
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
liberapay: deluan
|
||||
ko_fi: deluan
|
||||
liberapay: deluan
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
|
||||
12
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: "/ui"
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
- package-ecosystem: gomod
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
BIN
.github/screenshots/ss-desktop-player.png
vendored
|
Before Width: | Height: | Size: 364 KiB After Width: | Height: | Size: 3.7 MiB |
BIN
.github/screenshots/ss-mobile-album-view.png
vendored
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 236 KiB |
BIN
.github/screenshots/ss-mobile-artists.png
vendored
|
Before Width: | Height: | Size: 106 KiB |
BIN
.github/screenshots/ss-mobile-login.png
vendored
|
Before Width: | Height: | Size: 709 KiB After Width: | Height: | Size: 736 KiB |
BIN
.github/screenshots/ss-mobile-player.png
vendored
|
Before Width: | Height: | Size: 411 KiB After Width: | Height: | Size: 886 KiB |
BIN
.github/screenshots/ss-mobile-users.png
vendored
|
Before Width: | Height: | Size: 66 KiB |
1
.github/workflows/pipeline.dockerfile
vendored
@@ -27,7 +27,6 @@ COPY --from=copy-binary /navidrome /app/
|
||||
VOLUME ["/data", "/music"]
|
||||
ENV ND_MUSICFOLDER /music
|
||||
ENV ND_DATAFOLDER /data
|
||||
ENV ND_LOGLEVEL info
|
||||
ENV ND_PORT 4533
|
||||
ENV GODEBUG "asyncpreemptoff=1"
|
||||
|
||||
|
||||
91
.github/workflows/pipeline.yml
vendored
@@ -13,56 +13,66 @@ jobs:
|
||||
name: Lint Server
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install taglib
|
||||
run: sudo apt-get install libtag1-dev
|
||||
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v1
|
||||
uses: golangci/golangci-lint-action@v2
|
||||
with:
|
||||
version: v1.27
|
||||
version: v1.38
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
args: --timeout 2m
|
||||
|
||||
go:
|
||||
name: Test Server on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: Test Server with Go ${{ matrix.go_version }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
# TODO Fix tests in Windows
|
||||
# os: [macOS-latest, ubuntu-latest, windows-latest]
|
||||
os: [macOS-latest, ubuntu-latest]
|
||||
|
||||
go_version: [1.16.x]
|
||||
steps:
|
||||
- name: Set up Go 1.14
|
||||
uses: actions/setup-go@v1
|
||||
- name: Install taglib
|
||||
run: sudo apt-get install libtag1-dev
|
||||
|
||||
- name: Set up Go ${{ matrix.go_version }}
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: 1.14
|
||||
stable: '!contains(${{ matrix.go_version }}, "beta") && !contains(${{ matrix.go_version }}, "rc")'
|
||||
go-version: ${{ matrix.go_version }}
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- uses: actions/cache@v1
|
||||
- uses: actions/cache@v2
|
||||
id: cache-go
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
key: ${{ runner.os }}-go-${{ matrix.go_version }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
${{ runner.os }}-go-${{ matrix.go_version }}-
|
||||
|
||||
- name: Download dependencies
|
||||
if: steps.cache-go.outputs.cache-hit != 'true'
|
||||
continue-on-error: ${{contains(matrix.go_version, 'beta') || contains(matrix.go_version, 'rc')}}
|
||||
run: go mod download
|
||||
|
||||
- name: Test
|
||||
continue-on-error: ${{contains(matrix.go_version, 'beta') || contains(matrix.go_version, 'rc')}}
|
||||
run: go test -cover ./... -v
|
||||
js:
|
||||
name: Build JS bundle
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 14
|
||||
|
||||
- uses: actions/cache@v1
|
||||
- uses: actions/cache@v2
|
||||
id: cache-npm
|
||||
with:
|
||||
path: ~/.npm
|
||||
@@ -80,12 +90,17 @@ jobs:
|
||||
cd ui
|
||||
npm run check-formatting
|
||||
|
||||
- name: npm test
|
||||
run: |
|
||||
cd ui
|
||||
npm test
|
||||
|
||||
- name: npm build
|
||||
run: |
|
||||
cd ui
|
||||
npm run build
|
||||
|
||||
- uses: actions/upload-artifact@v1
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: js-bundle
|
||||
path: ui/build
|
||||
@@ -97,18 +112,23 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Unshallow
|
||||
run: git fetch --prune --unshallow
|
||||
|
||||
- uses: actions/download-artifact@v1
|
||||
- uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: js-bundle
|
||||
path: ui/build
|
||||
|
||||
- name: Show Tags
|
||||
run: git tag
|
||||
|
||||
- name: Show Version
|
||||
run: git describe --tags
|
||||
|
||||
- name: Run GoReleaser - SNAPSHOT
|
||||
if: startsWith(github.ref, 'refs/tags/') != true
|
||||
uses: docker://deluan/ci-goreleaser:1.14.4-2
|
||||
uses: docker://deluan/ci-goreleaser:1.16.2-1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
@@ -116,16 +136,19 @@ jobs:
|
||||
|
||||
- name: Run GoReleaser - RELEASE
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: docker://deluan/ci-goreleaser:1.14.4-2
|
||||
uses: docker://deluan/ci-goreleaser:1.16.2-1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
args: goreleaser release --rm-dist
|
||||
|
||||
- uses: actions/upload-artifact@v1
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: binaries
|
||||
path: dist
|
||||
path: |
|
||||
dist
|
||||
!dist/*.tar.gz
|
||||
!dist/*.zip
|
||||
|
||||
docker:
|
||||
name: Docker images
|
||||
@@ -134,18 +157,20 @@ jobs:
|
||||
env:
|
||||
DOCKER_IMAGE: ${{secrets.DOCKER_IMAGE}}
|
||||
steps:
|
||||
- name: Set up QEMU
|
||||
id: qemu
|
||||
uses: docker/setup-qemu-action@v1
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: crazy-max/ghaction-docker-buildx@v1
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
with:
|
||||
buildx-version: latest
|
||||
qemu-version: latest
|
||||
|
||||
- uses: actions/checkout@v1
|
||||
uses: docker/setup-buildx-action@v1
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
|
||||
- uses: actions/download-artifact@v1
|
||||
- uses: actions/checkout@v2
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
|
||||
- uses: actions/download-artifact@v2
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
with:
|
||||
name: binaries
|
||||
@@ -155,7 +180,7 @@ jobs:
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
env:
|
||||
DOCKER_IMAGE: ${{secrets.DOCKER_IMAGE}}
|
||||
DOCKER_PLATFORM: linux/amd64,linux/arm/v7,linux/arm64
|
||||
DOCKER_PLATFORM: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
|
||||
run: |
|
||||
echo ${{secrets.DOCKER_PASSWORD}} | docker login -u ${{secrets.DOCKER_USERNAME}} --password-stdin
|
||||
docker buildx build --platform ${DOCKER_PLATFORM} `.github/workflows/docker-tags.sh` -f .github/workflows/pipeline.dockerfile --push .
|
||||
|
||||
1
.gitignore
vendored
@@ -12,7 +12,6 @@ TODO.md
|
||||
var
|
||||
navidrome.toml
|
||||
master.zip
|
||||
Jamstash-master
|
||||
testDB
|
||||
navidrome.db
|
||||
*.swp
|
||||
|
||||
@@ -26,4 +26,4 @@ issues:
|
||||
exclude-rules:
|
||||
- linters:
|
||||
- gosec
|
||||
text: "(G501|G401):"
|
||||
text: "(G501|G401|G505):"
|
||||
|
||||
@@ -1,26 +1,7 @@
|
||||
# GoReleaser config
|
||||
project_name: navidrome
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go-bindata -fs -prefix resources -tags embed -ignore="\\\*.go" -pkg resources -o resources/embedded_gen.go resources/...
|
||||
- go-bindata -fs -prefix ui/build -tags embed -nocompress -pkg assets -o assets/embedded_gen.go ui/build/...
|
||||
|
||||
builds:
|
||||
- id: navidrome_darwin
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=o64-clang
|
||||
- CXX=o64-clang++
|
||||
goos:
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
flags:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- -s -w -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
- id: navidrome_linux_amd64
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
@@ -29,15 +10,29 @@ builds:
|
||||
goarch:
|
||||
- amd64
|
||||
flags:
|
||||
- -tags=embed
|
||||
- -tags=embed,netgo
|
||||
ldflags:
|
||||
- "-extldflags '-static -lz'"
|
||||
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
- id: navidrome_linux_386
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- 386
|
||||
flags:
|
||||
- -tags=embed,netgo
|
||||
ldflags:
|
||||
- "-extldflags '-static'"
|
||||
- -s -w -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
- id: navidrome_linux_arm
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=arm-linux-gnueabi-gcc-5
|
||||
- CC=arm-linux-gnueabi-gcc
|
||||
- CXX=arm-linux-gnueabi-g++
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
@@ -47,54 +42,72 @@ builds:
|
||||
- 6
|
||||
- 7
|
||||
flags:
|
||||
- -tags=embed
|
||||
- -tags=embed,netgo
|
||||
ldflags:
|
||||
- "-extldflags '-static'"
|
||||
- -s -w -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
- id: navidrome_linux_arm64
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=aarch64-linux-gnu-gcc-5
|
||||
- CC=aarch64-linux-gnu-gcc
|
||||
- CXX=aarch64-linux-gnu-g++
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- arm64
|
||||
flags:
|
||||
- -tags=embed
|
||||
- -tags=embed,netgo
|
||||
ldflags:
|
||||
- "-extldflags '-static'"
|
||||
- -s -w -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
- id: navidrome_windows_i686
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=i686-w64-mingw32-gcc
|
||||
- CXX=i686-w64-mingw32-g++
|
||||
- PKG_CONFIG_PATH=/mingw32/lib/pkgconfig
|
||||
goos:
|
||||
- windows
|
||||
goarch:
|
||||
- 386
|
||||
flags:
|
||||
- -tags=embed
|
||||
- -tags=embed,netgo
|
||||
ldflags:
|
||||
- "-extldflags '-static'"
|
||||
- -s -w -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
- id: navidrome_windows_x64
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=x86_64-w64-mingw32-gcc
|
||||
- CXX=x86_64-w64-mingw32-g++
|
||||
- PKG_CONFIG_PATH=/mingw64/lib/pkgconfig
|
||||
goos:
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
flags:
|
||||
- -tags=embed
|
||||
- -tags=embed,netgo
|
||||
ldflags:
|
||||
- "-extldflags '-static'"
|
||||
- -s -w -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
- id: navidrome_darwin
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=o64-clang
|
||||
- CXX=o64-clang++
|
||||
- PKG_CONFIG_PATH=/darwin/lib/pkgconfig
|
||||
goos:
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
flags:
|
||||
- -tags=embed,netgo
|
||||
ldflags:
|
||||
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
archives:
|
||||
- format_overrides:
|
||||
|
||||
129
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,129 @@
|
||||
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
navidrome@navidrome.org.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
93
Makefile
@@ -1,4 +1,4 @@
|
||||
GO_VERSION=$(shell grep -e "^go " go.mod | cut -f 2 -d ' ')
|
||||
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
|
||||
NODE_VERSION=$(shell cat .nvmrc)
|
||||
|
||||
GIT_SHA=$(shell git rev-parse --short HEAD)
|
||||
@@ -6,23 +6,23 @@ GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`)
|
||||
|
||||
## Default target just build the Go project.
|
||||
default:
|
||||
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=master"
|
||||
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=master"
|
||||
.PHONY: default
|
||||
|
||||
dev: check_env
|
||||
dev: check_dev_env
|
||||
npx foreman -j Procfile.dev -p 4533 start
|
||||
.PHONY: dev
|
||||
|
||||
server: check_go_env
|
||||
@reflex -d none -c reflex.conf
|
||||
server: check_go_dev_env
|
||||
@go run github.com/cespare/reflex -d none -c reflex.conf
|
||||
.PHONY: server
|
||||
|
||||
wire: check_go_env
|
||||
wire ./...
|
||||
go run github.com/google/wire/cmd/wire ./...
|
||||
.PHONY: wire
|
||||
|
||||
watch: check_go_env
|
||||
ginkgo watch -notify ./...
|
||||
go run github.com/onsi/ginkgo/ginkgo watch -notify ./...
|
||||
.PHONY: watch
|
||||
|
||||
test: check_go_env
|
||||
@@ -33,41 +33,61 @@ testall: check_go_env test
|
||||
@(cd ./ui && npm test -- --watchAll=false)
|
||||
.PHONY: testall
|
||||
|
||||
lint:
|
||||
go run github.com/golangci/golangci-lint/cmd/golangci-lint run -v
|
||||
.PHONY: lint
|
||||
|
||||
update-snapshots: check_go_env
|
||||
UPDATE_SNAPSHOTS=true ginkgo ./server/subsonic/...
|
||||
UPDATE_SNAPSHOTS=true go run github.com/onsi/ginkgo/ginkgo ./server/subsonic/...
|
||||
.PHONY: update-snapshots
|
||||
|
||||
create-migration:
|
||||
@if [ -z "${name}" ]; then echo "Usage: make create-migration name=name_of_migration_file"; exit 1; fi
|
||||
goose -dir db/migration create ${name}
|
||||
.PHONY: create-migration
|
||||
migration:
|
||||
@if [ -z "${name}" ]; then echo "Usage: make migration name=name_of_migration_file"; exit 1; fi
|
||||
go run github.com/pressly/goose/cmd/goose -dir db/migration create ${name}
|
||||
.PHONY: migration
|
||||
|
||||
setup:
|
||||
@which go-bindata || (echo "Installing BinData" && GO111MODULE=off go get -u github.com/go-bindata/go-bindata/...)
|
||||
go mod download
|
||||
@(cd ./ui && npm ci)
|
||||
setup: download-deps
|
||||
@echo Installing tools from tools.go
|
||||
@cat tools.go | grep _ | awk -F'"' '{print $$2}' | xargs -tI % go install %
|
||||
.PHONY: setup
|
||||
|
||||
download-deps:
|
||||
@echo Downloading Go dependencies...
|
||||
@go mod download -x
|
||||
@go mod tidy # To revert any changes made by the `go mod download` command
|
||||
@echo Downloading Node dependencies...
|
||||
@(cd ./ui && npm ci)
|
||||
.PHONY: download-deps
|
||||
|
||||
setup-dev: setup setup-git
|
||||
@which wire || (echo "Installing Wire" && GO111MODULE=off go get -u github.com/google/wire/cmd/wire)
|
||||
@which ginkgo || (echo "Installing Ginkgo" && GO111MODULE=off go get -u github.com/onsi/ginkgo/ginkgo)
|
||||
@which goose || (echo "Installing Goose" && GO111MODULE=off go get -u github.com/pressly/goose/cmd/goose)
|
||||
@which reflex || (echo "Installing Reflex" && GO111MODULE=off go get -u github.com/cespare/reflex)
|
||||
@which golangci-lint || (echo "Installing GolangCI-Lint" && cd .. && GO111MODULE=on go get github.com/golangci/golangci-lint/cmd/golangci-lint@v1.27.0)
|
||||
@which goimports || (echo "Installing goimports" && GO111MODULE=off go get -u golang.org/x/tools/cmd/goimports)
|
||||
.PHONY: setup-dev
|
||||
|
||||
setup-git:
|
||||
@echo Setting up git hooks
|
||||
@mkdir -p .git/hooks
|
||||
(cd .git/hooks && ln -sf ../../git/* .)
|
||||
@(cd .git/hooks && ln -sf ../../git/* .)
|
||||
.PHONY: setup-git
|
||||
|
||||
Jamstash-master:
|
||||
wget -N https://github.com/tsquillario/Jamstash/archive/master.zip
|
||||
unzip -o master.zip
|
||||
rm master.zip
|
||||
(cd Jamstash-master && npm ci && npx bower install && npx grunt build)
|
||||
rm -rf Jamstash-master/node_modules Jamstash-master/bower_components
|
||||
check_dev_env: check_go_dev_env check_node_dev_env
|
||||
.PHONY: check_dev_env
|
||||
|
||||
check_go_dev_env:
|
||||
@(hash go) || (echo "\nERROR: GO environment not setup properly!\n"; exit 1)
|
||||
@current_go_version=`go version | cut -d ' ' -f 3 | cut -c3-` && \
|
||||
echo "$(GO_VERSION) $$current_go_version" | \
|
||||
tr ' ' '\n' | sort -V | tail -1 | \
|
||||
grep -q "^$${current_go_version}$$" || \
|
||||
(echo "\nERROR: Please upgrade your GO version\nThis project requires at least the version $(GO_VERSION)"; exit 1)
|
||||
.PHONY: check_go_dev_env
|
||||
|
||||
check_node_dev_env:
|
||||
@(hash node) || (echo "\nERROR: Node environment not setup properly!\n"; exit 1)
|
||||
@current_node_version=`node --version` && \
|
||||
echo "$(NODE_VERSION) $$current_node_version" | \
|
||||
tr ' ' '\n' | sort -V | tail -1 | \
|
||||
grep -q "^$${current_node_version}$$" || \
|
||||
(echo "\nERROR: Please check your Node version. Should be at least $(NODE_VERSION)\n"; exit 1)
|
||||
.PHONY: check_node_dev_env
|
||||
|
||||
check_env: check_go_env check_node_env
|
||||
.PHONY: check_env
|
||||
@@ -83,25 +103,26 @@ check_node_env:
|
||||
.PHONY: check_node_env
|
||||
|
||||
build: check_go_env
|
||||
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT"
|
||||
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT"
|
||||
.PHONY: build
|
||||
|
||||
buildall: check_env
|
||||
@(cd ./ui && npm run build)
|
||||
go-bindata -fs -prefix "resources" -tags embed -ignore="\\\*.go" -pkg resources -o resources/embedded_gen.go resources/...
|
||||
go-bindata -fs -prefix "ui/build" -tags embed -nocompress -pkg assets -o assets/embedded_gen.go ui/build/...
|
||||
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT" -tags=embed
|
||||
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT" -tags=netgo
|
||||
.PHONY: buildall
|
||||
|
||||
pre-push: lint test
|
||||
.PHONY: pre-push
|
||||
|
||||
release:
|
||||
@if [[ ! "${V}" =~ ^[0-9]+\.[0-9]+\.[0-9]+.*$$ ]]; then echo "Usage: make release V=X.X.X"; exit 1; fi
|
||||
go mod tidy
|
||||
@if [ -n "`git status -s`" ]; then echo "\n\nThere are pending changes. Please commit or stash first"; exit 1; fi
|
||||
make test
|
||||
make pre-push
|
||||
git tag v${V}
|
||||
git push origin v${V}
|
||||
git push origin v${V} --no-verify
|
||||
.PHONY: release
|
||||
|
||||
snapshot:
|
||||
docker run -it -v $(PWD):/workspace -w /workspace deluan/ci-goreleaser:1.14.4-2 goreleaser release --rm-dist --skip-publish --snapshot
|
||||
docker run -t -v $(PWD):/workspace -w /workspace deluan/ci-goreleaser:1.16.2-1 goreleaser release --rm-dist --skip-publish --snapshot
|
||||
.PHONY: snapshot
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
JS: sh -c "cd ./ui && npm start"
|
||||
GO: reflex -c reflex.conf
|
||||
GO: go run github.com/cespare/reflex -c reflex.conf
|
||||
|
||||
29
README.md
@@ -1,27 +1,26 @@
|
||||
# Navidrome Music Streamer
|
||||
# Navidrome Music Server
|
||||
|
||||
[](https://github.com/deluan/navidrome/releases)
|
||||
[](https://github.com/deluan/navidrome/actions)
|
||||
[](https://github.com/deluan/navidrome/releases/latest)
|
||||
[](https://github.com/navidrome/navidrome/releases)
|
||||
[](https://github.com/navidrome/navidrome/actions)
|
||||
[](https://github.com/navidrome/navidrome/releases/latest)
|
||||
[](https://hub.docker.com/r/deluan/navidrome)
|
||||
[](https://discord.gg/xh7j7yF)
|
||||
[](https://www.reddit.com/r/navidrome/)
|
||||
[](code_of_conduct.md)
|
||||
|
||||
## [Check out our Live Demo!](https://www.navidrome.org/demo/)
|
||||
|
||||
Navidrome is an open source web-based music collection server and streamer. It gives you freedom to listen to your
|
||||
music collection from any browser or mobile device. It's like your personal Spotify!
|
||||
|
||||
__Any feedback is welcome!__ If you need/want a new feature, find a bug or think of any way to improve Navidrome,
|
||||
please fill a [GitHub issue](https://github.com/deluan/navidrome/issues) or join the discussion in our
|
||||
please file a [GitHub issue](https://github.com/navidrome/navidrome/issues) or join the discussion in our
|
||||
[Subreddit](https://www.reddit.com/r/navidrome/). If you want to contribute to the project in any other way
|
||||
([ui/backend dev](https://www.navidrome.org/docs/developers/),
|
||||
[translations](https://www.navidrome.org/docs/developers/translations/),
|
||||
[themes](https://www.navidrome.org/docs/developers/creating-themes)), please join the chat in our
|
||||
[Discord server](https://discord.gg/xh7j7yF).
|
||||
|
||||
## Demo Site
|
||||
|
||||
To see Navidrome in action, check out our [live demo](https://www.navidrome.org/demo/)
|
||||
|
||||
## Installation
|
||||
|
||||
See instructions in the [project's website](https://www.navidrome.org/docs/installation/)
|
||||
@@ -31,11 +30,11 @@ See instructions in the [project's website](https://www.navidrome.org/docs/insta
|
||||
- Handles very **large music collections**
|
||||
- Streams virtually **any audio format** available
|
||||
- Reads and uses all your beautifully curated **metadata**
|
||||
- Great support for **Box Sets** (multi-disc albums)
|
||||
- Great support for **compilations** (Various Artists albums) and **box sets** (multi-disc albums)
|
||||
- **Multi-user**, each user has their own play counts, playlists, favourites, etc...
|
||||
- Very **low resource usage**
|
||||
- **Multi-platform**, runs on macOS, Linux and Windows. **Docker** images are also provided
|
||||
- Ready to use **Raspberry Pi** binaries and Docker images available
|
||||
- Ready to use binaries for all major platforms, including **Raspberry Pi**
|
||||
- Automatically **monitors your library** for changes, importing new files and reloading new metadata
|
||||
- **Themeable**, modern and responsive **Web interface** based on [Material UI](https://material-ui.com)
|
||||
- **Compatible** with all Subsonic/Madsonic/Airsonic [clients](https://www.navidrome.org/docs/overview/#apps)
|
||||
@@ -57,8 +56,8 @@ Here are some useful direct links:
|
||||
## Screenshots
|
||||
|
||||
<p align="left">
|
||||
<img height="550" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-login.png">
|
||||
<img height="550" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-player.png">
|
||||
<img height="550" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-album-view.png">
|
||||
<img width="550" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-desktop-player.png">
|
||||
<img height="550" src="https://raw.githubusercontent.com/navidrome/navidrome/master/.github/screenshots/ss-mobile-login.png">
|
||||
<img height="550" src="https://raw.githubusercontent.com/navidrome/navidrome/master/.github/screenshots/ss-mobile-player.png">
|
||||
<img height="550" src="https://raw.githubusercontent.com/navidrome/navidrome/master/.github/screenshots/ss-mobile-album-view.png">
|
||||
<img width="550" src="https://raw.githubusercontent.com/navidrome/navidrome/master/.github/screenshots/ss-desktop-player.png">
|
||||
</p>
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
// +build !embed
|
||||
|
||||
package assets
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
)
|
||||
|
||||
var once sync.Once
|
||||
|
||||
func AssetFile() http.FileSystem {
|
||||
once.Do(func() {
|
||||
log.Warn("Using external assets from 'ui/build' folder")
|
||||
})
|
||||
return http.Dir("ui/build")
|
||||
}
|
||||
71
cmd/root.go
@@ -1,12 +1,16 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/db"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/oklog/run"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
@@ -24,7 +28,7 @@ Complete documentation is available at https://www.navidrome.org/docs`,
|
||||
preRun()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
startServer()
|
||||
runNavidrome()
|
||||
},
|
||||
Version: consts.Version(),
|
||||
}
|
||||
@@ -45,20 +49,60 @@ func preRun() {
|
||||
conf.Load()
|
||||
}
|
||||
|
||||
func startServer() {
|
||||
func runNavidrome() {
|
||||
db.EnsureLatestVersion()
|
||||
|
||||
subsonic, err := CreateSubsonicAPIRouter()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Could not create the Subsonic API router. Aborting! err=%v", err))
|
||||
var g run.Group
|
||||
g.Add(startServer())
|
||||
g.Add(startScanner())
|
||||
|
||||
if err := g.Run(); err != nil {
|
||||
log.Error("Fatal error in Navidrome. Aborting", err)
|
||||
}
|
||||
a := CreateServer(conf.Server.MusicFolder)
|
||||
a.MountRouter(consts.URLPathSubsonicAPI, subsonic)
|
||||
a.MountRouter(consts.URLPathUI, CreateAppRouter())
|
||||
a.Run(fmt.Sprintf("%s:%d", conf.Server.Address, conf.Server.Port))
|
||||
}
|
||||
|
||||
// TODO: Implemement some struct tags to map flags to viper
|
||||
func startServer() (func() error, func(err error)) {
|
||||
return func() error {
|
||||
a := CreateServer(conf.Server.MusicFolder)
|
||||
a.MountRouter(consts.URLPathSubsonicAPI, CreateSubsonicAPIRouter())
|
||||
a.MountRouter(consts.URLPathUI, CreateAppRouter())
|
||||
return a.Run(fmt.Sprintf("%s:%d", conf.Server.Address, conf.Server.Port))
|
||||
}, func(err error) {
|
||||
if err != nil {
|
||||
log.Error("Shutting down Server due to error", err)
|
||||
} else {
|
||||
log.Info("Shutting down Server")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startScanner() (func() error, func(err error)) {
|
||||
interval := conf.Server.ScanInterval
|
||||
log.Info("Starting scanner", "interval", interval.String())
|
||||
scanner := GetScanner()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
return func() error {
|
||||
if interval != 0 {
|
||||
time.Sleep(2 * time.Second) // Wait 2 seconds before the first scan
|
||||
scanner.Run(ctx, interval)
|
||||
} else {
|
||||
log.Warn("Periodic scan is DISABLED", "interval", interval)
|
||||
<-ctx.Done()
|
||||
}
|
||||
|
||||
return nil
|
||||
}, func(err error) {
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Error("Shutting down Scanner due to error", err)
|
||||
} else {
|
||||
log.Info("Shutting down Scanner")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Implement some struct tags to map flags to viper
|
||||
func init() {
|
||||
cobra.OnInitialize(func() {
|
||||
conf.InitConfig(cfgFile)
|
||||
@@ -83,6 +127,7 @@ func init() {
|
||||
rootCmd.Flags().Bool("enabletranscodingconfig", viper.GetBool("enabletranscodingconfig"), "enables transcoding configuration in the UI")
|
||||
rootCmd.Flags().String("transcodingcachesize", viper.GetString("transcodingcachesize"), "size of transcoding cache")
|
||||
rootCmd.Flags().String("imagecachesize", viper.GetString("imagecachesize"), "size of image (art work) cache. set to 0 to disable cache")
|
||||
rootCmd.Flags().Bool("autoimportplaylists", viper.GetBool("autoimportplaylists"), "enable/disable .m3u playlist auto-import`")
|
||||
|
||||
_ = viper.BindPFlag("address", rootCmd.Flags().Lookup("address"))
|
||||
_ = viper.BindPFlag("port", rootCmd.Flags().Lookup("port"))
|
||||
|
||||
14
cmd/scan.go
@@ -1,9 +1,10 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
var fullRescan bool
|
||||
@@ -23,11 +24,10 @@ var scanCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
func runScanner() {
|
||||
scanner := CreateScanner(conf.Server.MusicFolder)
|
||||
err := scanner.RescanAll(fullRescan)
|
||||
if err != nil {
|
||||
log.Error("Error scanning media folder", "folder", conf.Server.MusicFolder, err)
|
||||
}
|
||||
conf.Server.DevPreCacheAlbumArtwork = false
|
||||
|
||||
scanner := GetScanner()
|
||||
_ = scanner.RescanAll(context.Background(), fullRescan)
|
||||
if fullRescan {
|
||||
log.Info("Finished full rescan")
|
||||
} else {
|
||||
|
||||
105
cmd/wire_gen.go
@@ -1,69 +1,94 @@
|
||||
// Code generated by Wire. DO NOT EDIT.
|
||||
|
||||
//go:generate wire
|
||||
//go:generate go run github.com/google/wire/cmd/wire
|
||||
//+build !wireinject
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/deluan/navidrome/core"
|
||||
"github.com/deluan/navidrome/core/transcoder"
|
||||
"github.com/deluan/navidrome/engine"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
"github.com/deluan/navidrome/scanner"
|
||||
"github.com/deluan/navidrome/server"
|
||||
"github.com/deluan/navidrome/server/app"
|
||||
"github.com/deluan/navidrome/server/subsonic"
|
||||
"github.com/google/wire"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/transcoder"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/app"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/server/subsonic"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Injectors from wire_injectors.go:
|
||||
|
||||
func CreateServer(musicFolder string) *server.Server {
|
||||
dataStore := persistence.New()
|
||||
scannerScanner := scanner.New(dataStore)
|
||||
serverServer := server.New(scannerScanner, dataStore)
|
||||
serverServer := server.New(dataStore)
|
||||
return serverServer
|
||||
}
|
||||
|
||||
func CreateScanner(musicFolder string) *scanner.Scanner {
|
||||
dataStore := persistence.New()
|
||||
scannerScanner := scanner.New(dataStore)
|
||||
return scannerScanner
|
||||
}
|
||||
|
||||
func CreateAppRouter() *app.Router {
|
||||
dataStore := persistence.New()
|
||||
router := app.New(dataStore)
|
||||
broker := GetBroker()
|
||||
router := app.New(dataStore, broker)
|
||||
return router
|
||||
}
|
||||
|
||||
func CreateSubsonicAPIRouter() (*subsonic.Router, error) {
|
||||
func CreateSubsonicAPIRouter() *subsonic.Router {
|
||||
dataStore := persistence.New()
|
||||
browser := engine.NewBrowser(dataStore)
|
||||
imageCache, err := core.NewImageCache()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cover := core.NewCover(dataStore, imageCache)
|
||||
nowPlayingRepository := engine.NewNowPlayingRepository()
|
||||
listGenerator := engine.NewListGenerator(dataStore, nowPlayingRepository)
|
||||
users := engine.NewUsers(dataStore)
|
||||
playlists := engine.NewPlaylists(dataStore)
|
||||
ratings := engine.NewRatings(dataStore)
|
||||
scrobbler := engine.NewScrobbler(dataStore, nowPlayingRepository)
|
||||
search := engine.NewSearch(dataStore)
|
||||
artworkCache := core.GetImageCache()
|
||||
artwork := core.NewArtwork(dataStore, artworkCache)
|
||||
transcoderTranscoder := transcoder.New()
|
||||
transcodingCache, err := core.NewTranscodingCache()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transcodingCache := core.GetTranscodingCache()
|
||||
mediaStreamer := core.NewMediaStreamer(dataStore, transcoderTranscoder, transcodingCache)
|
||||
players := engine.NewPlayers(dataStore)
|
||||
router := subsonic.New(browser, cover, listGenerator, users, playlists, ratings, scrobbler, search, mediaStreamer, players)
|
||||
return router, nil
|
||||
archiver := core.NewArchiver(dataStore)
|
||||
players := core.NewPlayers(dataStore)
|
||||
externalMetadata := core.NewExternalMetadata(dataStore)
|
||||
scanner := GetScanner()
|
||||
router := subsonic.New(dataStore, artwork, mediaStreamer, archiver, players, externalMetadata, scanner)
|
||||
return router
|
||||
}
|
||||
|
||||
func createScanner() scanner.Scanner {
|
||||
dataStore := persistence.New()
|
||||
artworkCache := core.GetImageCache()
|
||||
artwork := core.NewArtwork(dataStore, artworkCache)
|
||||
cacheWarmer := core.NewCacheWarmer(artwork, artworkCache)
|
||||
broker := GetBroker()
|
||||
scannerScanner := scanner.New(dataStore, cacheWarmer, broker)
|
||||
return scannerScanner
|
||||
}
|
||||
|
||||
func createBroker() events.Broker {
|
||||
broker := events.NewBroker()
|
||||
return broker
|
||||
}
|
||||
|
||||
// wire_injectors.go:
|
||||
|
||||
var allProviders = wire.NewSet(engine.Set, core.Set, scanner.New, subsonic.New, app.New, persistence.New)
|
||||
var allProviders = wire.NewSet(core.Set, subsonic.New, app.New, persistence.New)
|
||||
|
||||
// Scanner must be a Singleton
|
||||
var (
|
||||
onceScanner sync.Once
|
||||
scannerInstance scanner.Scanner
|
||||
)
|
||||
|
||||
func GetScanner() scanner.Scanner {
|
||||
onceScanner.Do(func() {
|
||||
scannerInstance = createScanner()
|
||||
})
|
||||
return scannerInstance
|
||||
}
|
||||
|
||||
// Broker must be a Singleton
|
||||
var (
|
||||
onceBroker sync.Once
|
||||
brokerInstance events.Broker
|
||||
)
|
||||
|
||||
func GetBroker() events.Broker {
|
||||
onceBroker.Do(func() {
|
||||
brokerInstance = createBroker()
|
||||
})
|
||||
return brokerInstance
|
||||
}
|
||||
|
||||
@@ -3,20 +3,20 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/deluan/navidrome/core"
|
||||
"github.com/deluan/navidrome/engine"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
"github.com/deluan/navidrome/scanner"
|
||||
"github.com/deluan/navidrome/server"
|
||||
"github.com/deluan/navidrome/server/app"
|
||||
"github.com/deluan/navidrome/server/subsonic"
|
||||
"sync"
|
||||
|
||||
"github.com/google/wire"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/app"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/server/subsonic"
|
||||
)
|
||||
|
||||
var allProviders = wire.NewSet(
|
||||
engine.Set,
|
||||
core.Set,
|
||||
scanner.New,
|
||||
subsonic.New,
|
||||
app.New,
|
||||
persistence.New,
|
||||
@@ -29,16 +29,56 @@ func CreateServer(musicFolder string) *server.Server {
|
||||
))
|
||||
}
|
||||
|
||||
func CreateScanner(musicFolder string) *scanner.Scanner {
|
||||
func CreateAppRouter() *app.Router {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
GetBroker,
|
||||
))
|
||||
}
|
||||
|
||||
func CreateAppRouter() *app.Router {
|
||||
panic(wire.Build(allProviders))
|
||||
func CreateSubsonicAPIRouter() *subsonic.Router {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
GetScanner,
|
||||
))
|
||||
}
|
||||
|
||||
func CreateSubsonicAPIRouter() (*subsonic.Router, error) {
|
||||
panic(wire.Build(allProviders))
|
||||
// Scanner must be a Singleton
|
||||
var (
|
||||
onceScanner sync.Once
|
||||
scannerInstance scanner.Scanner
|
||||
)
|
||||
|
||||
func GetScanner() scanner.Scanner {
|
||||
onceScanner.Do(func() {
|
||||
scannerInstance = createScanner()
|
||||
})
|
||||
return scannerInstance
|
||||
}
|
||||
|
||||
func createScanner() scanner.Scanner {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
GetBroker,
|
||||
scanner.New,
|
||||
))
|
||||
}
|
||||
|
||||
// Broker must be a Singleton
|
||||
var (
|
||||
onceBroker sync.Once
|
||||
brokerInstance events.Broker
|
||||
)
|
||||
|
||||
func GetBroker() events.Broker {
|
||||
onceBroker.Do(func() {
|
||||
brokerInstance = createBroker()
|
||||
})
|
||||
return brokerInstance
|
||||
}
|
||||
|
||||
func createBroker() events.Broker {
|
||||
panic(wire.Build(
|
||||
events.NewBroker,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/kr/pretty"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
@@ -24,26 +26,58 @@ type configOptions struct {
|
||||
BaseURL string
|
||||
UILoginBackgroundURL string
|
||||
EnableTranscodingConfig bool
|
||||
EnableDownloads bool
|
||||
TranscodingCacheSize string
|
||||
ImageCacheSize string
|
||||
AutoImportPlaylists bool
|
||||
|
||||
IgnoredArticles string
|
||||
IndexGroups string
|
||||
ProbeCommand string
|
||||
CoverArtPriority string
|
||||
CoverJpegQuality int
|
||||
UIWelcomeMessage string
|
||||
GATrackingID string
|
||||
AuthRequestLimit int
|
||||
AuthWindowLength time.Duration
|
||||
SearchFullString bool
|
||||
RecentlyAddedByModTime bool
|
||||
IgnoredArticles string
|
||||
IndexGroups string
|
||||
ProbeCommand string
|
||||
CoverArtPriority string
|
||||
CoverJpegQuality int
|
||||
UIWelcomeMessage string
|
||||
EnableGravatar bool
|
||||
GATrackingID string
|
||||
AuthRequestLimit int
|
||||
AuthWindowLength time.Duration
|
||||
|
||||
Scanner scannerOptions
|
||||
|
||||
Agents string
|
||||
LastFM lastfmOptions
|
||||
Spotify spotifyOptions
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
DevLogSourceLine bool
|
||||
DevAutoCreateAdminPassword string
|
||||
DevOldScanner bool
|
||||
DevPreCacheAlbumArtwork bool
|
||||
DevFastAccessCoverArt bool
|
||||
DevOldCacheLayout bool
|
||||
DevActivityPanel bool
|
||||
}
|
||||
|
||||
var Server = &configOptions{}
|
||||
type scannerOptions struct {
|
||||
Extractor string
|
||||
}
|
||||
|
||||
type lastfmOptions struct {
|
||||
ApiKey string
|
||||
Secret string
|
||||
Language string
|
||||
}
|
||||
|
||||
type spotifyOptions struct {
|
||||
ID string
|
||||
Secret string
|
||||
}
|
||||
|
||||
var (
|
||||
Server = &configOptions{}
|
||||
hooks []func()
|
||||
)
|
||||
|
||||
func LoadFromFile(confFile string) {
|
||||
viper.SetConfigFile(confFile)
|
||||
@@ -68,12 +102,24 @@ func Load() {
|
||||
|
||||
log.SetLevelString(Server.LogLevel)
|
||||
log.SetLogSourceLine(Server.DevLogSourceLine)
|
||||
log.Debug("Loaded configuration", "file", Server.ConfigFile, "config", fmt.Sprintf("%#v", Server))
|
||||
if log.CurrentLevel() >= log.LevelDebug {
|
||||
pretty.Printf("Loaded configuration from '%s': %# v\n", Server.ConfigFile, Server)
|
||||
}
|
||||
|
||||
// Call init hooks
|
||||
for _, hook := range hooks {
|
||||
hook()
|
||||
}
|
||||
}
|
||||
|
||||
// AddHook is used to register initialization code that should run as soon as the config is loaded
|
||||
func AddHook(hook func()) {
|
||||
hooks = append(hooks, hook)
|
||||
}
|
||||
|
||||
func init() {
|
||||
viper.SetDefault("musicfolder", "./music")
|
||||
viper.SetDefault("datafolder", "./")
|
||||
viper.SetDefault("musicfolder", filepath.Join(".", "music"))
|
||||
viper.SetDefault("datafolder", ".")
|
||||
viper.SetDefault("loglevel", "info")
|
||||
viper.SetDefault("address", "0.0.0.0")
|
||||
viper.SetDefault("port", 4533)
|
||||
@@ -84,25 +130,42 @@ func init() {
|
||||
viper.SetDefault("enabletranscodingconfig", false)
|
||||
viper.SetDefault("transcodingcachesize", "100MB")
|
||||
viper.SetDefault("imagecachesize", "100MB")
|
||||
viper.SetDefault("autoimportplaylists", true)
|
||||
viper.SetDefault("enabledownloads", true)
|
||||
|
||||
// Config options only valid for file/env configuration
|
||||
viper.SetDefault("searchfullstring", false)
|
||||
viper.SetDefault("recentlyaddedbymodtime", false)
|
||||
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
|
||||
viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)")
|
||||
viper.SetDefault("probecommand", "ffmpeg %s -f ffmetadata")
|
||||
viper.SetDefault("coverartpriority", "embedded, cover.*, folder.*, front.*")
|
||||
viper.SetDefault("coverjpegquality", 75)
|
||||
viper.SetDefault("uiwelcomemessage", "")
|
||||
viper.SetDefault("enablegravatar", false)
|
||||
viper.SetDefault("gatrackingid", "")
|
||||
viper.SetDefault("authrequestlimit", 5)
|
||||
viper.SetDefault("authwindowlength", 20*time.Second)
|
||||
|
||||
viper.SetDefault("scanner.extractor", "taglib")
|
||||
viper.SetDefault("agents", "lastfm,spotify")
|
||||
viper.SetDefault("lastfm.language", "en")
|
||||
viper.SetDefault("lastfm.apikey", "")
|
||||
viper.SetDefault("lastfm.secret", "")
|
||||
viper.SetDefault("spotify.id", "")
|
||||
viper.SetDefault("spotify.secret", "")
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
viper.SetDefault("devlogsourceline", false)
|
||||
viper.SetDefault("devautocreateadminpassword", "")
|
||||
viper.SetDefault("devoldscanner", false)
|
||||
viper.SetDefault("devprecachealbumartwork", false)
|
||||
viper.SetDefault("devoldcachelayout", false)
|
||||
viper.SetDefault("devFastAccessCoverArt", false)
|
||||
viper.SetDefault("devactivitypanel", true)
|
||||
}
|
||||
|
||||
func InitConfig(cfgFile string) {
|
||||
cfgFile = getConfigFile(cfgFile)
|
||||
if cfgFile != "" {
|
||||
// Use config file from the flag.
|
||||
viper.SetConfigFile(cfgFile)
|
||||
@@ -114,6 +177,8 @@ func InitConfig(cfgFile string) {
|
||||
|
||||
_ = viper.BindEnv("port")
|
||||
viper.SetEnvPrefix("ND")
|
||||
replacer := strings.NewReplacer(".", "_")
|
||||
viper.SetEnvKeyReplacer(replacer)
|
||||
viper.AutomaticEnv()
|
||||
|
||||
err := viper.ReadInConfig()
|
||||
@@ -122,3 +187,10 @@ func InitConfig(cfgFile string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func getConfigFile(cfgFile string) string {
|
||||
if cfgFile != "" {
|
||||
return cfgFile
|
||||
}
|
||||
return os.Getenv("ND_CONFIGFILE")
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/deluan/navidrome/resources"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
)
|
||||
|
||||
func getBanner() string {
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
const (
|
||||
AppName = "navidrome"
|
||||
|
||||
LocalConfigFile = "./navidrome.toml"
|
||||
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on"
|
||||
InitialSetupFlagKey = "InitialSetup"
|
||||
|
||||
@@ -28,10 +27,15 @@ const (
|
||||
RequestThrottleBacklogLimit = 100
|
||||
RequestThrottleBacklogTimeout = time.Minute
|
||||
|
||||
ArtistInfoTimeToLive = 1 * time.Hour
|
||||
|
||||
I18nFolder = "i18n"
|
||||
SkipScanFile = ".ndignore"
|
||||
|
||||
PlaceholderAlbumArt = "navidrome-600x600.png"
|
||||
PlaceholderAvatar = "logo-192x192.png"
|
||||
|
||||
DefaultCachedHttpClientTTL = 10 * time.Second
|
||||
)
|
||||
|
||||
// Cache options
|
||||
@@ -56,7 +60,7 @@ var (
|
||||
},
|
||||
{
|
||||
"name": "opus audio",
|
||||
"targetFormat": "oga",
|
||||
"targetFormat": "opus",
|
||||
"defaultBitRate": 128,
|
||||
"command": "ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -c:a libopus -f opus -",
|
||||
},
|
||||
@@ -67,4 +71,6 @@ var (
|
||||
VariousArtists = "Various Artists"
|
||||
VariousArtistsID = fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(VariousArtists))))
|
||||
UnknownArtist = "[Unknown Artist]"
|
||||
|
||||
ServerStart = time.Now()
|
||||
)
|
||||
|
||||
@@ -22,9 +22,12 @@ func init() {
|
||||
".m3u": "audio/x-mpegurl",
|
||||
".pls": "audio/x-scpls",
|
||||
".dsf": "audio/dsd",
|
||||
".wv": "audio/x-wavpack",
|
||||
".wvp": "audio/x-wavpack",
|
||||
".gif": "image/gif",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".png": "image/png",
|
||||
".bmp": "image/bmp",
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package consts
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
// This will be set in build time. If not, version will be set to "dev"
|
||||
@@ -11,10 +14,12 @@ var (
|
||||
// Formats:
|
||||
// dev
|
||||
// v0.2.0 (5b84188)
|
||||
// v0.3.2-SNAPSHOT (715f552)
|
||||
// master (9ed35cb)
|
||||
func Version() string {
|
||||
if gitSha == "" {
|
||||
return "dev"
|
||||
}
|
||||
gitTag = strings.TrimPrefix(gitTag, "v")
|
||||
return fmt.Sprintf("%s (%s)", gitTag, gitSha)
|
||||
}
|
||||
|
||||
52
contrib/freebsd_rc
Normal file
@@ -0,0 +1,52 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# $FreeBSD: $
|
||||
#
|
||||
# PROVIDE: navidrome
|
||||
# REQUIRE: NETWORKING
|
||||
# KEYWORD:
|
||||
#
|
||||
# Add the following lines to /etc/rc.conf to enable navidrome:
|
||||
# navidrome_enable="YES"
|
||||
#
|
||||
# navidrome_enable (bool): Set to YES to enable navidrome
|
||||
# Default: NO
|
||||
# navidrome_config (str): navidrome configration file
|
||||
# Default: /usr/local/etc/navidrome/config.toml
|
||||
# navidrome_datafolder (str): navidrome Folder to store application data
|
||||
# Default: www
|
||||
# navidrome_user (str): navidrome daemon user
|
||||
# Default: www
|
||||
# navidrome_group (str): navidrome daemon group
|
||||
# Default: www
|
||||
|
||||
. /etc/rc.subr
|
||||
|
||||
name="navidrome"
|
||||
rcvar="navidrome_enable"
|
||||
load_rc_config $name
|
||||
|
||||
: ${navidrome_user:="www"}
|
||||
: ${navidrome_group:="www"}
|
||||
: ${navidrome_enable:="NO"}
|
||||
: ${navidrome_config:="/usr/local/etc/navidrome/config.toml"}
|
||||
: ${navidrome_flags=""}
|
||||
: ${navidrome_facility:="daemon"}
|
||||
: ${navidrome_priority:="debug"}
|
||||
: ${navidrome_datafolder:="/var/db/${name}"}
|
||||
|
||||
required_dirs=${navidrome_datafolder}
|
||||
required_files=${navidrome_config}
|
||||
procname="/usr/local/bin/${name}"
|
||||
pidfile="/var/run/${name}.pid"
|
||||
start_precmd="${name}_precmd"
|
||||
command=/usr/sbin/daemon
|
||||
command_args="-S -l ${navidrome_facility} -s ${navidrome_priority} -T ${name} -t ${name} -p ${pidfile} \
|
||||
${procname} --configfile ${navidrome_config} --datafolder ${navidrome_datafolder} ${navidrome_flags}"
|
||||
|
||||
navidrome_precmd()
|
||||
{
|
||||
install -o ${navidrome_user} /dev/null ${pidfile}
|
||||
}
|
||||
|
||||
run_rc_command "$1"
|
||||
12
core/agents/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
This folder abstracts metadata lookup into "agents". Each agent can be implemented to get as
|
||||
much info as the external source provides, by using a granular set of interfaces
|
||||
(see [interfaces](interfaces.go)].
|
||||
|
||||
A new agent must comply with these simple implementation rules:
|
||||
1) Implement the `AgentName()` method. It just returns the name of the agent for logging purposes.
|
||||
2) Implement one or more of the `*Retriever()` interfaces. That's where the agent's logic resides.
|
||||
3) Register itself (in its `init()` function).
|
||||
|
||||
For an agent to be used it needs to be listed in the `Agents` config option (default is `"lastfm,spotify"`). The order dictates the priority of the agents
|
||||
|
||||
For a simple Agent example, look at the [placeholders.go](placeholders.go) agent source code.
|
||||
17
core/agents/agents_suite_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestAgents(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelCritical)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Agents Test Suite")
|
||||
}
|
||||
107
core/agents/cached_http_client.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ReneKroon/ttlcache/v2"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
const cacheSizeLimit = 100
|
||||
|
||||
type CachedHTTPClient struct {
|
||||
cache *ttlcache.Cache
|
||||
hc httpDoer
|
||||
}
|
||||
|
||||
type httpDoer interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
type requestData struct {
|
||||
Method string
|
||||
Header http.Header
|
||||
URL string
|
||||
Body *string
|
||||
}
|
||||
|
||||
func NewCachedHTTPClient(wrapped httpDoer, ttl time.Duration) *CachedHTTPClient {
|
||||
c := &CachedHTTPClient{hc: wrapped}
|
||||
c.cache = ttlcache.NewCache()
|
||||
c.cache.SetCacheSizeLimit(cacheSizeLimit)
|
||||
c.cache.SkipTTLExtensionOnHit(true)
|
||||
c.cache.SetLoaderFunction(func(key string) (interface{}, time.Duration, error) {
|
||||
req, err := c.deserializeReq(key)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return c.serializeResponse(resp), ttl, nil
|
||||
})
|
||||
c.cache.SetNewItemCallback(func(key string, value interface{}) {
|
||||
log.Trace("New request cached", "req", key, "resp", value)
|
||||
})
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *CachedHTTPClient) Do(req *http.Request) (*http.Response, error) {
|
||||
key := c.serializeReq(req)
|
||||
respStr, err := c.cache.Get(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.deserializeResponse(req, respStr.(string))
|
||||
}
|
||||
|
||||
func (c *CachedHTTPClient) serializeReq(req *http.Request) string {
|
||||
data := requestData{
|
||||
Method: req.Method,
|
||||
Header: req.Header,
|
||||
URL: req.URL.String(),
|
||||
}
|
||||
if req.Body != nil {
|
||||
bodyData, _ := ioutil.ReadAll(req.Body)
|
||||
bodyStr := base64.StdEncoding.EncodeToString(bodyData)
|
||||
data.Body = &bodyStr
|
||||
}
|
||||
j, _ := json.Marshal(&data)
|
||||
return string(j)
|
||||
}
|
||||
|
||||
func (c *CachedHTTPClient) deserializeReq(reqStr string) (*http.Request, error) {
|
||||
var data requestData
|
||||
_ = json.Unmarshal([]byte(reqStr), &data)
|
||||
var body io.Reader
|
||||
if data.Body != nil {
|
||||
bodyStr, _ := base64.StdEncoding.DecodeString(*data.Body)
|
||||
body = strings.NewReader(string(bodyStr))
|
||||
}
|
||||
req, err := http.NewRequest(data.Method, data.URL, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header = data.Header
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (c *CachedHTTPClient) serializeResponse(resp *http.Response) string {
|
||||
var b = &bytes.Buffer{}
|
||||
_ = resp.Write(b)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (c *CachedHTTPClient) deserializeResponse(req *http.Request, respStr string) (*http.Response, error) {
|
||||
r := bufio.NewReader(strings.NewReader(respStr))
|
||||
return http.ReadResponse(r, req)
|
||||
}
|
||||
93
core/agents/cached_http_client_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("CachedHttpClient", func() {
|
||||
Context("GET", func() {
|
||||
var chc *CachedHTTPClient
|
||||
var ts *httptest.Server
|
||||
var requestsReceived int
|
||||
var header string
|
||||
|
||||
BeforeEach(func() {
|
||||
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestsReceived++
|
||||
header = r.Header.Get("head")
|
||||
_, _ = fmt.Fprintf(w, "Hello, %s", r.URL.Query()["name"])
|
||||
}))
|
||||
chc = NewCachedHTTPClient(http.DefaultClient, consts.DefaultCachedHttpClientTTL)
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
defer ts.Close()
|
||||
})
|
||||
|
||||
It("caches repeated requests", func() {
|
||||
r, _ := http.NewRequest("GET", ts.URL+"?name=doe", nil)
|
||||
resp, err := chc.Do(r)
|
||||
Expect(err).To(BeNil())
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(string(body)).To(Equal("Hello, [doe]"))
|
||||
Expect(requestsReceived).To(Equal(1))
|
||||
|
||||
// Same request
|
||||
r, _ = http.NewRequest("GET", ts.URL+"?name=doe", nil)
|
||||
resp, err = chc.Do(r)
|
||||
Expect(err).To(BeNil())
|
||||
body, err = ioutil.ReadAll(resp.Body)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(string(body)).To(Equal("Hello, [doe]"))
|
||||
Expect(requestsReceived).To(Equal(1))
|
||||
|
||||
// Different request
|
||||
r, _ = http.NewRequest("GET", ts.URL, nil)
|
||||
resp, err = chc.Do(r)
|
||||
Expect(err).To(BeNil())
|
||||
body, err = ioutil.ReadAll(resp.Body)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(string(body)).To(Equal("Hello, []"))
|
||||
Expect(requestsReceived).To(Equal(2))
|
||||
|
||||
// Different again (same as before, but with header)
|
||||
r, _ = http.NewRequest("GET", ts.URL, nil)
|
||||
r.Header.Add("head", "this is a header")
|
||||
resp, err = chc.Do(r)
|
||||
Expect(err).To(BeNil())
|
||||
body, err = ioutil.ReadAll(resp.Body)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(string(body)).To(Equal("Hello, []"))
|
||||
Expect(header).To(Equal("this is a header"))
|
||||
Expect(requestsReceived).To(Equal(3))
|
||||
})
|
||||
|
||||
It("expires responses after TTL", func() {
|
||||
requestsReceived = 0
|
||||
chc = NewCachedHTTPClient(http.DefaultClient, 10*time.Millisecond)
|
||||
|
||||
r, _ := http.NewRequest("GET", ts.URL+"?name=doe", nil)
|
||||
_, err := chc.Do(r)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(requestsReceived).To(Equal(1))
|
||||
|
||||
// Wait more than the TTL
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Same request
|
||||
r, _ = http.NewRequest("GET", ts.URL+"?name=doe", nil)
|
||||
_, err = chc.Do(r)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(requestsReceived).To(Equal(2))
|
||||
})
|
||||
})
|
||||
})
|
||||
64
core/agents/interfaces.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type Constructor func(ctx context.Context) Interface
|
||||
|
||||
type Interface interface {
|
||||
AgentName() string
|
||||
}
|
||||
|
||||
type Artist struct {
|
||||
Name string
|
||||
MBID string
|
||||
}
|
||||
|
||||
type ArtistImage struct {
|
||||
URL string
|
||||
Size int
|
||||
}
|
||||
|
||||
type Song struct {
|
||||
Name string
|
||||
MBID string
|
||||
}
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
)
|
||||
|
||||
type ArtistMBIDRetriever interface {
|
||||
GetMBID(id string, name string) (string, error)
|
||||
}
|
||||
|
||||
type ArtistURLRetriever interface {
|
||||
GetURL(id, name, mbid string) (string, error)
|
||||
}
|
||||
|
||||
type ArtistBiographyRetriever interface {
|
||||
GetBiography(id, name, mbid string) (string, error)
|
||||
}
|
||||
|
||||
type ArtistSimilarRetriever interface {
|
||||
GetSimilar(id, name, mbid string, limit int) ([]Artist, error)
|
||||
}
|
||||
|
||||
type ArtistImageRetriever interface {
|
||||
GetImages(id, name, mbid string) ([]ArtistImage, error)
|
||||
}
|
||||
|
||||
type ArtistTopSongsRetriever interface {
|
||||
GetTopSongs(id, artistName, mbid string, count int) ([]Song, error)
|
||||
}
|
||||
|
||||
var Map map[string]Constructor
|
||||
|
||||
func Register(name string, init Constructor) {
|
||||
if Map == nil {
|
||||
Map = make(map[string]Constructor)
|
||||
}
|
||||
Map[name] = init
|
||||
}
|
||||
140
core/agents/lastfm.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils/lastfm"
|
||||
)
|
||||
|
||||
const lastFMAgentName = "lastfm"
|
||||
|
||||
type lastfmAgent struct {
|
||||
ctx context.Context
|
||||
apiKey string
|
||||
lang string
|
||||
client *lastfm.Client
|
||||
}
|
||||
|
||||
func lastFMConstructor(ctx context.Context) Interface {
|
||||
l := &lastfmAgent{
|
||||
ctx: ctx,
|
||||
apiKey: conf.Server.LastFM.ApiKey,
|
||||
lang: conf.Server.LastFM.Language,
|
||||
}
|
||||
hc := NewCachedHTTPClient(http.DefaultClient, consts.DefaultCachedHttpClientTTL)
|
||||
l.client = lastfm.NewClient(l.apiKey, l.lang, hc)
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) AgentName() string {
|
||||
return lastFMAgentName
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) GetMBID(id string, name string) (string, error) {
|
||||
a, err := l.callArtistGetInfo(name, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if a.MBID == "" {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return a.MBID, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) GetURL(id, name, mbid string) (string, error) {
|
||||
a, err := l.callArtistGetInfo(name, mbid)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if a.URL == "" {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return a.URL, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) GetBiography(id, name, mbid string) (string, error) {
|
||||
a, err := l.callArtistGetInfo(name, mbid)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if a.Bio.Summary == "" {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return a.Bio.Summary, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) GetSimilar(id, name, mbid string, limit int) ([]Artist, error) {
|
||||
resp, err := l.callArtistGetSimilar(name, mbid, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp) == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
var res []Artist
|
||||
for _, a := range resp {
|
||||
res = append(res, Artist{
|
||||
Name: a.Name,
|
||||
MBID: a.MBID,
|
||||
})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) GetTopSongs(id, artistName, mbid string, count int) ([]Song, error) {
|
||||
resp, err := l.callArtistGetTopTracks(artistName, mbid, count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp) == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
var res []Song
|
||||
for _, t := range resp {
|
||||
res = append(res, Song{
|
||||
Name: t.Name,
|
||||
MBID: t.MBID,
|
||||
})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) callArtistGetInfo(name string, mbid string) (*lastfm.Artist, error) {
|
||||
a, err := l.client.ArtistGetInfo(l.ctx, name, mbid)
|
||||
if err != nil {
|
||||
log.Error(l.ctx, "Error calling LastFM/artist.getInfo", "artist", name, "mbid", mbid, err)
|
||||
return nil, err
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) callArtistGetSimilar(name string, mbid string, limit int) ([]lastfm.Artist, error) {
|
||||
s, err := l.client.ArtistGetSimilar(l.ctx, name, mbid, limit)
|
||||
if err != nil {
|
||||
log.Error(l.ctx, "Error calling LastFM/artist.getSimilar", "artist", name, "mbid", mbid, err)
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) callArtistGetTopTracks(artistName, mbid string, count int) ([]lastfm.Track, error) {
|
||||
t, err := l.client.ArtistGetTopTracks(l.ctx, artistName, mbid, count)
|
||||
if err != nil {
|
||||
log.Error(l.ctx, "Error calling LastFM/artist.getTopTracks", "artist", artistName, "mbid", mbid, err)
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
if conf.Server.LastFM.ApiKey != "" {
|
||||
log.Info("Last.FM integration is ENABLED")
|
||||
Register(lastFMAgentName, lastFMConstructor)
|
||||
}
|
||||
})
|
||||
}
|
||||
40
core/agents/placeholders.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const PlaceholderAgentName = "placeholder"
|
||||
|
||||
const (
|
||||
placeholderArtistImageSmallUrl = "https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png"
|
||||
placeholderArtistImageMediumUrl = "https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png"
|
||||
placeholderArtistImageLargeUrl = "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png"
|
||||
placeholderBiography = "Biography not available"
|
||||
)
|
||||
|
||||
type placeholderAgent struct{}
|
||||
|
||||
func placeholdersConstructor(ctx context.Context) Interface {
|
||||
return &placeholderAgent{}
|
||||
}
|
||||
|
||||
func (p *placeholderAgent) AgentName() string {
|
||||
return PlaceholderAgentName
|
||||
}
|
||||
|
||||
func (p *placeholderAgent) GetBiography(id, name, mbid string) (string, error) {
|
||||
return placeholderBiography, nil
|
||||
}
|
||||
|
||||
func (p *placeholderAgent) GetImages(id, name, mbid string) ([]ArtistImage, error) {
|
||||
return []ArtistImage{
|
||||
{placeholderArtistImageLargeUrl, 300},
|
||||
{placeholderArtistImageMediumUrl, 174},
|
||||
{placeholderArtistImageSmallUrl, 64},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(PlaceholderAgentName, placeholdersConstructor)
|
||||
}
|
||||
91
core/agents/spotify.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/spotify"
|
||||
"github.com/xrash/smetrics"
|
||||
)
|
||||
|
||||
const spotifyAgentName = "spotify"
|
||||
|
||||
type spotifyAgent struct {
|
||||
ctx context.Context
|
||||
id string
|
||||
secret string
|
||||
client *spotify.Client
|
||||
}
|
||||
|
||||
func spotifyConstructor(ctx context.Context) Interface {
|
||||
l := &spotifyAgent{
|
||||
ctx: ctx,
|
||||
id: conf.Server.Spotify.ID,
|
||||
secret: conf.Server.Spotify.Secret,
|
||||
}
|
||||
hc := NewCachedHTTPClient(http.DefaultClient, consts.DefaultCachedHttpClientTTL)
|
||||
l.client = spotify.NewClient(l.id, l.secret, hc)
|
||||
return l
|
||||
}
|
||||
|
||||
func (s *spotifyAgent) AgentName() string {
|
||||
return spotifyAgentName
|
||||
}
|
||||
|
||||
func (s *spotifyAgent) GetImages(id, name, mbid string) ([]ArtistImage, error) {
|
||||
a, err := s.searchArtist(name)
|
||||
if err != nil {
|
||||
if err == model.ErrNotFound {
|
||||
log.Warn(s.ctx, "Artist not found in Spotify", "artist", name)
|
||||
} else {
|
||||
log.Error(s.ctx, "Error calling Spotify", "artist", name, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var res []ArtistImage
|
||||
for _, img := range a.Images {
|
||||
res = append(res, ArtistImage{
|
||||
URL: img.URL,
|
||||
Size: img.Width,
|
||||
})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *spotifyAgent) searchArtist(name string) (*spotify.Artist, error) {
|
||||
artists, err := s.client.SearchArtists(s.ctx, name, 40)
|
||||
if err != nil || len(artists) == 0 {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
name = strings.ToLower(name)
|
||||
|
||||
// Sort results, prioritizing artists with images, with similar names and with high popularity, in this order
|
||||
sort.Slice(artists, func(i, j int) bool {
|
||||
ai := fmt.Sprintf("%-5t-%03d-%04d", len(artists[i].Images) == 0, smetrics.WagnerFischer(name, strings.ToLower(artists[i].Name), 1, 1, 2), 1000-artists[i].Popularity)
|
||||
aj := fmt.Sprintf("%-5t-%03d-%04d", len(artists[j].Images) == 0, smetrics.WagnerFischer(name, strings.ToLower(artists[j].Name), 1, 1, 2), 1000-artists[j].Popularity)
|
||||
return ai < aj
|
||||
})
|
||||
|
||||
// If the first one has the same name, that's the one
|
||||
if strings.ToLower(artists[0].Name) != name {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return &artists[0], err
|
||||
}
|
||||
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
if conf.Server.Spotify.ID != "" && conf.Server.Spotify.Secret != "" {
|
||||
log.Info("Spotify integration is ENABLED")
|
||||
Register(spotifyAgentName, spotifyConstructor)
|
||||
}
|
||||
})
|
||||
}
|
||||
110
core/archiver.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type Archiver interface {
|
||||
ZipAlbum(ctx context.Context, id string, w io.Writer) error
|
||||
ZipArtist(ctx context.Context, id string, w io.Writer) error
|
||||
ZipPlaylist(ctx context.Context, id string, w io.Writer) error
|
||||
}
|
||||
|
||||
func NewArchiver(ds model.DataStore) Archiver {
|
||||
return &archiver{ds: ds}
|
||||
}
|
||||
|
||||
type archiver struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
type createHeader func(idx int, mf model.MediaFile) *zip.FileHeader
|
||||
|
||||
func (a *archiver) ZipAlbum(ctx context.Context, id string, out io.Writer) error {
|
||||
mfs, err := a.ds.MediaFile(ctx).FindByAlbum(id)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading mediafiles from album", "id", id, err)
|
||||
return err
|
||||
}
|
||||
return a.zipTracks(ctx, id, out, mfs, a.createHeader)
|
||||
}
|
||||
|
||||
func (a *archiver) ZipArtist(ctx context.Context, id string, out io.Writer) error {
|
||||
mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Sort: "album",
|
||||
Filters: squirrel.Eq{"album_artist_id": id},
|
||||
})
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading mediafiles from artist", "id", id, err)
|
||||
return err
|
||||
}
|
||||
return a.zipTracks(ctx, id, out, mfs, a.createHeader)
|
||||
}
|
||||
|
||||
func (a *archiver) ZipPlaylist(ctx context.Context, id string, out io.Writer) error {
|
||||
pls, err := a.ds.Playlist(ctx).Get(id)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading mediafiles from playlist", "id", id, err)
|
||||
return err
|
||||
}
|
||||
return a.zipTracks(ctx, id, out, pls.Tracks, a.createPlaylistHeader)
|
||||
}
|
||||
|
||||
func (a *archiver) zipTracks(ctx context.Context, id string, out io.Writer, mfs model.MediaFiles, ch createHeader) error {
|
||||
z := zip.NewWriter(out)
|
||||
for idx, mf := range mfs {
|
||||
_ = a.addFileToZip(ctx, z, mf, ch(idx, mf))
|
||||
}
|
||||
err := z.Close()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error closing zip file", "id", id, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *archiver) createHeader(idx int, mf model.MediaFile) *zip.FileHeader {
|
||||
_, file := filepath.Split(mf.Path)
|
||||
return &zip.FileHeader{
|
||||
Name: fmt.Sprintf("%s/%s", mf.Album, file),
|
||||
Modified: mf.UpdatedAt,
|
||||
Method: zip.Store,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *archiver) createPlaylistHeader(idx int, mf model.MediaFile) *zip.FileHeader {
|
||||
_, file := filepath.Split(mf.Path)
|
||||
return &zip.FileHeader{
|
||||
Name: fmt.Sprintf("%d - %s-%s", idx, mf.AlbumArtist, file),
|
||||
Modified: mf.UpdatedAt,
|
||||
Method: zip.Store,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, zh *zip.FileHeader) error {
|
||||
w, err := z.CreateHeader(zh)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error creating zip entry", "file", mf.Path, err)
|
||||
return err
|
||||
}
|
||||
f, err := os.Open(mf.Path)
|
||||
defer func() { _ = f.Close() }()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error opening file for zipping", "file", mf.Path, err)
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(w, f)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error zipping file", "file", mf.Path, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
221
core/artwork.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
"image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dhowden/tag"
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
type Artwork interface {
|
||||
Get(ctx context.Context, id string, size int) (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
type ArtworkCache cache.FileCache
|
||||
|
||||
func NewArtwork(ds model.DataStore, cache ArtworkCache) Artwork {
|
||||
return &artwork{ds: ds, cache: cache}
|
||||
}
|
||||
|
||||
type artwork struct {
|
||||
ds model.DataStore
|
||||
cache cache.FileCache
|
||||
}
|
||||
|
||||
type imageInfo struct {
|
||||
a *artwork
|
||||
id string
|
||||
path string
|
||||
size int
|
||||
lastUpdate time.Time
|
||||
}
|
||||
|
||||
func (ci *imageInfo) Key() string {
|
||||
return fmt.Sprintf("%s.%d.%s.%d", ci.path, ci.size, ci.lastUpdate.Format(time.RFC3339Nano), conf.Server.CoverJpegQuality)
|
||||
}
|
||||
|
||||
func (a *artwork) Get(ctx context.Context, id string, size int) (io.ReadCloser, error) {
|
||||
path, lastUpdate, err := a.getImagePath(ctx, id)
|
||||
if err != nil && err != model.ErrNotFound {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !conf.Server.DevFastAccessCoverArt {
|
||||
if stat, err := os.Stat(path); err == nil {
|
||||
lastUpdate = stat.ModTime()
|
||||
}
|
||||
}
|
||||
|
||||
info := &imageInfo{
|
||||
a: a,
|
||||
id: id,
|
||||
path: path,
|
||||
size: size,
|
||||
lastUpdate: lastUpdate,
|
||||
}
|
||||
|
||||
r, err := a.cache.Get(ctx, info)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error accessing image cache", "path", path, "size", size, err)
|
||||
return nil, err
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
func (a *artwork) getImagePath(ctx context.Context, id string) (path string, lastUpdated time.Time, err error) {
|
||||
// If id is an album cover ID
|
||||
if strings.HasPrefix(id, "al-") {
|
||||
log.Trace(ctx, "Looking for album art", "id", id)
|
||||
id = strings.TrimPrefix(id, "al-")
|
||||
var al *model.Album
|
||||
al, err = a.ds.Album(ctx).Get(id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if al.CoverArtId == "" {
|
||||
err = model.ErrNotFound
|
||||
}
|
||||
return al.CoverArtPath, al.UpdatedAt, err
|
||||
}
|
||||
|
||||
log.Trace(ctx, "Looking for media file art", "id", id)
|
||||
|
||||
// Check if id is a mediaFile id
|
||||
var mf *model.MediaFile
|
||||
mf, err = a.ds.MediaFile(ctx).Get(id)
|
||||
|
||||
// If it is not, may be an albumId
|
||||
if err == model.ErrNotFound {
|
||||
return a.getImagePath(ctx, "al-"+id)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// If it is a mediaFile and it has cover art, return it (if feature is disabled, skip)
|
||||
if !conf.Server.DevFastAccessCoverArt && mf.HasCoverArt {
|
||||
return mf.Path, mf.UpdatedAt, nil
|
||||
}
|
||||
|
||||
// if the mediaFile does not have a coverArt, fallback to the album cover
|
||||
log.Trace(ctx, "Media file does not contain art. Falling back to album art", "id", id, "albumId", "al-"+mf.AlbumID)
|
||||
return a.getImagePath(ctx, "al-"+mf.AlbumID)
|
||||
}
|
||||
|
||||
func (a *artwork) getArtwork(ctx context.Context, id string, path string, size int) (reader io.ReadCloser, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error extracting image", "path", path, "size", size, err)
|
||||
reader, err = resources.Assets().Open(consts.PlaceholderAlbumArt)
|
||||
}
|
||||
}()
|
||||
|
||||
if path == "" {
|
||||
return nil, errors.New("empty path given for artwork")
|
||||
}
|
||||
|
||||
if size == 0 {
|
||||
// If requested original size, just read from the file
|
||||
if utils.IsAudioFile(path) {
|
||||
reader, err = readFromTag(path)
|
||||
} else {
|
||||
reader, err = readFromFile(path)
|
||||
}
|
||||
} else {
|
||||
// If requested a resized image, get the original (possibly from cache) and resize it
|
||||
var r io.ReadCloser
|
||||
r, err = a.Get(ctx, id, 0)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer r.Close()
|
||||
reader, err = resizeImage(r, size)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func resizeImage(reader io.Reader, size int) (io.ReadCloser, error) {
|
||||
img, _, err := image.Decode(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Preserve the aspect ratio of the image.
|
||||
var m *image.NRGBA
|
||||
bounds := img.Bounds()
|
||||
if bounds.Max.X > bounds.Max.Y {
|
||||
m = imaging.Resize(img, size, 0, imaging.Lanczos)
|
||||
} else {
|
||||
m = imaging.Resize(img, 0, size, imaging.Lanczos)
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
err = jpeg.Encode(buf, m, &jpeg.Options{Quality: conf.Server.CoverJpegQuality})
|
||||
return ioutil.NopCloser(buf), err
|
||||
}
|
||||
|
||||
func readFromTag(path string) (io.ReadCloser, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
m, err := tag.ReadFrom(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
picture := m.Picture()
|
||||
if picture == nil {
|
||||
return nil, errors.New("file does not contain embedded art")
|
||||
}
|
||||
return ioutil.NopCloser(bytes.NewReader(picture.Data)), nil
|
||||
}
|
||||
|
||||
func readFromFile(path string) (io.ReadCloser, error) {
|
||||
return os.Open(path)
|
||||
}
|
||||
|
||||
var (
|
||||
onceImageCache sync.Once
|
||||
instanceImageCache ArtworkCache
|
||||
)
|
||||
|
||||
func GetImageCache() ArtworkCache {
|
||||
onceImageCache.Do(func() {
|
||||
instanceImageCache = cache.NewFileCache("Image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems,
|
||||
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
|
||||
info := arg.(*imageInfo)
|
||||
reader, err := info.a.getArtwork(ctx, info.id, info.path, info.size)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading artwork art", "path", info.path, "size", info.size, err)
|
||||
return nil, err
|
||||
}
|
||||
return reader, nil
|
||||
})
|
||||
})
|
||||
return instanceImageCache
|
||||
}
|
||||
141
core/artwork_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"image"
|
||||
"io/ioutil"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Artwork", func() {
|
||||
var artwork Artwork
|
||||
var ds model.DataStore
|
||||
ctx := log.NewContext(context.TODO())
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepository{}}
|
||||
ds.Album(ctx).(*tests.MockAlbum).SetData(model.Albums{
|
||||
{ID: "222", CoverArtId: "123", CoverArtPath: "tests/fixtures/test.mp3"},
|
||||
{ID: "333", CoverArtId: ""},
|
||||
{ID: "444", CoverArtId: "444", CoverArtPath: "tests/fixtures/cover.jpg"},
|
||||
})
|
||||
ds.MediaFile(ctx).(*tests.MockMediaFile).SetData(model.MediaFiles{
|
||||
{ID: "123", AlbumID: "222", Path: "tests/fixtures/test.mp3", HasCoverArt: true},
|
||||
{ID: "456", AlbumID: "222", Path: "tests/fixtures/test.ogg", HasCoverArt: false},
|
||||
})
|
||||
})
|
||||
|
||||
Context("Cache is configured", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DataFolder, _ = ioutil.TempDir("", "file_caches")
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
cache := GetImageCache()
|
||||
Eventually(func() bool { return cache.Ready(context.TODO()) }).Should(BeTrue())
|
||||
artwork = NewArtwork(ds, cache)
|
||||
})
|
||||
|
||||
It("retrieves the external artwork art for an album", func() {
|
||||
r, err := artwork.Get(ctx, "al-444", 0)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(r)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(r.Close()).To(BeNil())
|
||||
})
|
||||
|
||||
It("retrieves the embedded artwork art for an album", func() {
|
||||
r, err := artwork.Get(ctx, "al-222", 0)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(r)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(r.Close()).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns the default artwork if album does not have artwork", func() {
|
||||
r, err := artwork.Get(ctx, "al-333", 0)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(r)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("png"))
|
||||
Expect(r.Close()).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns the default artwork if album is not found", func() {
|
||||
r, err := artwork.Get(ctx, "al-0101", 0)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(r)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("png"))
|
||||
Expect(r.Close()).To(BeNil())
|
||||
})
|
||||
|
||||
It("retrieves the original artwork art from a media_file", func() {
|
||||
r, err := artwork.Get(ctx, "123", 0)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(600))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(600))
|
||||
Expect(r.Close()).To(BeNil())
|
||||
})
|
||||
|
||||
It("retrieves the album artwork art if media_file does not have one", func() {
|
||||
r, err := artwork.Get(ctx, "456", 0)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(r)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(r.Close()).To(BeNil())
|
||||
})
|
||||
|
||||
It("retrieves the album artwork by album id", func() {
|
||||
r, err := artwork.Get(ctx, "222", 0)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(r)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(r.Close()).To(BeNil())
|
||||
})
|
||||
|
||||
It("resized artwork art as requested", func() {
|
||||
r, err := artwork.Get(ctx, "123", 200)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
Expect(r.Close()).To(BeNil())
|
||||
})
|
||||
|
||||
Context("Errors", func() {
|
||||
It("returns err if gets error from album table", func() {
|
||||
ds.Album(ctx).(*tests.MockAlbum).SetError(true)
|
||||
_, err := artwork.Get(ctx, "al-222", 0)
|
||||
Expect(err).To(MatchError("Error!"))
|
||||
})
|
||||
|
||||
It("returns err if gets error from media_file table", func() {
|
||||
ds.MediaFile(ctx).(*tests.MockMediaFile).SetError(true)
|
||||
_, err := artwork.Get(ctx, "123", 0)
|
||||
Expect(err).To(MatchError("Error!"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,12 +6,12 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/go-chi/jwtauth"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/core/auth"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
93
core/cache_warmer.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils/pool"
|
||||
)
|
||||
|
||||
type CacheWarmer interface {
|
||||
AddAlbum(ctx context.Context, albumID string)
|
||||
Flush(ctx context.Context)
|
||||
}
|
||||
|
||||
func NewCacheWarmer(artwork Artwork, artworkCache ArtworkCache) CacheWarmer {
|
||||
w := &warmer{
|
||||
artwork: artwork,
|
||||
artworkCache: artworkCache,
|
||||
albums: map[string]struct{}{},
|
||||
}
|
||||
p, err := pool.NewPool("artwork", 3, w.execute)
|
||||
if err != nil {
|
||||
log.Error(context.Background(), "Error creating pool for Album Artwork Cache Warmer", err)
|
||||
} else {
|
||||
w.pool = p
|
||||
}
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
type warmer struct {
|
||||
pool *pool.Pool
|
||||
artwork Artwork
|
||||
artworkCache ArtworkCache
|
||||
albums map[string]struct{}
|
||||
}
|
||||
|
||||
func (w *warmer) AddAlbum(ctx context.Context, albumID string) {
|
||||
if albumID == "" {
|
||||
return
|
||||
}
|
||||
w.albums[albumID] = struct{}{}
|
||||
}
|
||||
|
||||
func (w *warmer) waitForCacheReady(ctx context.Context) {
|
||||
tick := time.NewTicker(time.Second)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
<-tick.C
|
||||
if w.artworkCache.Ready(ctx) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *warmer) Flush(ctx context.Context) {
|
||||
if conf.Server.DevPreCacheAlbumArtwork {
|
||||
w.waitForCacheReady(ctx)
|
||||
if w.artworkCache.Available(ctx) {
|
||||
if w.pool == nil || len(w.albums) == 0 {
|
||||
return
|
||||
}
|
||||
log.Info(ctx, "Pre-caching album artworks", "numAlbums", len(w.albums))
|
||||
for id := range w.albums {
|
||||
w.pool.Submit(artworkItem{albumID: id})
|
||||
}
|
||||
} else {
|
||||
log.Warn(ctx, "Cache warmer is not available as ImageCache is DISABLED")
|
||||
}
|
||||
}
|
||||
w.albums = map[string]struct{}{}
|
||||
}
|
||||
|
||||
func (w *warmer) execute(workload interface{}) {
|
||||
ctx := context.Background()
|
||||
item := workload.(artworkItem)
|
||||
log.Trace(ctx, "Pre-caching album artwork", "albumID", item.albumID)
|
||||
r, err := w.artwork.Get(ctx, item.albumID, 0)
|
||||
if err != nil {
|
||||
log.Warn("Error pre-caching artwork from album", "id", item.albumID, err)
|
||||
return
|
||||
}
|
||||
defer r.Close()
|
||||
_, _ = io.Copy(ioutil.Discard, r)
|
||||
}
|
||||
|
||||
type artworkItem struct {
|
||||
albumID string
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package core
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
)
|
||||
|
||||
func userName(ctx context.Context) string {
|
||||
|
||||
@@ -1,35 +1,17 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/tests"
|
||||
"github.com/djherbis/fscache"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestEngine(t *testing.T) {
|
||||
func TestCore(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelCritical)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Core Suite")
|
||||
}
|
||||
|
||||
var testCache fscache.Cache
|
||||
var testCacheDir string
|
||||
|
||||
var _ = Describe("Core Suite Setup", func() {
|
||||
BeforeSuite(func() {
|
||||
testCacheDir, _ = ioutil.TempDir("", "core_test_cache")
|
||||
fs, _ := fscache.NewFs(testCacheDir, 0755)
|
||||
testCache, _ = fscache.NewCache(fs, nil)
|
||||
})
|
||||
|
||||
AfterSuite(func() {
|
||||
os.RemoveAll(testCacheDir)
|
||||
})
|
||||
})
|
||||
|
||||
206
core/cover.go
@@ -1,206 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
"image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/resources"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
"github.com/dhowden/tag"
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/djherbis/fscache"
|
||||
)
|
||||
|
||||
type Cover interface {
|
||||
Get(ctx context.Context, id string, size int, out io.Writer) error
|
||||
}
|
||||
|
||||
type ImageCache fscache.Cache
|
||||
|
||||
func NewCover(ds model.DataStore, cache ImageCache) Cover {
|
||||
return &cover{ds: ds, cache: cache}
|
||||
}
|
||||
|
||||
type cover struct {
|
||||
ds model.DataStore
|
||||
cache fscache.Cache
|
||||
}
|
||||
|
||||
func (c *cover) Get(ctx context.Context, id string, size int, out io.Writer) error {
|
||||
path, lastUpdate, err := c.getCoverPath(ctx, id)
|
||||
if err != nil && err != model.ErrNotFound {
|
||||
return err
|
||||
}
|
||||
|
||||
// If cache is disabled, just read the coverart directly from file
|
||||
if c.cache == nil {
|
||||
log.Trace(ctx, "Retrieving cover art from file", "path", path, "size", size, err)
|
||||
reader, err := c.getCover(ctx, path, size)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading cover art", "path", path, "size", size, err)
|
||||
} else {
|
||||
_, err = io.Copy(out, reader)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
cacheKey := imageCacheKey(path, size, lastUpdate)
|
||||
r, w, err := c.cache.Get(cacheKey)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error reading from image cache", "path", path, "size", size, err)
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
if w != nil {
|
||||
log.Trace(ctx, "Image cache miss", "path", path, "size", size, "lastUpdate", lastUpdate)
|
||||
go func() {
|
||||
defer w.Close()
|
||||
reader, err := c.getCover(ctx, path, size)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading cover art", "path", path, "size", size, err)
|
||||
return
|
||||
}
|
||||
if _, err := io.Copy(w, reader); err != nil {
|
||||
log.Error(ctx, "Error saving covert art to cache", "path", path, "size", size, err)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
log.Trace(ctx, "Loading image from cache", "path", path, "size", size, "lastUpdate", lastUpdate)
|
||||
}
|
||||
|
||||
_, err = io.Copy(out, r)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *cover) getCoverPath(ctx context.Context, id string) (path string, lastUpdated time.Time, err error) {
|
||||
// If id is an album cover ID
|
||||
if strings.HasPrefix(id, "al-") {
|
||||
log.Trace(ctx, "Looking for album art", "id", id)
|
||||
id = strings.TrimPrefix(id, "al-")
|
||||
var al *model.Album
|
||||
al, err = c.ds.Album(ctx).Get(id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if al.CoverArtId == "" {
|
||||
err = model.ErrNotFound
|
||||
}
|
||||
return al.CoverArtPath, al.UpdatedAt, err
|
||||
}
|
||||
|
||||
log.Trace(ctx, "Looking for media file art", "id", id)
|
||||
// if id is a mediafile cover id
|
||||
var mf *model.MediaFile
|
||||
mf, err = c.ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if mf.HasCoverArt {
|
||||
return mf.Path, mf.UpdatedAt, nil
|
||||
}
|
||||
|
||||
// if the mediafile does not have a coverArt, fallback to the album cover
|
||||
log.Trace(ctx, "Media file does not contain art. Falling back to album art", "id", id, "albumId", "al-"+mf.AlbumID)
|
||||
return c.getCoverPath(ctx, "al-"+mf.AlbumID)
|
||||
}
|
||||
|
||||
func imageCacheKey(path string, size int, lastUpdate time.Time) string {
|
||||
return fmt.Sprintf("%s.%d.%s.%d", path, size, lastUpdate.Format(time.RFC3339Nano), conf.Server.CoverJpegQuality)
|
||||
}
|
||||
|
||||
func (c *cover) getCover(ctx context.Context, path string, size int) (reader io.Reader, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error extracting image", "path", path, "size", size, err)
|
||||
reader, err = resources.AssetFile().Open(consts.PlaceholderAlbumArt)
|
||||
}
|
||||
}()
|
||||
|
||||
if path == "" {
|
||||
return nil, errors.New("empty path given for cover")
|
||||
}
|
||||
|
||||
var data []byte
|
||||
if utils.IsAudioFile(path) {
|
||||
data, err = readFromTag(path)
|
||||
} else {
|
||||
data, err = readFromFile(path)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
} else if size > 0 {
|
||||
data, err = resizeImage(bytes.NewReader(data), size)
|
||||
}
|
||||
|
||||
// Confirm the image is valid. Costly, but necessary
|
||||
_, _, err = image.Decode(bytes.NewReader(data))
|
||||
if err == nil {
|
||||
reader = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func resizeImage(reader io.Reader, size int) ([]byte, error) {
|
||||
img, _, err := image.Decode(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := imaging.Resize(img, size, size, imaging.Lanczos)
|
||||
buf := new(bytes.Buffer)
|
||||
err = jpeg.Encode(buf, m, &jpeg.Options{Quality: conf.Server.CoverJpegQuality})
|
||||
return buf.Bytes(), err
|
||||
}
|
||||
|
||||
func readFromTag(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
m, err := tag.ReadFrom(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
picture := m.Picture()
|
||||
if picture == nil {
|
||||
return nil, errors.New("file does not contain embedded art")
|
||||
}
|
||||
return picture.Data, nil
|
||||
}
|
||||
|
||||
func readFromFile(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer f.Close()
|
||||
var buf bytes.Buffer
|
||||
if _, err := buf.ReadFrom(f); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func NewImageCache() (ImageCache, error) {
|
||||
return newFileCache("Image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems)
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Cover", func() {
|
||||
var cover Cover
|
||||
var ds model.DataStore
|
||||
ctx := log.NewContext(context.TODO())
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &persistence.MockDataStore{MockedTranscoding: &mockTranscodingRepository{}}
|
||||
ds.Album(ctx).(*persistence.MockAlbum).SetData(`[{"id": "222", "coverArtId": "123", "coverArtPath":"tests/fixtures/test.mp3"}, {"id": "333", "coverArtId": ""}, {"id": "444", "coverArtId": "444", "coverArtPath": "tests/fixtures/cover.jpg"}]`)
|
||||
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "albumId": "222", "path": "tests/fixtures/test.mp3", "hasCoverArt": true, "updatedAt":"2020-04-02T21:29:31.6377Z"},{"id": "456", "albumId": "222", "path": "tests/fixtures/test.ogg", "hasCoverArt": false, "updatedAt":"2020-04-02T21:29:31.6377Z"}]`)
|
||||
})
|
||||
|
||||
Context("Cache is configured", func() {
|
||||
BeforeEach(func() {
|
||||
cover = NewCover(ds, testCache)
|
||||
})
|
||||
|
||||
It("retrieves the external cover art for an album", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "al-444", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
})
|
||||
|
||||
It("retrieves the embedded cover art for an album", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "al-222", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
})
|
||||
|
||||
It("returns the default cover if album does not have cover", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "al-333", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("png"))
|
||||
})
|
||||
|
||||
It("returns the default cover if album is not found", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "al-0101", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("png"))
|
||||
})
|
||||
|
||||
It("retrieves the original cover art from a media_file", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "123", 0, buf)).To(BeNil())
|
||||
|
||||
img, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(600))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(600))
|
||||
})
|
||||
|
||||
It("retrieves the album cover art if media_file does not have one", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "456", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
})
|
||||
|
||||
It("resized cover art as requested", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "123", 200, buf)).To(BeNil())
|
||||
|
||||
img, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
|
||||
Context("Errors", func() {
|
||||
It("returns err if gets error from album table", func() {
|
||||
ds.Album(ctx).(*persistence.MockAlbum).SetError(true)
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "al-222", 0, buf)).To(MatchError("Error!"))
|
||||
})
|
||||
|
||||
It("returns err if gets error from media_file table", func() {
|
||||
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetError(true)
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "123", 0, buf)).To(MatchError("Error!"))
|
||||
})
|
||||
})
|
||||
})
|
||||
Context("Cache is NOT configured", func() {
|
||||
BeforeEach(func() {
|
||||
cover = NewCover(ds, nil)
|
||||
})
|
||||
|
||||
It("retrieves the original cover art from an album", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(cover.Get(ctx, "al-222", 0, buf)).To(BeNil())
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
})
|
||||
})
|
||||
})
|
||||
464
core/external_metadata.go
Normal file
@@ -0,0 +1,464 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
const unavailableArtistID = "-1"
|
||||
|
||||
type ExternalMetadata interface {
|
||||
UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error)
|
||||
SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error)
|
||||
TopSongs(ctx context.Context, artist string, count int) (model.MediaFiles, error)
|
||||
}
|
||||
|
||||
type externalMetadata struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
type auxArtist struct {
|
||||
model.Artist
|
||||
Name string
|
||||
}
|
||||
|
||||
func NewExternalMetadata(ds model.DataStore) ExternalMetadata {
|
||||
return &externalMetadata{ds: ds}
|
||||
}
|
||||
|
||||
func (e *externalMetadata) initAgents(ctx context.Context) []agents.Interface {
|
||||
order := strings.Split(conf.Server.Agents, ",")
|
||||
order = append(order, agents.PlaceholderAgentName)
|
||||
var res []agents.Interface
|
||||
for _, name := range order {
|
||||
init, ok := agents.Map[name]
|
||||
if !ok {
|
||||
log.Error(ctx, "Agent not available. Check configuration", "name", name)
|
||||
continue
|
||||
}
|
||||
|
||||
res = append(res, init(ctx))
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func (e *externalMetadata) getArtist(ctx context.Context, id string) (*auxArtist, error) {
|
||||
var entity interface{}
|
||||
entity, err := GetEntityByID(ctx, e.ds, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var artist auxArtist
|
||||
switch v := entity.(type) {
|
||||
case *model.Artist:
|
||||
artist.Artist = *v
|
||||
artist.Name = clearName(v.Name)
|
||||
case *model.MediaFile:
|
||||
return e.getArtist(ctx, v.ArtistID)
|
||||
case *model.Album:
|
||||
return e.getArtist(ctx, v.AlbumArtistID)
|
||||
default:
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return &artist, nil
|
||||
}
|
||||
|
||||
// Replace some Unicode chars with their equivalent ASCII
|
||||
func clearName(name string) string {
|
||||
name = strings.ReplaceAll(name, "–", "-")
|
||||
name = strings.ReplaceAll(name, "‐", "-")
|
||||
name = strings.ReplaceAll(name, "“", `"`)
|
||||
name = strings.ReplaceAll(name, "”", `"`)
|
||||
name = strings.ReplaceAll(name, "‘", `'`)
|
||||
name = strings.ReplaceAll(name, "’", `'`)
|
||||
return name
|
||||
}
|
||||
|
||||
func (e *externalMetadata) UpdateArtistInfo(ctx context.Context, id string, similarCount int, includeNotPresent bool) (*model.Artist, error) {
|
||||
allAgents := e.initAgents(ctx)
|
||||
artist, err := e.getArtist(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If we have fresh info, just return it
|
||||
if time.Since(artist.ExternalInfoUpdatedAt) < consts.ArtistInfoTimeToLive {
|
||||
log.Debug("Found cached ArtistInfo", "updatedAt", artist.ExternalInfoUpdatedAt, "name", artist.Name)
|
||||
err := e.loadSimilar(ctx, artist, includeNotPresent)
|
||||
return &artist.Artist, err
|
||||
}
|
||||
log.Debug(ctx, "ArtistInfo not cached or expired", "updatedAt", artist.ExternalInfoUpdatedAt, "id", id, "name", artist.Name)
|
||||
|
||||
// Get MBID first, if it is not yet available
|
||||
if artist.MbzArtistID == "" {
|
||||
e.callGetMBID(ctx, allAgents, artist)
|
||||
}
|
||||
|
||||
// Call all registered agents and collect information
|
||||
wg := &sync.WaitGroup{}
|
||||
e.callGetBiography(ctx, allAgents, artist, wg)
|
||||
e.callGetURL(ctx, allAgents, artist, wg)
|
||||
e.callGetImage(ctx, allAgents, artist, wg)
|
||||
e.callGetSimilar(ctx, allAgents, artist, similarCount, wg)
|
||||
wg.Wait()
|
||||
|
||||
if isDone(ctx) {
|
||||
log.Warn(ctx, "ArtistInfo update canceled", ctx.Err())
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
artist.ExternalInfoUpdatedAt = time.Now()
|
||||
err = e.ds.Artist(ctx).Put(&artist.Artist)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error trying to update artist external information", "id", id, "name", artist.Name, err)
|
||||
}
|
||||
|
||||
if !includeNotPresent {
|
||||
similar := artist.SimilarArtists
|
||||
artist.SimilarArtists = nil
|
||||
for _, s := range similar {
|
||||
if s.ID == unavailableArtistID {
|
||||
continue
|
||||
}
|
||||
artist.SimilarArtists = append(artist.SimilarArtists, s)
|
||||
}
|
||||
}
|
||||
|
||||
log.Trace(ctx, "ArtistInfo collected", "artist", artist)
|
||||
return &artist.Artist, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) {
|
||||
allAgents := e.initAgents(ctx)
|
||||
artist, err := e.getArtist(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
wg := &sync.WaitGroup{}
|
||||
e.callGetSimilar(ctx, allAgents, artist, count, wg)
|
||||
wg.Wait()
|
||||
|
||||
if isDone(ctx) {
|
||||
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
if len(artist.SimilarArtists) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var ids = []string{artist.ID}
|
||||
for _, a := range artist.SimilarArtists {
|
||||
if a.ID != unavailableArtistID {
|
||||
ids = append(ids, a.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"artist_id": ids},
|
||||
Max: count,
|
||||
Sort: "random()",
|
||||
})
|
||||
}
|
||||
|
||||
func (e *externalMetadata) TopSongs(ctx context.Context, artistName string, count int) (model.MediaFiles, error) {
|
||||
allAgents := e.initAgents(ctx)
|
||||
artist, err := e.findArtistByName(ctx, artistName)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Artist not found", "name", artistName, err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
songs, err := e.callGetTopSongs(ctx, allAgents, artist, count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var mfs model.MediaFiles
|
||||
for _, t := range songs {
|
||||
mf, err := e.findMatchingTrack(ctx, t.MBID, artist.ID, t.Name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
mfs = append(mfs, *mf)
|
||||
}
|
||||
return mfs, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, artistID, title string) (*model.MediaFile, error) {
|
||||
if mbid != "" {
|
||||
mfs, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"mbz_track_id": mbid},
|
||||
})
|
||||
if err == nil && len(mfs) > 0 {
|
||||
return &mfs[0], nil
|
||||
}
|
||||
}
|
||||
mfs, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Or{
|
||||
squirrel.Eq{"artist_id": artistID},
|
||||
squirrel.Eq{"album_artist_id": artistID},
|
||||
},
|
||||
squirrel.Like{"title": title},
|
||||
},
|
||||
Sort: "starred desc, rating desc, year asc",
|
||||
})
|
||||
if err != nil || len(mfs) == 0 {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return &mfs[0], nil
|
||||
}
|
||||
|
||||
func isDone(ctx context.Context) bool {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (e *externalMetadata) callGetMBID(ctx context.Context, allAgents []agents.Interface, artist *auxArtist) {
|
||||
start := time.Now()
|
||||
for _, a := range allAgents {
|
||||
if isDone(ctx) {
|
||||
break
|
||||
}
|
||||
agent, ok := a.(agents.ArtistMBIDRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
mbid, err := agent.GetMBID(artist.ID, artist.Name)
|
||||
if mbid != "" && err == nil {
|
||||
artist.MbzArtistID = mbid
|
||||
log.Debug(ctx, "Got MBID", "agent", a.AgentName(), "artist", artist.Name, "mbid", mbid, "elapsed", time.Since(start))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *externalMetadata) callGetTopSongs(ctx context.Context, allAgents []agents.Interface, artist *auxArtist,
|
||||
count int) ([]agents.Song, error) {
|
||||
start := time.Now()
|
||||
for _, a := range allAgents {
|
||||
if isDone(ctx) {
|
||||
break
|
||||
}
|
||||
agent, ok := a.(agents.ArtistTopSongsRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
songs, err := agent.GetTopSongs(artist.ID, artist.Name, artist.MbzArtistID, count)
|
||||
if len(songs) > 0 && err == nil {
|
||||
log.Debug(ctx, "Got Top Songs", "agent", a.AgentName(), "artist", artist.Name, "songs", songs, "elapsed", time.Since(start))
|
||||
return songs, err
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) callGetURL(ctx context.Context, allAgents []agents.Interface, artist *auxArtist, wg *sync.WaitGroup) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
start := time.Now()
|
||||
for _, a := range allAgents {
|
||||
if isDone(ctx) {
|
||||
break
|
||||
}
|
||||
agent, ok := a.(agents.ArtistURLRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
url, err := agent.GetURL(artist.ID, artist.Name, artist.MbzArtistID)
|
||||
if url != "" && err == nil {
|
||||
artist.ExternalUrl = url
|
||||
log.Debug(ctx, "Got External Url", "agent", a.AgentName(), "artist", artist.Name, "url", url, "elapsed", time.Since(start))
|
||||
break
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (e *externalMetadata) callGetBiography(ctx context.Context, allAgents []agents.Interface, artist *auxArtist, wg *sync.WaitGroup) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
start := time.Now()
|
||||
for _, a := range allAgents {
|
||||
if isDone(ctx) {
|
||||
break
|
||||
}
|
||||
agent, ok := a.(agents.ArtistBiographyRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
bio, err := agent.GetBiography(artist.ID, clearName(artist.Name), artist.MbzArtistID)
|
||||
if bio != "" && err == nil {
|
||||
policy := bluemonday.UGCPolicy()
|
||||
bio = policy.Sanitize(bio)
|
||||
bio = strings.ReplaceAll(bio, "\n", " ")
|
||||
artist.Biography = strings.ReplaceAll(bio, "<a ", "<a target='_blank' ")
|
||||
log.Debug(ctx, "Got Biography", "agent", a.AgentName(), "artist", artist.Name, "len", len(bio), "elapsed", time.Since(start))
|
||||
break
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (e *externalMetadata) callGetImage(ctx context.Context, allAgents []agents.Interface, artist *auxArtist, wg *sync.WaitGroup) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
start := time.Now()
|
||||
for _, a := range allAgents {
|
||||
if isDone(ctx) {
|
||||
break
|
||||
}
|
||||
agent, ok := a.(agents.ArtistImageRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
images, err := agent.GetImages(artist.ID, artist.Name, artist.MbzArtistID)
|
||||
if len(images) == 0 || err != nil {
|
||||
continue
|
||||
}
|
||||
log.Debug(ctx, "Got Images", "agent", a.AgentName(), "artist", artist.Name, "images", images, "elapsed", time.Since(start))
|
||||
sort.Slice(images, func(i, j int) bool { return images[i].Size > images[j].Size })
|
||||
if len(images) >= 1 {
|
||||
artist.LargeImageUrl = images[0].URL
|
||||
}
|
||||
if len(images) >= 2 {
|
||||
artist.MediumImageUrl = images[1].URL
|
||||
}
|
||||
if len(images) >= 3 {
|
||||
artist.SmallImageUrl = images[2].URL
|
||||
}
|
||||
break
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (e *externalMetadata) callGetSimilar(ctx context.Context, allAgents []agents.Interface, artist *auxArtist, limit int, wg *sync.WaitGroup) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
start := time.Now()
|
||||
for _, a := range allAgents {
|
||||
if isDone(ctx) {
|
||||
break
|
||||
}
|
||||
agent, ok := a.(agents.ArtistSimilarRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
similar, err := agent.GetSimilar(artist.ID, artist.Name, artist.MbzArtistID, limit)
|
||||
if len(similar) == 0 || err != nil {
|
||||
continue
|
||||
}
|
||||
sa, err := e.mapSimilarArtists(ctx, similar, true)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
log.Debug(ctx, "Got Similar Artists", "agent", a.AgentName(), "artist", artist.Name, "similar", similar, "elapsed", time.Since(start))
|
||||
artist.SimilarArtists = sa
|
||||
break
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (e *externalMetadata) mapSimilarArtists(ctx context.Context, similar []agents.Artist, includeNotPresent bool) (model.Artists, error) {
|
||||
var result model.Artists
|
||||
var notPresent []string
|
||||
|
||||
// First select artists that are present.
|
||||
for _, s := range similar {
|
||||
sa, err := e.findArtistByName(ctx, s.Name)
|
||||
if err != nil {
|
||||
notPresent = append(notPresent, s.Name)
|
||||
continue
|
||||
}
|
||||
result = append(result, sa.Artist)
|
||||
}
|
||||
|
||||
// Then fill up with non-present artists
|
||||
if includeNotPresent {
|
||||
for _, s := range notPresent {
|
||||
sa := model.Artist{ID: unavailableArtistID, Name: s}
|
||||
result = append(result, sa)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) findArtistByName(ctx context.Context, artistName string) (*auxArtist, error) {
|
||||
artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Like{"name": artistName},
|
||||
Max: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(artists) == 0 {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
artist := &auxArtist{
|
||||
Artist: artists[0],
|
||||
Name: clearName(artists[0].Name),
|
||||
}
|
||||
return artist, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) loadSimilar(ctx context.Context, artist *auxArtist, includeNotPresent bool) error {
|
||||
var ids []string
|
||||
for _, sa := range artist.SimilarArtists {
|
||||
if sa.ID == unavailableArtistID {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, sa.ID)
|
||||
}
|
||||
|
||||
similar, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"id": ids},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Use a map and iterate through original array, to keep the same order
|
||||
artistMap := make(map[string]model.Artist)
|
||||
for _, sa := range similar {
|
||||
artistMap[sa.ID] = sa
|
||||
}
|
||||
|
||||
var loaded model.Artists
|
||||
for _, sa := range artist.SimilarArtists {
|
||||
la, ok := artistMap[sa.ID]
|
||||
if !ok {
|
||||
if !includeNotPresent {
|
||||
continue
|
||||
}
|
||||
la = sa
|
||||
la.ID = unavailableArtistID
|
||||
}
|
||||
loaded = append(loaded, la)
|
||||
}
|
||||
artist.SimilarArtists = loaded
|
||||
return nil
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/djherbis/fscache"
|
||||
"github.com/dustin/go-humanize"
|
||||
)
|
||||
|
||||
func newFileCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cache, error) {
|
||||
if cacheSize == "0" {
|
||||
log.Warn(fmt.Sprintf("%s cache disabled", name))
|
||||
return nil, nil
|
||||
}
|
||||
size, err := humanize.ParseBytes(cacheSize)
|
||||
if err != nil {
|
||||
size = consts.DefaultCacheSize
|
||||
}
|
||||
lru := fscache.NewLRUHaunter(maxItems, int64(size), consts.DefaultCacheCleanUpInterval)
|
||||
h := fscache.NewLRUHaunterStrategy(lru)
|
||||
cacheFolder = filepath.Join(conf.Server.DataFolder, cacheFolder)
|
||||
log.Info(fmt.Sprintf("Creating %s cache", name), "path", cacheFolder, "maxSize", humanize.Bytes(size))
|
||||
fs, err := fscache.NewFs(cacheFolder, 0755)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fscache.NewCacheWithHaunter(fs, h)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("File Caches", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DataFolder, _ = ioutil.TempDir("", "file_caches")
|
||||
})
|
||||
AfterEach(func() {
|
||||
os.RemoveAll(conf.Server.DataFolder)
|
||||
})
|
||||
|
||||
Describe("newFileCache", func() {
|
||||
It("creates the cache folder", func() {
|
||||
Expect(newFileCache("test", "1k", "test", 10)).ToNot(BeNil())
|
||||
|
||||
_, err := os.Stat(filepath.Join(conf.Server.DataFolder, "test"))
|
||||
Expect(os.IsNotExist(err)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("creates the cache folder with invalid size", func() {
|
||||
Expect(newFileCache("test", "abc", "test", 10)).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("returns empty if cache size is '0'", func() {
|
||||
Expect(newFileCache("test", "0", "test", 10)).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
28
core/get_entity.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// TODO: Should the type be encoded in the ID?
|
||||
func GetEntityByID(ctx context.Context, ds model.DataStore, id string) (interface{}, error) {
|
||||
ar, err := ds.Artist(ctx).Get(id)
|
||||
if err == nil {
|
||||
return ar, nil
|
||||
}
|
||||
al, err := ds.Album(ctx).Get(id)
|
||||
if err == nil {
|
||||
return al, nil
|
||||
}
|
||||
pls, err := ds.Playlist(ctx).Get(id)
|
||||
if err == nil {
|
||||
return pls, nil
|
||||
}
|
||||
mf, err := ds.MediaFile(ctx).Get(id)
|
||||
if err == nil {
|
||||
return mf, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
@@ -6,22 +6,23 @@ import (
|
||||
"io"
|
||||
"mime"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/core/transcoder"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
"github.com/djherbis/fscache"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/transcoder"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
)
|
||||
|
||||
type MediaStreamer interface {
|
||||
NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error)
|
||||
}
|
||||
|
||||
type TranscodingCache fscache.Cache
|
||||
type TranscodingCache cache.FileCache
|
||||
|
||||
func NewMediaStreamer(ds model.DataStore, ffm transcoder.Transcoder, cache TranscodingCache) MediaStreamer {
|
||||
return &mediaStreamer{ds: ds, ffm: ffm, cache: cache}
|
||||
@@ -30,7 +31,18 @@ func NewMediaStreamer(ds model.DataStore, ffm transcoder.Transcoder, cache Trans
|
||||
type mediaStreamer struct {
|
||||
ds model.DataStore
|
||||
ffm transcoder.Transcoder
|
||||
cache fscache.Cache
|
||||
cache cache.FileCache
|
||||
}
|
||||
|
||||
type streamJob struct {
|
||||
ms *mediaStreamer
|
||||
mf *model.MediaFile
|
||||
format string
|
||||
bitRate int
|
||||
}
|
||||
|
||||
func (j *streamJob) Key() string {
|
||||
return fmt.Sprintf("%s.%s.%d.%s", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.format)
|
||||
}
|
||||
|
||||
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error) {
|
||||
@@ -49,92 +61,45 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat str
|
||||
}()
|
||||
|
||||
format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, reqFormat, reqBitRate)
|
||||
log.Trace(ctx, "Selected transcoding options",
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
|
||||
"selectedBitrate", bitRate, "selectedFormat", format,
|
||||
)
|
||||
s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate}
|
||||
|
||||
if format == "raw" {
|
||||
log.Debug(ctx, "Streaming raw file", "id", mf.ID, "path", mf.Path,
|
||||
log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", mf.Path,
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix)
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
|
||||
"selectedBitrate", bitRate, "selectedFormat", format)
|
||||
f, err := os.Open(mf.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Reader = f
|
||||
s.Closer = f
|
||||
s.ReadCloser = f
|
||||
s.Seeker = f
|
||||
s.format = mf.Suffix
|
||||
return s, nil
|
||||
}
|
||||
|
||||
key := cacheKey(id, bitRate, format)
|
||||
r, w, err := ms.cache.Get(key)
|
||||
job := &streamJob{
|
||||
ms: ms,
|
||||
mf: mf,
|
||||
format: format,
|
||||
bitRate: bitRate,
|
||||
}
|
||||
r, err := ms.cache.Get(ctx, job)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error creating stream caching buffer", "id", mf.ID, err)
|
||||
log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err)
|
||||
return nil, err
|
||||
}
|
||||
cached = r.Cached
|
||||
|
||||
cached = w == nil
|
||||
s.ReadCloser = r
|
||||
s.Seeker = r.Seeker
|
||||
|
||||
// If this is a brand new transcoding request, not in the cache, start transcoding
|
||||
if !cached {
|
||||
log.Trace(ctx, "Cache miss. Starting new transcoding session", "id", mf.ID)
|
||||
t, err := ms.ds.Transcoding(ctx).FindByFormat(format)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading transcoding command", "format", format, err)
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
out, err := ms.ffm.Start(ctx, t.Command, mf.Path, bitRate)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error starting transcoder", "id", mf.ID, err)
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
go copyAndClose(ctx, w, out)
|
||||
}
|
||||
|
||||
// If it is in the cache, check if the stream is done being written. If so, return a ReaderSeeker
|
||||
if cached {
|
||||
size := getFinalCachedSize(r)
|
||||
if size > 0 {
|
||||
log.Debug(ctx, "Streaming cached file", "id", mf.ID, "path", mf.Path,
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix, "size", size)
|
||||
sr := io.NewSectionReader(r, 0, size)
|
||||
s.Reader = sr
|
||||
s.Closer = r
|
||||
s.Seeker = sr
|
||||
s.format = format
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Streaming transcoded file", "id", mf.ID, "path", mf.Path,
|
||||
log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", mf.Path,
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix)
|
||||
// All other cases, just return a ReadCloser, without Seek capabilities
|
||||
s.Reader = r
|
||||
s.Closer = r
|
||||
s.format = format
|
||||
return s, nil
|
||||
}
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
|
||||
"selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable())
|
||||
|
||||
func copyAndClose(ctx context.Context, w io.WriteCloser, r io.ReadCloser) {
|
||||
_, err := io.Copy(w, r)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error copying data to cache", err)
|
||||
}
|
||||
err = r.Close()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error closing transcode output", err)
|
||||
}
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error closing cache", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
type Stream struct {
|
||||
@@ -142,8 +107,7 @@ type Stream struct {
|
||||
mf *model.MediaFile
|
||||
bitRate int
|
||||
format string
|
||||
io.Reader
|
||||
io.Closer
|
||||
io.ReadCloser
|
||||
io.Seeker
|
||||
}
|
||||
|
||||
@@ -202,21 +166,29 @@ func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model
|
||||
return
|
||||
}
|
||||
|
||||
func cacheKey(id string, bitRate int, format string) string {
|
||||
return fmt.Sprintf("%s.%d.%s", id, bitRate, format)
|
||||
}
|
||||
var (
|
||||
onceTranscodingCache sync.Once
|
||||
instanceTranscodingCache TranscodingCache
|
||||
)
|
||||
|
||||
func getFinalCachedSize(r fscache.ReadAtCloser) int64 {
|
||||
cr, ok := r.(*fscache.CacheReader)
|
||||
if ok {
|
||||
size, final, err := cr.Size()
|
||||
if final && err == nil {
|
||||
return size
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func NewTranscodingCache() (TranscodingCache, error) {
|
||||
return newFileCache("Transcoding", conf.Server.TranscodingCacheSize, consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems)
|
||||
func GetTranscodingCache() TranscodingCache {
|
||||
onceTranscodingCache.Do(func() {
|
||||
instanceTranscodingCache = cache.NewFileCache("Transcoding", conf.Server.TranscodingCacheSize,
|
||||
consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems,
|
||||
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
|
||||
job := arg.(*streamJob)
|
||||
t, err := job.ms.ds.Transcoding(ctx).FindByFormat(job.format)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading transcoding command", "format", job.format, err)
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
out, err := job.ms.ffm.Start(ctx, t.Command, job.mf.Path, job.bitRate)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
return out, nil
|
||||
})
|
||||
})
|
||||
return instanceTranscodingCache
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@ package core
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@@ -20,8 +22,14 @@ var _ = Describe("MediaStreamer", func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &persistence.MockDataStore{MockedTranscoding: &mockTranscodingRepository{}}
|
||||
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "suffix": "mp3", "bitRate": 128, "duration": 257.0}]`)
|
||||
conf.Server.DataFolder, _ = ioutil.TempDir("", "file_caches")
|
||||
conf.Server.TranscodingCacheSize = "100MB"
|
||||
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepository{}}
|
||||
ds.MediaFile(ctx).(*tests.MockMediaFile).SetData(model.MediaFiles{
|
||||
{ID: "123", Path: "tests/fixtures/test.mp3", Suffix: "mp3", BitRate: 128, Duration: 257.0},
|
||||
})
|
||||
testCache := GetTranscodingCache()
|
||||
Eventually(func() bool { return testCache.Ready(context.TODO()) }).Should(BeTrue())
|
||||
streamer = NewMediaStreamer(ds, ffmpeg, testCache)
|
||||
})
|
||||
|
||||
@@ -48,8 +56,13 @@ var _ = Describe("MediaStreamer", func() {
|
||||
Expect(s.Duration()).To(Equal(float32(257.0)))
|
||||
})
|
||||
It("returns a seekable stream if the file is complete in the cache", func() {
|
||||
Eventually(func() bool { return ffmpeg.closed }).Should(BeTrue())
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 64)
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 32)
|
||||
Expect(err).To(BeNil())
|
||||
_, _ = ioutil.ReadAll(s)
|
||||
_ = s.Close()
|
||||
Eventually(func() bool { return ffmpeg.closed }, "3s").Should(BeTrue())
|
||||
|
||||
s, err = streamer.NewStream(ctx, "123", "mp3", 32)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package core
|
||||
|
||||
import "github.com/deluan/navidrome/model"
|
||||
|
||||
type mockTranscodingRepository struct {
|
||||
model.TranscodingRepository
|
||||
}
|
||||
|
||||
func (m *mockTranscodingRepository) Get(id string) (*model.Transcoding, error) {
|
||||
return &model.Transcoding{ID: id, TargetFormat: "mp3", DefaultBitRate: 160}, nil
|
||||
}
|
||||
|
||||
func (m *mockTranscodingRepository) FindByFormat(format string) (*model.Transcoding, error) {
|
||||
switch format {
|
||||
case "mp3":
|
||||
return &model.Transcoding{ID: "mp31", TargetFormat: "mp3", DefaultBitRate: 160}, nil
|
||||
case "oga":
|
||||
return &model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 128}, nil
|
||||
default:
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package engine
|
||||
package core
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
@@ -17,22 +17,10 @@ type NowPlayingInfo struct {
|
||||
}
|
||||
|
||||
// This repo must have the semantics of a FIFO queue, for each playerId
|
||||
type NowPlayingRepository interface {
|
||||
type NowPlaying interface {
|
||||
// Insert at the head of the queue
|
||||
Enqueue(*NowPlayingInfo) error
|
||||
|
||||
// Removes and returns the element at the end of the queue
|
||||
Dequeue(playerId int) (*NowPlayingInfo, error)
|
||||
|
||||
// Returns the element at the head of the queue (last inserted one)
|
||||
Head(playerId int) (*NowPlayingInfo, error)
|
||||
|
||||
// Returns the element at the end of the queue (first inserted one)
|
||||
Tail(playerId int) (*NowPlayingInfo, error)
|
||||
|
||||
// Size of the queue for the playerId
|
||||
Count(playerId int) (int64, error)
|
||||
|
||||
// Returns all heads from all playerIds
|
||||
GetAll() ([]*NowPlayingInfo, error)
|
||||
}
|
||||
@@ -41,55 +29,17 @@ var playerMap = sync.Map{}
|
||||
|
||||
type nowPlayingRepository struct{}
|
||||
|
||||
func NewNowPlayingRepository() NowPlayingRepository {
|
||||
func NewNowPlayingRepository() NowPlaying {
|
||||
r := &nowPlayingRepository{}
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *nowPlayingRepository) getList(id int) *list.List {
|
||||
l, _ := playerMap.LoadOrStore(id, list.New())
|
||||
return l.(*list.List)
|
||||
}
|
||||
|
||||
func (r *nowPlayingRepository) Enqueue(info *NowPlayingInfo) error {
|
||||
l := r.getList(info.PlayerId)
|
||||
l.PushFront(info)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *nowPlayingRepository) Dequeue(playerId int) (*NowPlayingInfo, error) {
|
||||
l := r.getList(playerId)
|
||||
e := checkExpired(l, l.Back)
|
||||
if e == nil {
|
||||
return nil, nil
|
||||
}
|
||||
l.Remove(e)
|
||||
return e.Value.(*NowPlayingInfo), nil
|
||||
}
|
||||
|
||||
func (r *nowPlayingRepository) Head(playerId int) (*NowPlayingInfo, error) {
|
||||
l := r.getList(playerId)
|
||||
e := checkExpired(l, l.Front)
|
||||
if e == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return e.Value.(*NowPlayingInfo), nil
|
||||
}
|
||||
|
||||
func (r *nowPlayingRepository) Tail(playerId int) (*NowPlayingInfo, error) {
|
||||
l := r.getList(playerId)
|
||||
e := checkExpired(l, l.Back)
|
||||
if e == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return e.Value.(*NowPlayingInfo), nil
|
||||
}
|
||||
|
||||
func (r *nowPlayingRepository) Count(playerId int) (int64, error) {
|
||||
l := r.getList(playerId)
|
||||
return int64(l.Len()), nil
|
||||
}
|
||||
|
||||
func (r *nowPlayingRepository) GetAll() ([]*NowPlayingInfo, error) {
|
||||
var all []*NowPlayingInfo
|
||||
playerMap.Range(func(playerId, l interface{}) bool {
|
||||
@@ -103,6 +53,44 @@ func (r *nowPlayingRepository) GetAll() ([]*NowPlayingInfo, error) {
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func (r *nowPlayingRepository) getList(id int) *list.List {
|
||||
l, _ := playerMap.LoadOrStore(id, list.New())
|
||||
return l.(*list.List)
|
||||
}
|
||||
|
||||
func (r *nowPlayingRepository) dequeue(playerId int) (*NowPlayingInfo, error) {
|
||||
l := r.getList(playerId)
|
||||
e := checkExpired(l, l.Back)
|
||||
if e == nil {
|
||||
return nil, nil
|
||||
}
|
||||
l.Remove(e)
|
||||
return e.Value.(*NowPlayingInfo), nil
|
||||
}
|
||||
|
||||
func (r *nowPlayingRepository) head(playerId int) (*NowPlayingInfo, error) {
|
||||
l := r.getList(playerId)
|
||||
e := checkExpired(l, l.Front)
|
||||
if e == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return e.Value.(*NowPlayingInfo), nil
|
||||
}
|
||||
|
||||
func (r *nowPlayingRepository) tail(playerId int) (*NowPlayingInfo, error) {
|
||||
l := r.getList(playerId)
|
||||
e := checkExpired(l, l.Back)
|
||||
if e == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return e.Value.(*NowPlayingInfo), nil
|
||||
}
|
||||
|
||||
func (r *nowPlayingRepository) count(playerId int) (int64, error) {
|
||||
l := r.getList(playerId)
|
||||
return int64(l.Len()), nil
|
||||
}
|
||||
|
||||
func checkExpired(l *list.List, f func() *list.Element) *list.Element {
|
||||
for {
|
||||
e := f()
|
||||
@@ -1,4 +1,4 @@
|
||||
package engine
|
||||
package core
|
||||
|
||||
import (
|
||||
"sync"
|
||||
@@ -8,27 +8,27 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("NowPlayingRepository", func() {
|
||||
var repo NowPlayingRepository
|
||||
var _ = Describe("NowPlaying", func() {
|
||||
var repo *nowPlayingRepository
|
||||
var now = time.Now()
|
||||
var past = time.Time{}
|
||||
|
||||
BeforeEach(func() {
|
||||
playerMap = sync.Map{}
|
||||
repo = NewNowPlayingRepository()
|
||||
repo = NewNowPlayingRepository().(*nowPlayingRepository)
|
||||
})
|
||||
|
||||
It("enqueues and dequeues records", func() {
|
||||
Expect(repo.Enqueue(&NowPlayingInfo{PlayerId: 1, TrackID: "AAA", Start: now})).To(BeNil())
|
||||
Expect(repo.Enqueue(&NowPlayingInfo{PlayerId: 1, TrackID: "BBB", Start: now})).To(BeNil())
|
||||
|
||||
Expect(repo.Tail(1)).To(Equal(&NowPlayingInfo{PlayerId: 1, TrackID: "AAA", Start: now}))
|
||||
Expect(repo.Head(1)).To(Equal(&NowPlayingInfo{PlayerId: 1, TrackID: "BBB", Start: now}))
|
||||
Expect(repo.tail(1)).To(Equal(&NowPlayingInfo{PlayerId: 1, TrackID: "AAA", Start: now}))
|
||||
Expect(repo.head(1)).To(Equal(&NowPlayingInfo{PlayerId: 1, TrackID: "BBB", Start: now}))
|
||||
|
||||
Expect(repo.Count(1)).To(Equal(int64(2)))
|
||||
Expect(repo.count(1)).To(Equal(int64(2)))
|
||||
|
||||
Expect(repo.Dequeue(1)).To(Equal(&NowPlayingInfo{PlayerId: 1, TrackID: "AAA", Start: now}))
|
||||
Expect(repo.Count(1)).To(Equal(int64(1)))
|
||||
Expect(repo.dequeue(1)).To(Equal(&NowPlayingInfo{PlayerId: 1, TrackID: "AAA", Start: now}))
|
||||
Expect(repo.count(1)).To(Equal(int64(1)))
|
||||
})
|
||||
|
||||
It("handles multiple players", func() {
|
||||
@@ -43,11 +43,11 @@ var _ = Describe("NowPlayingRepository", func() {
|
||||
{PlayerId: 2, TrackID: "DDD", Start: now},
|
||||
}))
|
||||
|
||||
Expect(repo.Count(2)).To(Equal(int64(2)))
|
||||
Expect(repo.Count(2)).To(Equal(int64(2)))
|
||||
Expect(repo.count(2)).To(Equal(int64(2)))
|
||||
Expect(repo.count(2)).To(Equal(int64(2)))
|
||||
|
||||
Expect(repo.Tail(1)).To(Equal(&NowPlayingInfo{PlayerId: 1, TrackID: "AAA", Start: now}))
|
||||
Expect(repo.Head(2)).To(Equal(&NowPlayingInfo{PlayerId: 2, TrackID: "DDD", Start: now}))
|
||||
Expect(repo.tail(1)).To(Equal(&NowPlayingInfo{PlayerId: 1, TrackID: "AAA", Start: now}))
|
||||
Expect(repo.head(2)).To(Equal(&NowPlayingInfo{PlayerId: 2, TrackID: "DDD", Start: now}))
|
||||
})
|
||||
|
||||
It("handles expired items", func() {
|
||||
@@ -1,14 +1,14 @@
|
||||
package engine
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
)
|
||||
|
||||
type Players interface {
|
||||
@@ -40,9 +40,8 @@ func (p *players) Register(ctx context.Context, id, client, typ, ip string) (*mo
|
||||
if err == nil {
|
||||
log.Debug("Found player by name", "id", plr.ID, "client", client, "username", userName)
|
||||
} else {
|
||||
r, _ := uuid.NewRandom()
|
||||
plr = &model.Player{
|
||||
ID: r.String(),
|
||||
ID: uuid.NewString(),
|
||||
Name: fmt.Sprintf("%s (%s)", client, userName),
|
||||
UserName: userName,
|
||||
Client: client,
|
||||
@@ -1,13 +1,13 @@
|
||||
package engine
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@@ -22,7 +22,7 @@ var _ = Describe("Players", func() {
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = &mockPlayerRepository{}
|
||||
ds := &persistence.MockDataStore{MockedPlayer: repo, MockedTranscoding: &mockTranscodingRepository{}}
|
||||
ds := &tests.MockDataStore{MockedPlayer: repo, MockedTranscoding: &tests.MockTranscodingRepository{}}
|
||||
players = NewPlayers(ds)
|
||||
beforeRegister = time.Now()
|
||||
})
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
type Transcoder interface {
|
||||
@@ -16,11 +16,10 @@ type Transcoder interface {
|
||||
}
|
||||
|
||||
func New() Transcoder {
|
||||
path, err := exec.LookPath("ffmpeg")
|
||||
_, err := exec.LookPath("ffmpeg")
|
||||
if err != nil {
|
||||
log.Error("Unable to find ffmpeg", err)
|
||||
}
|
||||
log.Debug("Found ffmpeg", "path", path)
|
||||
return &ffmpeg{}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ package transcoder
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"github.com/deluan/navidrome/core/transcoder"
|
||||
"github.com/google/wire"
|
||||
"github.com/navidrome/navidrome/core/transcoder"
|
||||
)
|
||||
|
||||
var Set = wire.NewSet(
|
||||
NewCover,
|
||||
NewArtwork,
|
||||
NewMediaStreamer,
|
||||
NewTranscodingCache,
|
||||
NewImageCache,
|
||||
GetTranscodingCache,
|
||||
GetImageCache,
|
||||
NewArchiver,
|
||||
NewNowPlayingRepository,
|
||||
NewExternalMetadata,
|
||||
NewCacheWarmer,
|
||||
NewPlayers,
|
||||
transcoder.New,
|
||||
)
|
||||
|
||||
20
db/db.go
@@ -5,10 +5,10 @@ import (
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
_ "github.com/deluan/navidrome/db/migration"
|
||||
"github.com/deluan/navidrome/log"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
_ "github.com/navidrome/navidrome/db/migration"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
@@ -42,7 +42,19 @@ func Db() *sql.DB {
|
||||
func EnsureLatestVersion() {
|
||||
db := Db()
|
||||
|
||||
err := goose.SetDialect(Driver)
|
||||
// Disable foreign_keys to allow re-creating tables in migrations
|
||||
_, err := db.Exec("PRAGMA foreign_keys=off")
|
||||
defer func() {
|
||||
_, err := db.Exec("PRAGMA foreign_keys=on")
|
||||
if err != nil {
|
||||
log.Error("Error re-enabling foreign_keys", err)
|
||||
}
|
||||
}()
|
||||
if err != nil {
|
||||
log.Error("Error disabling foreign_keys", err)
|
||||
}
|
||||
|
||||
err = goose.SetDialect(Driver)
|
||||
if err != nil {
|
||||
log.Error("Invalid DB driver", "driver", Driver, err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
@@ -29,8 +29,7 @@ func upAddDefaultTranscodings(tx *sql.Tx) error {
|
||||
}
|
||||
|
||||
for _, t := range consts.DefaultTranscodings {
|
||||
r, _ := uuid.NewRandom()
|
||||
_, err := stmt.Exec(r.String(), t["name"], t["targetFormat"], t["defaultBitRate"], t["command"])
|
||||
_, err := stmt.Exec(uuid.NewString(), t["name"], t["targetFormat"], t["defaultBitRate"], t["command"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
36
db/migration/20200731095603_create_play_queues_table.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upCreatePlayQueuesTable, downCreatePlayQueuesTable)
|
||||
}
|
||||
|
||||
func upCreatePlayQueuesTable(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table playqueue
|
||||
(
|
||||
id varchar(255) not null primary key,
|
||||
user_id varchar(255) not null
|
||||
references user (id)
|
||||
on update cascade on delete cascade,
|
||||
comment varchar(255),
|
||||
current varchar(255) not null,
|
||||
position integer,
|
||||
changed_by varchar(255),
|
||||
items varchar(255),
|
||||
created_at datetime,
|
||||
updated_at datetime
|
||||
);
|
||||
`)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func downCreatePlayQueuesTable(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
53
db/migration/20200801101355_create_bookmark_table.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upCreateBookmarkTable, downCreateBookmarkTable)
|
||||
}
|
||||
|
||||
func upCreateBookmarkTable(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table bookmark
|
||||
(
|
||||
user_id varchar(255) not null
|
||||
references user
|
||||
on update cascade on delete cascade,
|
||||
item_id varchar(255) not null,
|
||||
item_type varchar(255) not null,
|
||||
comment varchar(255),
|
||||
position integer,
|
||||
changed_by varchar(255),
|
||||
created_at datetime,
|
||||
updated_at datetime,
|
||||
constraint bookmark_pk
|
||||
unique (user_id, item_id, item_type)
|
||||
);
|
||||
|
||||
create table playqueue_dg_tmp
|
||||
(
|
||||
id varchar(255) not null,
|
||||
user_id varchar(255) not null
|
||||
references user
|
||||
on update cascade on delete cascade,
|
||||
current varchar(255),
|
||||
position real,
|
||||
changed_by varchar(255),
|
||||
items varchar(255),
|
||||
created_at datetime,
|
||||
updated_at datetime
|
||||
);
|
||||
drop table playqueue;
|
||||
alter table playqueue_dg_tmp rename to playqueue;
|
||||
`)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func downCreateBookmarkTable(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
42
db/migration/20200819111809_drop_email_unique_constraint.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upDropEmailUniqueConstraint, downDropEmailUniqueConstraint)
|
||||
}
|
||||
|
||||
func upDropEmailUniqueConstraint(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table user_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
user_name varchar(255) default '' not null
|
||||
unique,
|
||||
name varchar(255) default '' not null,
|
||||
email varchar(255) default '' not null,
|
||||
password varchar(255) default '' not null,
|
||||
is_admin bool default FALSE not null,
|
||||
last_login_at datetime,
|
||||
last_access_at datetime,
|
||||
created_at datetime not null,
|
||||
updated_at datetime not null
|
||||
);
|
||||
|
||||
insert into user_dg_tmp(id, user_name, name, email, password, is_admin, last_login_at, last_access_at, created_at, updated_at) select id, user_name, name, email, password, is_admin, last_login_at, last_access_at, created_at, updated_at from user;
|
||||
|
||||
drop table user;
|
||||
|
||||
alter table user_dg_tmp rename to user;
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downDropEmailUniqueConstraint(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
23
db/migration/20201003111749_add_starred_at_index.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20201003111749, Down20201003111749)
|
||||
}
|
||||
|
||||
func Up20201003111749(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create index if not exists annotation_starred_at
|
||||
on annotation (starred_at);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20201003111749(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
33
db/migration/20201010162350_add_album_size.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20201010162350, Down20201010162350)
|
||||
}
|
||||
|
||||
func Up20201010162350(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table album
|
||||
add size integer default 0 not null;
|
||||
create index if not exists album_size
|
||||
on album(size);
|
||||
|
||||
update album set size = ifnull((
|
||||
select sum(f.size)
|
||||
from media_file f
|
||||
where f.album_id = album.id
|
||||
), 0)
|
||||
where id not null;`)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20201010162350(tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
44
db/migration/20201012210022_add_artist_playlist_size.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20201012210022, Down20201012210022)
|
||||
}
|
||||
|
||||
func Up20201012210022(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table artist
|
||||
add size integer default 0 not null;
|
||||
create index if not exists artist_size
|
||||
on artist(size);
|
||||
|
||||
update artist set size = ifnull((
|
||||
select sum(f.size)
|
||||
from album f
|
||||
where f.album_artist_id = artist.id
|
||||
), 0)
|
||||
where id not null;
|
||||
|
||||
alter table playlist
|
||||
add size integer default 0 not null;
|
||||
create index if not exists playlist_size
|
||||
on playlist(size);
|
||||
|
||||
update playlist set size = ifnull((
|
||||
select sum(size)
|
||||
from media_file f
|
||||
left join playlist_tracks pt on f.id = pt.media_file_id
|
||||
where pt.playlist_id = playlist.id
|
||||
), 0);`)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20201012210022(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
58
db/migration/20201021085410_add_mbids.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20201021085410, Down20201021085410)
|
||||
}
|
||||
|
||||
func Up20201021085410(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table media_file
|
||||
add mbz_track_id varchar(255);
|
||||
alter table media_file
|
||||
add mbz_album_id varchar(255);
|
||||
alter table media_file
|
||||
add mbz_artist_id varchar(255);
|
||||
alter table media_file
|
||||
add mbz_album_artist_id varchar(255);
|
||||
alter table media_file
|
||||
add mbz_album_type varchar(255);
|
||||
alter table media_file
|
||||
add mbz_album_comment varchar(255);
|
||||
alter table media_file
|
||||
add catalog_num varchar(255);
|
||||
|
||||
alter table album
|
||||
add mbz_album_id varchar(255);
|
||||
alter table album
|
||||
add mbz_album_artist_id varchar(255);
|
||||
alter table album
|
||||
add mbz_album_type varchar(255);
|
||||
alter table album
|
||||
add mbz_album_comment varchar(255);
|
||||
alter table album
|
||||
add catalog_num varchar(255);
|
||||
|
||||
create index if not exists album_mbz_album_type
|
||||
on album (mbz_album_type);
|
||||
|
||||
alter table artist
|
||||
add mbz_artist_id varchar(255);
|
||||
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan needs to be performed to import more tags")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20201021085410(tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
27
db/migration/20201021093209_add_media_file_indexes.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20201021093209, Down20201021093209)
|
||||
}
|
||||
|
||||
func Up20201021093209(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create index if not exists media_file_artist
|
||||
on media_file (artist);
|
||||
create index if not exists media_file_album_artist
|
||||
on media_file (album_artist);
|
||||
create index if not exists media_file_mbz_track_id
|
||||
on media_file (mbz_track_id);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20201021093209(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
23
db/migration/20201021135455_add_media_file_artist_index.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20201021135455, Down20201021135455)
|
||||
}
|
||||
|
||||
func Up20201021135455(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create index if not exists media_file_artist_id
|
||||
on media_file (artist_id);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20201021135455(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
35
db/migration/20201030162009_add_artist_info_table.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upAddArtistImageUrl, downAddArtistImageUrl)
|
||||
}
|
||||
|
||||
func upAddArtistImageUrl(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table artist
|
||||
add biography varchar(255) default '' not null;
|
||||
alter table artist
|
||||
add small_image_url varchar(255) default '' not null;
|
||||
alter table artist
|
||||
add medium_image_url varchar(255) default '' not null;
|
||||
alter table artist
|
||||
add large_image_url varchar(255) default '' not null;
|
||||
alter table artist
|
||||
add similar_artists varchar(255) default '' not null;
|
||||
alter table artist
|
||||
add external_url varchar(255) default '' not null;
|
||||
alter table artist
|
||||
add external_info_updated_at datetime;
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddArtistImageUrl(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
32
db/migration/20201110205344_add_comments_and_lyrics.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20201110205344, Down20201110205344)
|
||||
}
|
||||
|
||||
func Up20201110205344(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table media_file
|
||||
add comment varchar;
|
||||
alter table media_file
|
||||
add lyrics varchar;
|
||||
|
||||
alter table album
|
||||
add comment varchar;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan will be performed to import comments and lyrics")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20201110205344(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
23
db/migration/20201128100726_add_real-path_option.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20201128100726, Down20201128100726)
|
||||
}
|
||||
|
||||
func Up20201128100726(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table player
|
||||
add report_real_path bool default FALSE not null;
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20201128100726(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
62
db/migration/20201213124814_add_all_artist_ids_to_album.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20201213124814, Down20201213124814)
|
||||
}
|
||||
|
||||
func Up20201213124814(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table album
|
||||
add all_artist_ids varchar;
|
||||
|
||||
create index if not exists album_all_artist_ids
|
||||
on album (all_artist_ids);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return updateAlbums20201213124814(tx)
|
||||
}
|
||||
|
||||
func updateAlbums20201213124814(tx *sql.Tx) error {
|
||||
rows, err := tx.Query(`
|
||||
select a.id, a.name, a.artist_id, a.album_artist_id, group_concat(mf.artist_id, ' ')
|
||||
from album a left join media_file mf on a.id = mf.album_id group by a.id
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
stmt, err := tx.Prepare("update album set all_artist_ids = ? where id = ?")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var id, name, artistId, albumArtistId, songArtistIds string
|
||||
for rows.Next() {
|
||||
err := rows.Scan(&id, &name, &artistId, &albumArtistId, &songArtistIds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
all := utils.SanitizeStrings(artistId, albumArtistId, songArtistIds)
|
||||
_, err = stmt.Exec(all, id)
|
||||
if err != nil {
|
||||
log.Error("Error setting album's artist_ids", "album", name, "albumId", id, err)
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func Down20201213124814(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||