mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-04 04:48:08 -05:00
Compare commits
788 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
7e81a3b895 | ||
|
|
d268075046 | ||
|
|
482f46f3fd | ||
|
|
f0160f5d2a | ||
|
|
feca030c6d | ||
|
|
41138bd665 | ||
|
|
178e42487b | ||
|
|
ae04919585 | ||
|
|
6adba03868 | ||
|
|
609d172259 | ||
|
|
9cf8c92cae | ||
|
|
38c3013ddf | ||
|
|
8f512a40f7 | ||
|
|
b9b6ce066b | ||
|
|
35114be5f7 | ||
|
|
3239be4a4d | ||
|
|
a706cb46fa | ||
|
|
3095bee5d9 | ||
|
|
51c295d1de | ||
|
|
de0cc1f268 | ||
|
|
037f6b606e | ||
|
|
e7f6ba8f35 | ||
|
|
25f68b6c89 | ||
|
|
dc50f672b8 | ||
|
|
d14a6031f0 | ||
|
|
8b20c26e04 | ||
|
|
1ef0869a54 | ||
|
|
ca10e800a9 | ||
|
|
33d5459c20 | ||
|
|
aae43f4452 | ||
|
|
0bd842869b | ||
|
|
394d3b0e67 | ||
|
|
1ef17e2986 | ||
|
|
d4347f20ae | ||
|
|
3319f78de0 | ||
|
|
ee0ae0a06c | ||
|
|
064da8e034 | ||
|
|
74cf0ee1c1 | ||
|
|
c2f40ea8a3 | ||
|
|
f694e471fb | ||
|
|
dc8368c89c | ||
|
|
e55397fcdc | ||
|
|
8260b46e8f | ||
|
|
b59c6c85e0 | ||
|
|
b96ff9c210 | ||
|
|
c758780e38 | ||
|
|
9e35534dad | ||
|
|
5620c58a30 | ||
|
|
5418a6b6b1 | ||
|
|
865bad1550 | ||
|
|
7c3fd38559 | ||
|
|
933052583a | ||
|
|
941e252d44 | ||
|
|
f0a5df7cd7 | ||
|
|
fdc38b5ca5 | ||
|
|
2f8b01015d | ||
|
|
2a302de42f | ||
|
|
681849d174 | ||
|
|
17830d63b4 | ||
|
|
1cc03fdd8c | ||
|
|
dd91f983b5 | ||
|
|
3a7d70c908 | ||
|
|
8181aba61f | ||
|
|
2d0539300d | ||
|
|
f45045d1c0 | ||
|
|
6954e1b4eb | ||
|
|
ef9af6ed1a | ||
|
|
99e269208e | ||
|
|
f980e24868 | ||
|
|
a65c9bbb16 | ||
|
|
d2e4cade62 | ||
|
|
5021c0fd0c | ||
|
|
fea060e4f2 | ||
|
|
7a9b848f38 | ||
|
|
2d8f0a740e | ||
|
|
fa107a6b65 | ||
|
|
2371e9b943 | ||
|
|
f0ee52a98e | ||
|
|
c01d81802d | ||
|
|
890ca64f51 | ||
|
|
bcaf330233 | ||
|
|
ab1c943d1f | ||
|
|
703875b895 | ||
|
|
5f40801a78 | ||
|
|
eb109ebeb4 | ||
|
|
bb9a7fadc0 | ||
|
|
ac5d99c079 | ||
|
|
d9c991e325 | ||
|
|
08cd28af2d | ||
|
|
6563897692 | ||
|
|
04d598819d | ||
|
|
965c04469e | ||
|
|
416ca2c063 | ||
|
|
ab35586b0c | ||
|
|
acb5985127 | ||
|
|
9b75b729ba | ||
|
|
e1968b0953 | ||
|
|
f36e15cfeb | ||
|
|
7547c775fa | ||
|
|
ad21b5f0d0 | ||
|
|
4427900d84 | ||
|
|
0ca70b1e4d | ||
|
|
0292a334fe | ||
|
|
f93e2d0c04 | ||
|
|
3a9324c6ef | ||
|
|
cf692140a9 | ||
|
|
83b8fa14c6 | ||
|
|
de693b8206 | ||
|
|
1686e358fe | ||
|
|
804d969427 | ||
|
|
9d23b191b5 | ||
|
|
eb4c0f0b84 | ||
|
|
c507e344ff | ||
|
|
a6af46dbad | ||
|
|
2d1d992e17 | ||
|
|
653b5ea9d3 | ||
|
|
e73b71aaf7 | ||
|
|
01919661e9 | ||
|
|
3190611ec8 | ||
|
|
6a3dabbb06 | ||
|
|
238020c839 | ||
|
|
72b2e756f7 | ||
|
|
86bc8d97a0 | ||
|
|
003b73fe1a | ||
|
|
be2afb94ae | ||
|
|
f8a18b59b0 | ||
|
|
c216b14655 | ||
|
|
4702c5abbd | ||
|
|
c742ae0843 | ||
|
|
0033966c25 | ||
|
|
f072ffd377 | ||
|
|
94d88395e7 | ||
|
|
c9bcb333ae | ||
|
|
84ed3eb427 | ||
|
|
8bd9787c51 | ||
|
|
1c466d6083 | ||
|
|
a64b15c174 | ||
|
|
7148741a4f | ||
|
|
630c71119a | ||
|
|
50f4bd86a3 | ||
|
|
44c74f42e1 | ||
|
|
29c7513879 | ||
|
|
82d437f004 | ||
|
|
b54d4c75ae | ||
|
|
b636565c62 | ||
|
|
b4e06c416d | ||
|
|
5e2d463129 | ||
|
|
12d5d9573e | ||
|
|
42ee8b64cb | ||
|
|
3908ad2681 | ||
|
|
e9115dab4c | ||
|
|
79cf33281c | ||
|
|
2adb290c34 | ||
|
|
c6f23139bc | ||
|
|
4906b816af | ||
|
|
39afe0c669 | ||
|
|
f8a7ef1e19 | ||
|
|
4776dba003 | ||
|
|
331fa1d952 | ||
|
|
b597a34cb4 | ||
|
|
51fb1d1349 | ||
|
|
8fd86def18 | ||
|
|
5d285f92f5 | ||
|
|
888151728f | ||
|
|
b836dfe7f4 | ||
|
|
ddcfc546fb | ||
|
|
86a9f9e410 | ||
|
|
14d7a69088 | ||
|
|
35e4eec293 | ||
|
|
7547888f10 | ||
|
|
fbedbb7893 | ||
|
|
a7640c9df4 | ||
|
|
8f8d992da4 | ||
|
|
3fe8b02cbd | ||
|
|
ba8c8725dd | ||
|
|
915b701e44 | ||
|
|
596100b58d | ||
|
|
d8699b03bd | ||
|
|
7b36096153 | ||
|
|
62290bca77 | ||
|
|
498e196d48 | ||
|
|
432fe10a5e | ||
|
|
7e625d68b5 | ||
|
|
50f3a2c11d | ||
|
|
9028d301f0 | ||
|
|
26dba27778 | ||
|
|
7170485d08 | ||
|
|
2c68ba3934 | ||
|
|
201a22e613 | ||
|
|
3ca295c863 | ||
|
|
be85fe3773 | ||
|
|
7c3d96cf6c | ||
|
|
50b44c1991 | ||
|
|
f9dae2dd2a | ||
|
|
00811f8000 | ||
|
|
9c940cd44f | ||
|
|
1607dc8b88 | ||
|
|
a42a16696e | ||
|
|
6db63e4dfc | ||
|
|
23bd5e1131 | ||
|
|
8973477fe5 | ||
|
|
fbd6c965b0 | ||
|
|
aaa4f1531e | ||
|
|
72e92c7318 | ||
|
|
72cb3850d1 | ||
|
|
a6cc88177c | ||
|
|
d6ad833538 | ||
|
|
eb1749ce71 | ||
|
|
acebe18c95 | ||
|
|
cac1a20ec8 | ||
|
|
ac8f92d7ac | ||
|
|
207565bde0 | ||
|
|
3ae1586e10 | ||
|
|
5c46f7822f | ||
|
|
c13766bbc3 | ||
|
|
290e8c4bf0 | ||
|
|
442671578d | ||
|
|
1bca8fca97 | ||
|
|
e811816021 | ||
|
|
9331be67a3 | ||
|
|
55ad5c9fc9 | ||
|
|
ec0002e77a | ||
|
|
3632608de0 | ||
|
|
0a3e6c66c1 | ||
|
|
52a46e61e0 | ||
|
|
de2759b3d5 | ||
|
|
978e7f2eaa | ||
|
|
ae847103a2 | ||
|
|
6f6b223453 | ||
|
|
8a68cecdb9 | ||
|
|
e21262675e | ||
|
|
a3ba05b2cc | ||
|
|
294712739a | ||
|
|
ad725ac355 | ||
|
|
17df63b550 | ||
|
|
c2d1e9df9f | ||
|
|
0e4f7036eb | ||
|
|
a4183aea8c | ||
|
|
9e845cb116 | ||
|
|
f82fefe0ab | ||
|
|
f28531b609 | ||
|
|
14f3ffbee6 | ||
|
|
94e1b1f65d | ||
|
|
274eb805f9 | ||
|
|
84ea852339 | ||
|
|
cf019849f0 | ||
|
|
76a5d1928e | ||
|
|
3dced978c7 | ||
|
|
6071ae143e | ||
|
|
05a07f31c9 | ||
|
|
1afbbbf189 | ||
|
|
308163c2e0 | ||
|
|
176bfe1506 | ||
|
|
4c3f3f3573 | ||
|
|
1aef21a4a9 | ||
|
|
d1a0ffaaee | ||
|
|
41010515ee | ||
|
|
a734a1aaa3 | ||
|
|
bf1dc33782 | ||
|
|
c43798c5dd | ||
|
|
12cf2f1104 | ||
|
|
5c95eed517 | ||
|
|
e81a9dd1b5 | ||
|
|
fd49ae319f | ||
|
|
f881e2a54b | ||
|
|
0ca79eead4 | ||
|
|
8a709c489a | ||
|
|
b1f5d35f73 | ||
|
|
5682d0e721 | ||
|
|
ab690215ef | ||
|
|
8f9601090c | ||
|
|
aebee651ac | ||
|
|
a56e588c8e | ||
|
|
27de18f8c9 | ||
|
|
5afcd0ad22 | ||
|
|
fec589dce5 | ||
|
|
4e613be960 | ||
|
|
8e2480a82d | ||
|
|
50eda78ca1 | ||
|
|
b3af0f880b | ||
|
|
9490374faa | ||
|
|
a340b62fdf | ||
|
|
0d1af8c635 | ||
|
|
377c9e6be6 | ||
|
|
b8ae5ccb02 | ||
|
|
f8362a4acb | ||
|
|
5ce3135f00 | ||
|
|
162971f7b3 | ||
|
|
49dd13002c | ||
|
|
1e5c879fc6 | ||
|
|
e369cbf493 | ||
|
|
a88270a22b | ||
|
|
4355f4fe2d | ||
|
|
0d9361734f | ||
|
|
7f75994906 | ||
|
|
e9d594ebcf | ||
|
|
0d1e2a92f6 | ||
|
|
1ed6d130b1 | ||
|
|
09267d2ffd | ||
|
|
3a6639f820 | ||
|
|
8b79b288eb | ||
|
|
a0cde80c52 | ||
|
|
458636d2b8 | ||
|
|
8b30af561e | ||
|
|
1fb2b9bf1d | ||
|
|
5c9fdb064d | ||
|
|
70047fe20e | ||
|
|
1c41582d79 | ||
|
|
9a854f6cc4 | ||
|
|
06ab88415a | ||
|
|
16f2b056ef | ||
|
|
a761e6f2d0 | ||
|
|
da7489cecd | ||
|
|
0472988645 | ||
|
|
7e0881f0ec | ||
|
|
f8fb4c8f54 | ||
|
|
ddcacbb6e5 | ||
|
|
9d7512e9ab | ||
|
|
2e31b4d046 | ||
|
|
c585ca7131 | ||
|
|
29e2ab1b4a | ||
|
|
8880294ee7 | ||
|
|
a8d3466b0e | ||
|
|
0ee000a8a0 | ||
|
|
0833d87f94 | ||
|
|
23836d7c3c | ||
|
|
5495451448 | ||
|
|
bb01c8973f | ||
|
|
2f4d4c6e38 | ||
|
|
8d99c3ab92 | ||
|
|
8f66e87099 | ||
|
|
3e778e6007 | ||
|
|
b2d6dd0254 | ||
|
|
589c4cf225 | ||
|
|
4b70cc52d6 | ||
|
|
cc1205c79d | ||
|
|
cccd0235cf | ||
|
|
17e51756ef | ||
|
|
13ce21843f | ||
|
|
151f43b95f | ||
|
|
055c77b38c | ||
|
|
8dc2d7a5e0 | ||
|
|
a71d5b3954 | ||
|
|
854a923fea | ||
|
|
496b467c1d | ||
|
|
056d5e7111 | ||
|
|
e43c172d96 | ||
|
|
0b56c3f026 | ||
|
|
5445d20ecd | ||
|
|
2f7443e4bd | ||
|
|
41cf99541d | ||
|
|
1a9663d432 | ||
|
|
b7dcdedf41 | ||
|
|
bf8f9d2be8 | ||
|
|
6d20ca27f6 | ||
|
|
3bb573b45f | ||
|
|
9b2d91c0f2 | ||
|
|
b002a69bf8 | ||
|
|
e341df1e26 | ||
|
|
35e8c1c407 | ||
|
|
d1a88ed8d6 | ||
|
|
10a7dfeb15 | ||
|
|
dbde5330bd | ||
|
|
9b817edd1a | ||
|
|
261d73410a | ||
|
|
555c78f536 | ||
|
|
0270a9c924 | ||
|
|
a45e278cda | ||
|
|
bdbee7f541 | ||
|
|
b453ee6598 | ||
|
|
716de24f1e | ||
|
|
c816ca4525 | ||
|
|
eb7d2dcaa1 | ||
|
|
e6d4cfba96 | ||
|
|
2a5d2d70ba | ||
|
|
e539ddceb9 | ||
|
|
00666da9c1 | ||
|
|
7ad9c385b5 | ||
|
|
e65fb189ce | ||
|
|
1afe409a79 | ||
|
|
dbf9c8be7d | ||
|
|
26188e6d8a | ||
|
|
d6c70554b3 | ||
|
|
5990a4285f | ||
|
|
08e9ac63b1 | ||
|
|
71a1f65be2 | ||
|
|
5862157a2c | ||
|
|
d4f17f2b73 | ||
|
|
ea1d534c29 | ||
|
|
069de0f9ea | ||
|
|
e871c7daee | ||
|
|
320fe11a66 | ||
|
|
5fdc09a5b9 | ||
|
|
46f1b33812 | ||
|
|
b44218fdcc | ||
|
|
4441ae1f0b | ||
|
|
1c3ee89ab4 | ||
|
|
ebc7964157 | ||
|
|
ad6c86d78a | ||
|
|
f3097496c6 | ||
|
|
ddeefad501 | ||
|
|
5cd453afeb | ||
|
|
03c3c192ed | ||
|
|
95790b9eff | ||
|
|
6bf7c751a1 | ||
|
|
1019bb8258 | ||
|
|
531155d016 | ||
|
|
47311d16cf | ||
|
|
ef3466787d | ||
|
|
b7fd116bd8 | ||
|
|
34ad740e07 | ||
|
|
79454d7a92 | ||
|
|
87cc397bc3 | ||
|
|
37602a2049 | ||
|
|
56ea380bb3 | ||
|
|
177ace1cee | ||
|
|
61e3fe21ff | ||
|
|
8dcca76ec9 | ||
|
|
1dd3a794f8 | ||
|
|
6c5dd245fe | ||
|
|
3b3ad65612 | ||
|
|
e6f798811d | ||
|
|
371e8ab6ca | ||
|
|
69c19e946c | ||
|
|
d7edbf93f0 | ||
|
|
fb4d920fba | ||
|
|
5a072fbd10 | ||
|
|
79c9d8f4f4 | ||
|
|
871bf5a70a | ||
|
|
e4af235ce9 | ||
|
|
00384a60f3 | ||
|
|
f7b3ff4b34 | ||
|
|
eaa48306fc | ||
|
|
f5572b8447 | ||
|
|
a756751cc6 | ||
|
|
b8a3af090d | ||
|
|
d534cb96a9 | ||
|
|
f1e1d3bc07 | ||
|
|
694be54428 | ||
|
|
76531fb1cd | ||
|
|
716f4c5cf7 | ||
|
|
ba2d4b6859 | ||
|
|
2ec5e47328 | ||
|
|
b3f70538a9 | ||
|
|
de115ff466 | ||
|
|
129f02b36b | ||
|
|
1a8d219197 | ||
|
|
80c8d85cb9 | ||
|
|
db02f5f07f | ||
|
|
579294b0f1 | ||
|
|
f83d0d471d | ||
|
|
3b7d7bdb04 | ||
|
|
05958f5195 | ||
|
|
6cf4b81de9 | ||
|
|
689449df9e | ||
|
|
dae938de6f | ||
|
|
f6617ff77d | ||
|
|
defdc2ea6b | ||
|
|
1fd6571a87 | ||
|
|
4c0250f9f8 | ||
|
|
0e1735e7a9 | ||
|
|
a698e434fd | ||
|
|
95f658336c | ||
|
|
69dc4d97b3 | ||
|
|
4aeb63c16e | ||
|
|
e5efadf99e | ||
|
|
d117d5794d | ||
|
|
d09a2182e0 | ||
|
|
b8b09820b1 | ||
|
|
2cfd7babb3 | ||
|
|
161a9b340c | ||
|
|
605253446a | ||
|
|
f8d9b1508e | ||
|
|
3c4de3c8b5 | ||
|
|
a6c9bf1b15 | ||
|
|
bf6ec67528 | ||
|
|
289ba68824 | ||
|
|
2dfe01963a | ||
|
|
5ed1d5c19f | ||
|
|
db4479e720 | ||
|
|
66275d3b94 | ||
|
|
57f2c3f823 | ||
|
|
afba4c9915 | ||
|
|
f0d18d2cb3 | ||
|
|
da45bcf448 | ||
|
|
3a54246b15 | ||
|
|
2b06f20f41 | ||
|
|
88f44b2e77 | ||
|
|
4dff067e0b | ||
|
|
d81bf8a518 | ||
|
|
adfaf39489 | ||
|
|
f6a15905d7 | ||
|
|
52b8c5f151 | ||
|
|
c4eab5db86 | ||
|
|
4b1c76e307 | ||
|
|
e476a5f6f1 | ||
|
|
9fb4f5ef52 | ||
|
|
e232c5c561 | ||
|
|
803a5776ae | ||
|
|
a6dfcafdab | ||
|
|
8f2c7b7913 | ||
|
|
2ab647efe1 | ||
|
|
04eb421186 | ||
|
|
6a3a66975c | ||
|
|
1ef4fa970f | ||
|
|
b34523e196 | ||
|
|
09985453aa | ||
|
|
159a6e1cad | ||
|
|
b429949dd9 | ||
|
|
b9f601dfb4 | ||
|
|
5b488b72b1 | ||
|
|
03044bcb68 | ||
|
|
7bc3dace4c | ||
|
|
c2ec142ce3 | ||
|
|
2d39a6df8d | ||
|
|
5265d0234f | ||
|
|
4fc88f23e9 | ||
|
|
5412bb2dc8 | ||
|
|
b661d52477 | ||
|
|
43ce81af67 | ||
|
|
b8d1185f7f | ||
|
|
0fa8290ed3 | ||
|
|
519e3f014d | ||
|
|
d38f8544d5 | ||
|
|
089a92157f | ||
|
|
db246900a6 | ||
|
|
a0f389fc3e | ||
|
|
d0188db4f9 | ||
|
|
f537984bbf | ||
|
|
7e6c0e3894 | ||
|
|
b930c7253a | ||
|
|
c1afe70d98 | ||
|
|
3f9ddb915e | ||
|
|
c3edc7f449 | ||
|
|
9b272c8021 | ||
|
|
6d1221164b | ||
|
|
647132625c | ||
|
|
a17a98a75f | ||
|
|
59707b3a8f | ||
|
|
fa378ab4e4 | ||
|
|
05ffb1acad | ||
|
|
a1ba5c59b2 | ||
|
|
1bc68c20fc | ||
|
|
d308e7ca46 | ||
|
|
2b5433dc6e | ||
|
|
86a23f9b14 | ||
|
|
0ba5840a65 | ||
|
|
93646b964e | ||
|
|
13be8d297c | ||
|
|
a4b97121ab | ||
|
|
660f9c205b | ||
|
|
28852ce7d7 | ||
|
|
656ca1f3b5 | ||
|
|
b8f7715a74 | ||
|
|
096ed396c8 | ||
|
|
3b6d0b3d15 | ||
|
|
75cd21da1f | ||
|
|
b8eb22d162 | ||
|
|
9b461735f4 | ||
|
|
63bf49b3c4 | ||
|
|
559848299c | ||
|
|
8510273216 | ||
|
|
2392060bc1 | ||
|
|
2d7998de59 | ||
|
|
40638688b2 | ||
|
|
ea22b2fc6d | ||
|
|
1182218787 | ||
|
|
14f7c5610e | ||
|
|
27579b99a3 | ||
|
|
c58021e645 | ||
|
|
1810cc7ac7 | ||
|
|
86f73eecca | ||
|
|
3d6ce8a77f | ||
|
|
670be29d7b | ||
|
|
2b3e506583 | ||
|
|
6e6cfdd02b | ||
|
|
a18093e255 | ||
|
|
a35636999d | ||
|
|
13a3d38e4f | ||
|
|
9f00fb0f05 | ||
|
|
6cddcd6f0d | ||
|
|
c6d1cfeceb | ||
|
|
de43c27b3c | ||
|
|
747b5ea25e | ||
|
|
dd2e98fca2 | ||
|
|
eb621be646 | ||
|
|
d223a4f4db | ||
|
|
7aa182e33d | ||
|
|
7fec503b72 | ||
|
|
083a11a563 | ||
|
|
944f3695c4 | ||
|
|
dfc8691262 | ||
|
|
395b598bb1 | ||
|
|
d04b434d96 | ||
|
|
f041503a85 | ||
|
|
500207f7b8 | ||
|
|
1e0a79ebb7 | ||
|
|
301fa2a957 | ||
|
|
46f4f63212 | ||
|
|
fec8b5f731 | ||
|
|
777231ea79 | ||
|
|
0e36ed35a3 | ||
|
|
f1af646cee | ||
|
|
fc0621646b | ||
|
|
575800dcff | ||
|
|
0ca849a61a | ||
|
|
53e8a92fed | ||
|
|
fc650cd127 | ||
|
|
b03519b09c | ||
|
|
39b9f818be | ||
|
|
7febe05ed5 | ||
|
|
2c42e4e12e | ||
|
|
dcb3b3b5d1 | ||
|
|
5331732236 | ||
|
|
dc973ae670 | ||
|
|
100db2bcfd | ||
|
|
c84a58ff7d | ||
|
|
2d7fda1b2f | ||
|
|
3cba5f70fd | ||
|
|
b4c7cac964 | ||
|
|
5ef80d2490 | ||
|
|
3b798cf943 | ||
|
|
50b7756159 | ||
|
|
15606770ca | ||
|
|
f403a8da34 | ||
|
|
20075ae68d | ||
|
|
91a743623a | ||
|
|
e23a290812 | ||
|
|
dee68559ab | ||
|
|
9f42e330b4 | ||
|
|
ad63b8b1b4 | ||
|
|
0d8a2b310f | ||
|
|
3977575563 | ||
|
|
47244cb770 | ||
|
|
57aaf5a26b | ||
|
|
352d686d94 | ||
|
|
f6e448c1ba | ||
|
|
270b0ae74e | ||
|
|
8401d85f78 | ||
|
|
32fbf2e9eb | ||
|
|
8cdd4e317d | ||
|
|
97d95ea794 | ||
|
|
cbbebb3264 | ||
|
|
8b108905a3 | ||
|
|
5b40ec400e | ||
|
|
29e661e1fe | ||
|
|
b466ec75a4 | ||
|
|
c8cd755451 | ||
|
|
faac303eff | ||
|
|
ced87be57b | ||
|
|
811703ab60 | ||
|
|
bc1f767123 | ||
|
|
7055dc514b | ||
|
|
e02f3d3ec9 | ||
|
|
68a49befc8 | ||
|
|
c8b0d2bfae | ||
|
|
39993810b3 | ||
|
|
45180115a6 | ||
|
|
353c48d8d8 | ||
|
|
da36941252 | ||
|
|
8ec78900c5 | ||
|
|
a0e0fbad58 | ||
|
|
75e7ba8b1e | ||
|
|
74c30b5a66 | ||
|
|
e67bdbbc32 | ||
|
|
9554c8f783 | ||
|
|
e36a42f356 | ||
|
|
9d1960232c | ||
|
|
d3547544bf | ||
|
|
9cb42606ba | ||
|
|
7772afce1c | ||
|
|
10e76257c6 | ||
|
|
9235ab6414 | ||
|
|
59356f0029 | ||
|
|
9ae14015a1 | ||
|
|
0b131e91c1 | ||
|
|
77b12eafde | ||
|
|
050778460d | ||
|
|
28bc9c1d4f | ||
|
|
5e7aaa667b | ||
|
|
1afc495920 | ||
|
|
cf7d877714 | ||
|
|
81831da67a | ||
|
|
fcd2fcae67 | ||
|
|
1c33b0aea8 | ||
|
|
fc06163b5a | ||
|
|
72f0a6fb66 | ||
|
|
6f5a322927 | ||
|
|
a7f8e4ee2b | ||
|
|
0850872b0f | ||
|
|
1d886156d5 | ||
|
|
faa2a978c0 | ||
|
|
38faffa907 | ||
|
|
65a792be3a | ||
|
|
876354e58e | ||
|
|
14b33bc34d | ||
|
|
9044aa8740 | ||
|
|
07ac14f810 | ||
|
|
0370f0a3ea | ||
|
|
33ede13eef | ||
|
|
e032bfcf6b | ||
|
|
f4014c475d | ||
|
|
f394de664a | ||
|
|
d2eea64528 | ||
|
|
d7b5e6a36c | ||
|
|
b49b9e3ca0 | ||
|
|
1322bb3bf3 | ||
|
|
13a046a679 | ||
|
|
e6d2056438 | ||
|
|
a6b0c57ce0 | ||
|
|
fc14e346b9 | ||
|
|
5525145906 | ||
|
|
74d87790b8 | ||
|
|
8ce796756f | ||
|
|
a412989f7e | ||
|
|
ae02dc203e | ||
|
|
fc7595a464 | ||
|
|
4ceaea7732 |
@@ -1,8 +1,8 @@
|
||||
.DS_Store
|
||||
ui/node_modules
|
||||
ui/build
|
||||
Jamstash-master
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
data
|
||||
*.db
|
||||
testDB
|
||||
@@ -10,4 +10,4 @@ navidrome
|
||||
navidrome.db
|
||||
navidrome.toml
|
||||
assets/*gen.go
|
||||
dist
|
||||
|
||||
|
||||
2
.git-blame-ignore-revs
Normal file
2
.git-blame-ignore-revs
Normal file
@@ -0,0 +1,2 @@
|
||||
# Upgrade Prettier to 2.0.4. Reformatted all JS files
|
||||
b3f70538a9138bc279a451f4f358605097210d41
|
||||
12
.github/FUNDING.yml
vendored
Normal file
12
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: deluan
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
liberapay: deluan
|
||||
ko_fi: 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
|
||||
otechie: # Replace with a single Otechie username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
53
.github/workflows/build.yml
vendored
53
.github/workflows/build.yml
vendored
@@ -1,53 +0,0 @@
|
||||
name: Build
|
||||
on: [push]
|
||||
jobs:
|
||||
go:
|
||||
name: Test Server on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
# TODO Fix tests in Windows
|
||||
# os: [macOS-latest, ubuntu-latest, windows-latest]
|
||||
os: [macOS-latest, ubuntu-latest]
|
||||
|
||||
steps:
|
||||
- name: Set up Go 1.13
|
||||
uses: actions/setup-go@v1
|
||||
with:
|
||||
go-version: 1.13
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Test
|
||||
run: go test -cover ./... -v
|
||||
|
||||
js:
|
||||
name: Test UI
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 13
|
||||
|
||||
- name: npm install dependencies
|
||||
run: |
|
||||
cd ui
|
||||
npm ci
|
||||
|
||||
# TODO: Enable when there are tests to run
|
||||
# - name: npm test
|
||||
# run: |
|
||||
# cd ui
|
||||
# CI=test npm test
|
||||
|
||||
- name: npm build
|
||||
run: |
|
||||
cd ui
|
||||
npm run build
|
||||
22
.github/workflows/docker-tags.sh
vendored
Executable file
22
.github/workflows/docker-tags.sh
vendored
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
|
||||
GIT_TAG="${GITHUB_REF##refs/tags/}"
|
||||
GIT_BRANCH="${GITHUB_REF##refs/heads/}"
|
||||
GIT_SHA=$(git rev-parse --short HEAD)
|
||||
PR_NUM=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH")
|
||||
|
||||
DOCKER_IMAGE_TAG="--tag ${DOCKER_IMAGE}:sha-${GIT_SHA}"
|
||||
|
||||
if [[ $PR_NUM != "null" ]]; then
|
||||
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG} --tag ${DOCKER_IMAGE}:pr-${PR_NUM}"
|
||||
fi
|
||||
|
||||
if [[ $GITHUB_REF != "$GIT_TAG" ]]; then
|
||||
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG} --tag ${DOCKER_IMAGE}:${GIT_TAG#v} --tag ${DOCKER_IMAGE}:latest"
|
||||
elif [[ $GITHUB_REF == "refs/heads/master" ]]; then
|
||||
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG} --tag ${DOCKER_IMAGE}:develop"
|
||||
elif [[ $GIT_BRANCH = feature/* ]]; then
|
||||
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG} --tag ${DOCKER_IMAGE}:$(echo $GIT_BRANCH | tr / -)"
|
||||
fi
|
||||
|
||||
echo ${DOCKER_IMAGE_TAG}
|
||||
37
.github/workflows/pipeline.dockerfile
vendored
Normal file
37
.github/workflows/pipeline.dockerfile
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
#####################################################
|
||||
### Copy platform specific binary
|
||||
FROM bash as copy-binary
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
RUN echo "Target Platform = ${TARGETPLATFORM}"
|
||||
|
||||
COPY dist .
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then cp navidrome_linux_amd64_linux_amd64/navidrome /navidrome; fi
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then cp navidrome_linux_arm64_linux_arm64/navidrome /navidrome; fi
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then cp navidrome_linux_arm_linux_arm_6/navidrome /navidrome; fi
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then cp navidrome_linux_arm_linux_arm_7/navidrome /navidrome; fi
|
||||
RUN chmod +x /navidrome
|
||||
|
||||
|
||||
#####################################################
|
||||
### Build Final Image
|
||||
FROM alpine as release
|
||||
LABEL maintainer="deluan@navidrome.org"
|
||||
|
||||
# Install ffmpeg and output build config
|
||||
RUN apk add --no-cache ffmpeg
|
||||
RUN ffmpeg -buildconf
|
||||
|
||||
COPY --from=copy-binary /navidrome /app/
|
||||
|
||||
VOLUME ["/data", "/music"]
|
||||
ENV ND_MUSICFOLDER /music
|
||||
ENV ND_DATAFOLDER /data
|
||||
ENV ND_PORT 4533
|
||||
ENV GODEBUG "asyncpreemptoff=1"
|
||||
|
||||
EXPOSE ${ND_PORT}
|
||||
HEALTHCHECK CMD wget -O- http://localhost:${ND_PORT}/ping || exit 1
|
||||
WORKDIR /app
|
||||
|
||||
ENTRYPOINT ["/app/navidrome"]
|
||||
169
.github/workflows/pipeline.yml
vendored
Normal file
169
.github/workflows/pipeline.yml
vendored
Normal file
@@ -0,0 +1,169 @@
|
||||
name: Pipeline
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
jobs:
|
||||
golangci-lint:
|
||||
name: Lint Server
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v1
|
||||
with:
|
||||
version: v1.27
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
go:
|
||||
name: Test Server on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
# TODO Fix tests in Windows
|
||||
# os: [macOS-latest, ubuntu-latest, windows-latest]
|
||||
os: [macOS-latest, ubuntu-latest]
|
||||
|
||||
steps:
|
||||
- name: Set up Go 1.14
|
||||
uses: actions/setup-go@v1
|
||||
with:
|
||||
go-version: 1.14
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- uses: actions/cache@v1
|
||||
id: cache-go
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Download dependencies
|
||||
if: steps.cache-go.outputs.cache-hit != 'true'
|
||||
run: go mod download
|
||||
|
||||
- name: Test
|
||||
run: go test -cover ./... -v
|
||||
js:
|
||||
name: Build JS bundle
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 14
|
||||
|
||||
- uses: actions/cache@v1
|
||||
id: cache-npm
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('ui/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- name: npm install dependencies
|
||||
run: |
|
||||
cd ui
|
||||
npm ci
|
||||
|
||||
- name: npm check-formatting
|
||||
run: |
|
||||
cd ui
|
||||
npm run check-formatting
|
||||
|
||||
- name: npm build
|
||||
run: |
|
||||
cd ui
|
||||
npm run build
|
||||
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: js-bundle
|
||||
path: ui/build
|
||||
|
||||
binaries:
|
||||
name: Binaries
|
||||
needs: [js, go, golangci-lint]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- 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
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
args: goreleaser release --rm-dist --skip-publish --snapshot
|
||||
|
||||
- name: Run GoReleaser - RELEASE
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: docker://deluan/ci-goreleaser:1.14.4-2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
args: goreleaser release --rm-dist
|
||||
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: binaries
|
||||
path: |
|
||||
dist
|
||||
!dist/*.tar.gz
|
||||
!dist/*.zip
|
||||
|
||||
docker:
|
||||
name: Docker images
|
||||
needs: [binaries]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DOCKER_IMAGE: ${{secrets.DOCKER_IMAGE}}
|
||||
steps:
|
||||
- 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
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
|
||||
- uses: actions/download-artifact@v2
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
with:
|
||||
name: binaries
|
||||
path: dist
|
||||
|
||||
- name: Build the Docker image and push
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
env:
|
||||
DOCKER_IMAGE: ${{secrets.DOCKER_IMAGE}}
|
||||
DOCKER_PLATFORM: linux/amd64,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 .
|
||||
32
.github/workflows/release.yml
vendored
32
.github/workflows/release.yml
vendored
@@ -1,32 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
create:
|
||||
tags:
|
||||
- v*.*.*
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 13
|
||||
- name: Build UI
|
||||
run: |
|
||||
cd ui
|
||||
npm ci
|
||||
npm run build
|
||||
- name: Fetch tags
|
||||
run: git fetch --depth=1 origin +refs/tags/*:refs/tags/*
|
||||
- name: Run GoReleaser
|
||||
uses: docker://bepsays/ci-goreleaser:latest
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
args: goreleaser release --rm-dist
|
||||
18
.github/workflows/remove-old-artifacts.yml
vendored
Normal file
18
.github/workflows/remove-old-artifacts.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
name: Remove old artifacts
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every day at 1am
|
||||
- cron: '0 1 * * *'
|
||||
|
||||
jobs:
|
||||
remove-old-artifacts:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Remove old artifacts
|
||||
uses: c-hive/gha-remove-artifacts@v1
|
||||
with:
|
||||
age: '7 days'
|
||||
skip-tags: false
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
.DS_Store
|
||||
.idea
|
||||
.vscode
|
||||
.envrc
|
||||
/navidrome
|
||||
/iTunes*.xml
|
||||
@@ -9,14 +10,17 @@ vendor/*/
|
||||
wiki
|
||||
TODO.md
|
||||
var
|
||||
Artwork
|
||||
navidrome.toml
|
||||
master.zip
|
||||
Jamstash-master
|
||||
testDB
|
||||
navidrome.db
|
||||
*.swp
|
||||
*_gen.go
|
||||
embedded_gen.go
|
||||
dist
|
||||
music
|
||||
docker-compose.override.yml
|
||||
docker-compose.yml
|
||||
navidrome.db-shm
|
||||
navidrome.db-wal
|
||||
tags
|
||||
|
||||
|
||||
29
.golangci.yml
Normal file
29
.golangci.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
linters:
|
||||
enable:
|
||||
- bodyclose
|
||||
- deadcode
|
||||
- dogsled
|
||||
- errcheck
|
||||
- gocyclo
|
||||
- goimports
|
||||
- goprintffuncname
|
||||
- gosec
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- interfacer
|
||||
- misspell
|
||||
- rowserrcheck
|
||||
- staticcheck
|
||||
- structcheck
|
||||
- typecheck
|
||||
- unconvert
|
||||
- unused
|
||||
- varcheck
|
||||
- whitespace
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
- linters:
|
||||
- gosec
|
||||
text: "(G501|G401):"
|
||||
@@ -1,10 +1,10 @@
|
||||
# GoReleaser config
|
||||
project_name: navidrome
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go get -u github.com/go-bindata/go-bindata/...
|
||||
- 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/...
|
||||
- git checkout .
|
||||
|
||||
builds:
|
||||
- id: navidrome_darwin
|
||||
@@ -19,9 +19,9 @@ builds:
|
||||
flags:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
|
||||
- -s -w -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
- id: navidrome_linux
|
||||
- id: navidrome_linux_amd64
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
goos:
|
||||
@@ -32,7 +32,39 @@ builds:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- "-extldflags '-static'"
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
|
||||
- -s -w -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
- id: navidrome_linux_arm
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=arm-linux-gnueabi-gcc-5
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- arm
|
||||
goarm:
|
||||
- 5
|
||||
- 6
|
||||
- 7
|
||||
flags:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- "-extldflags '-static'"
|
||||
- -s -w -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
- id: navidrome_linux_arm64
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=aarch64-linux-gnu-gcc-5
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- arm64
|
||||
flags:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- "-extldflags '-static'"
|
||||
- -s -w -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
- id: navidrome_windows_i686
|
||||
env:
|
||||
@@ -47,7 +79,7 @@ builds:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- "-extldflags '-static'"
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
|
||||
- -s -w -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
- id: navidrome_windows_x64
|
||||
env:
|
||||
@@ -62,24 +94,30 @@ builds:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- "-extldflags '-static'"
|
||||
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
|
||||
- -s -w -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
archives:
|
||||
-
|
||||
format_overrides:
|
||||
- format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
replacements:
|
||||
darwin: macOS
|
||||
linux: Linux
|
||||
windows: Windows
|
||||
386: i386
|
||||
amd64: x86_64
|
||||
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
name_template: "{{ .ProjectName }}_checksums.txt"
|
||||
|
||||
snapshot:
|
||||
name_template: "{{ .Tag }}-next"
|
||||
name_template: "{{ .Tag }}-SNAPSHOT"
|
||||
|
||||
release:
|
||||
draft: true
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
# sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- '^docs:'
|
||||
- "^docs:"
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
|
||||
### Supported Subsonic API endpoints
|
||||
|
||||
Navidrome is currently compatible with [Subsonic API](http://www.subsonic.org/pages/api.jsp) v1.8.0, with some exceptions.
|
||||
|
||||
This is an (almost) up to date list of all Subsonic API endpoints implemented by Navidrome.
|
||||
Check the "Notes" column for limitations/missing behaviour. Also keep in mind these differences between
|
||||
Navidrome and Subsonic:
|
||||
|
||||
* Right now, Navidrome only works with a single Music Library (Music Folder)
|
||||
* Navidrome does not mark songs as played by calls to `stream`, only when
|
||||
`scrobble` is called with `submission=true`
|
||||
* Next features to be implemented: Playlists (WIP), MultiUser (WIP), Jukebox, Sharing, Podcasts, Bookmarks, Internet Radio.
|
||||
|
||||
Navidrome is actively being tested with:
|
||||
[DSub](http://www.subsonic.org/pages/apps.jsp#dsub),
|
||||
[Music Stash](https://play.google.com/store/apps/details?id=com.ghenry22.mymusicstash) and
|
||||
[Jamstash](http://www.subsonic.org/pages/apps.jsp#jamstash))
|
||||
|
||||
|
||||
| ENDPOINT | NOTES |
|
||||
|------------------------|-------|
|
||||
| _SYSTEM_ ||
|
||||
| `ping` | |
|
||||
| `getLicense` | Always valid ;) |
|
||||
| ||
|
||||
| _BROWSING_ ||
|
||||
| `getMusicFolders` | Hardcoded to just one, set with ND_MUSICFOLDER configuration |
|
||||
| `getIndexes` | Doesn't support shortcuts, nor direct children |
|
||||
| `getMusicDirectory` | |
|
||||
| `getSong` | |
|
||||
| `getArtists` | |
|
||||
| `getArtist` | |
|
||||
| `getAlbum` | |
|
||||
| `getGenres` | |
|
||||
| ||
|
||||
| _ALBUM/SONGS LISTS_ ||
|
||||
| `getAlbumList` | `byYear` and `byGenre` are not implemented |
|
||||
| `getAlbumList2` | `byYear` and `byGenre` are not implemented |
|
||||
| `getStarred` | |
|
||||
| `getStarred2` | |
|
||||
| `getNowPlaying` | |
|
||||
| `getRandomSongs` | Ignores `fromYear` and `toYear` parameters |
|
||||
| ||
|
||||
| _SEARCHING_ ||
|
||||
| `search2` | Doesn't support Lucene queries, only simple auto complete queries |
|
||||
| `search3` | Doesn't support Lucene queries, only simple auto complete queries |
|
||||
| ||
|
||||
| _PLAYLISTS_ ||
|
||||
| `getPlaylists` | `username` parameter is not implemented |
|
||||
| `getPlaylist` | |
|
||||
| `createPlaylist` | Return empty response on success |
|
||||
| `updatePlaylist` | `comment` and `public` are not implemented. All playlists are public |
|
||||
| `deletePlaylist` | |
|
||||
| ||
|
||||
| _MEDIA RETRIEVAL_ ||
|
||||
| `stream` | No Transcoding/Downsampling support (for now)|
|
||||
| `download` | |
|
||||
| `getCoverArt` | Only gets embedded artwork |
|
||||
| `getAvatar` | Always returns the same image |
|
||||
| ||
|
||||
| _MEDIA ANNOTATION_ ||
|
||||
| `star` | |
|
||||
| `unstar` | |
|
||||
| `setRating` | Doesn't work with artists |
|
||||
| `scrobble` | No Last.FM support yet. It is used to update play count and last played |
|
||||
| ||
|
||||
| _USER MANAGEMENT_ ||
|
||||
| `getUser` | Hardcoded all roles, ignores `username` parameter|
|
||||
129
CODE_OF_CONDUCT.md
Normal file
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.
|
||||
73
Dockerfile
73
Dockerfile
@@ -1,73 +0,0 @@
|
||||
#####################################################
|
||||
### Build UI bundles
|
||||
FROM node:13.7-alpine AS jsbuilder
|
||||
WORKDIR /src
|
||||
COPY ui/package.json ui/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY ui/ .
|
||||
RUN npm run build
|
||||
|
||||
|
||||
#####################################################
|
||||
### Build executable
|
||||
FROM golang:1.13-alpine AS gobuilder
|
||||
|
||||
# Download build tools
|
||||
RUN mkdir -p /src/ui/build
|
||||
RUN apk add -U --no-cache build-base git
|
||||
RUN go get -u github.com/go-bindata/go-bindata/...
|
||||
|
||||
# Download and unpack static ffmpeg
|
||||
ARG FFMPEG_URL=https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
|
||||
RUN wget -O /tmp/ffmpeg.tar.xz ${FFMPEG_URL}
|
||||
RUN cd /tmp && tar xJf ffmpeg.tar.xz && rm ffmpeg.tar.xz
|
||||
|
||||
# Download project dependencies
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source, test it
|
||||
COPY . .
|
||||
RUN go test ./...
|
||||
|
||||
# Copy UI bundle, build executable
|
||||
COPY --from=jsbuilder /src/build/* /src/ui/build/
|
||||
COPY --from=jsbuilder /src/build/static/css/* /src/ui/build/static/css/
|
||||
COPY --from=jsbuilder /src/build/static/js/* /src/ui/build/static/js/
|
||||
RUN rm -rf /src/build/css /src/build/js
|
||||
RUN GIT_TAG=$(git name-rev --name-only HEAD) && \
|
||||
GIT_TAG=${GIT_TAG#"tags/"} && \
|
||||
GIT_SHA=$(git rev-parse --short HEAD) && \
|
||||
echo "Building version: ${GIT_TAG} (${GIT_SHA})" && \
|
||||
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}" -tags=embed
|
||||
|
||||
#####################################################
|
||||
### Build Final Image
|
||||
FROM alpine as release
|
||||
MAINTAINER Deluan Quintao <navidrome@deluan.com>
|
||||
|
||||
# Download Tini
|
||||
ENV TINI_VERSION v0.18.0
|
||||
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini-static /tini
|
||||
RUN chmod +x /tini
|
||||
|
||||
COPY --from=gobuilder /src/navidrome /app/
|
||||
COPY --from=gobuilder /tmp/ffmpeg*/ffmpeg /usr/bin/
|
||||
|
||||
# Check if ffmpeg runs properly
|
||||
RUN ffmpeg -buildconf
|
||||
|
||||
VOLUME ["/data", "/music"]
|
||||
ENV ND_MUSICFOLDER /music
|
||||
ENV ND_DATAFOLDER /data
|
||||
ENV ND_SCANINTERVAL 1m
|
||||
ENV ND_LOGLEVEL info
|
||||
ENV ND_PORT 4533
|
||||
|
||||
EXPOSE 4533
|
||||
WORKDIR /app
|
||||
|
||||
ENTRYPOINT ["/tini", "--"]
|
||||
CMD ["/app/navidrome"]
|
||||
89
Makefile
89
Makefile
@@ -1,74 +1,98 @@
|
||||
GO_VERSION=1.13
|
||||
NODE_VERSION=v13.7.0
|
||||
GO_VERSION=$(shell grep -e "^go " go.mod | cut -f 2 -d ' ')
|
||||
NODE_VERSION=$(shell cat .nvmrc)
|
||||
|
||||
GIT_SHA=$(shell git rev-parse --short HEAD)
|
||||
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"
|
||||
.PHONY: default
|
||||
|
||||
.PHONY: dev
|
||||
dev: check_env
|
||||
@goreman -f Procfile.dev -b 4533 start
|
||||
npx foreman -j Procfile.dev -p 4533 start
|
||||
.PHONY: dev
|
||||
|
||||
.PHONY: server
|
||||
server: check_go_env
|
||||
@reflex -d none -c reflex.conf
|
||||
.PHONY: server
|
||||
|
||||
wire: check_go_env
|
||||
wire ./...
|
||||
.PHONY: wire
|
||||
|
||||
.PHONY: watch
|
||||
watch: check_go_env
|
||||
ginkgo watch -notify ./...
|
||||
.PHONY: watch
|
||||
|
||||
.PHONY: test
|
||||
test: check_go_env
|
||||
go test ./... -v
|
||||
# @(cd ./ui && npm test -- --watchAll=false)
|
||||
.PHONY: test
|
||||
|
||||
.PHONY: testall
|
||||
testall: check_go_env test
|
||||
@(cd ./ui && npm test -- --watchAll=false)
|
||||
.PHONY: testall
|
||||
|
||||
.PHONY: setup
|
||||
setup: Jamstash-master
|
||||
@which goconvey || (echo "Installing GoConvey" && GO111MODULE=off go get -u github.com/smartystreets/goconvey)
|
||||
@which wire || (echo "Installing Wire" && GO111MODULE=off go get -u github.com/google/wire/cmd/wire)
|
||||
update-snapshots: check_go_env
|
||||
UPDATE_SNAPSHOTS=true ginkgo ./server/subsonic/...
|
||||
.PHONY: update-snapshots
|
||||
|
||||
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: migration
|
||||
|
||||
setup:
|
||||
@which go-bindata || (echo "Installing BinData" && GO111MODULE=off go get -u github.com/go-bindata/go-bindata/...)
|
||||
@which reflex || (echo "Installing Reflex" && GO111MODULE=off go get -u github.com/cespare/reflex)
|
||||
@which goreman || (echo "Installing Goreman" && GO111MODULE=off go get -u github.com/mattn/goreman)
|
||||
@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)
|
||||
go mod download
|
||||
@(cd ./ui && npm ci)
|
||||
.PHONY: setup
|
||||
|
||||
.PHONY: static
|
||||
static:
|
||||
cd static && go-bindata -fs -prefix "static" -nocompress -ignore="\\\*.go" -pkg static .
|
||||
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:
|
||||
@mkdir -p .git/hooks
|
||||
(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
|
||||
|
||||
.PHONE: check_env
|
||||
check_env: check_go_env check_node_env
|
||||
.PHONY: check_env
|
||||
|
||||
.PHONY: check_go_env
|
||||
check_go_env:
|
||||
@(hash go) || (echo "\nERROR: GO environment not setup properly!\n"; exit 1)
|
||||
@go version | grep -q $(GO_VERSION) || (echo "\nERROR: Please upgrade your GO version\n"; exit 1)
|
||||
@go version | grep -q $(GO_VERSION) || (echo "\nERROR: Please upgrade your GO version\nThis project requires version $(GO_VERSION)"; exit 1)
|
||||
.PHONY: check_go_env
|
||||
|
||||
.PHONY: check_node_env
|
||||
check_node_env:
|
||||
@(hash node) || (echo "\nERROR: Node environment not setup properly!\n"; exit 1)
|
||||
@node --version | grep -q $(NODE_VERSION) || (echo "\nERROR: Please check your Node version. Should be $(NODE_VERSION)\n"; exit 1)
|
||||
.PHONY: check_node_env
|
||||
|
||||
.PHONY: build
|
||||
build: check_go_env
|
||||
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/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT"
|
||||
.PHONY: build
|
||||
|
||||
.PHONY: buildall
|
||||
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=master" -tags=embed
|
||||
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT" -tags=embed
|
||||
.PHONY: buildall
|
||||
|
||||
.PHONY: release
|
||||
release:
|
||||
@if [[ ! "${V}" =~ ^[0-9]+\.[0-9]+\.[0-9]+.*$$ ]]; then echo "Usage: make release V=X.X.X"; exit 1; fi
|
||||
go mod tidy
|
||||
@@ -76,7 +100,8 @@ release:
|
||||
make test
|
||||
git tag v${V}
|
||||
git push origin v${V}
|
||||
.PHONY: release
|
||||
|
||||
.PHONY: dist
|
||||
dist:
|
||||
docker run -it -v $(PWD):/github/workspace -w /github/workspace bepsays/ci-goreleaser:1.13-4 goreleaser release --rm-dist --skip-publish --snapshot
|
||||
snapshot:
|
||||
docker run -it -v $(PWD):/workspace -w /workspace deluan/ci-goreleaser:1.14.4-2 goreleaser release --rm-dist --skip-publish --snapshot
|
||||
.PHONY: snapshot
|
||||
|
||||
159
README.md
159
README.md
@@ -1,132 +1,65 @@
|
||||
# Navidrome Music Streamer
|
||||
|
||||
[](https://github.com/deluan/navidrome/actions)
|
||||
[](https://github.com/deluan/navidrome/releases)
|
||||
[](https://hub.docker.com/r/deluan/navidrome)
|
||||
[](https://discord.gg/xh7j7yF)
|
||||
[](https://github.com/deluan/navidrome/releases)
|
||||
[](https://github.com/deluan/navidrome/actions)
|
||||
[](https://github.com/deluan/navidrome/releases/latest)
|
||||
[](https://hub.docker.com/r/deluan/navidrome)
|
||||
[](https://discord.gg/xh7j7yF)
|
||||
[](https://www.reddit.com/r/navidrome/)
|
||||
[](code_of_conduct.md)
|
||||
|
||||
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 chat in our [Discord server](https://discord.gg/xh7j7yF)
|
||||
please fill a [GitHub issue](https://github.com/deluan/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
|
||||
|
||||
## Features
|
||||
|
||||
- Handles very large music collections
|
||||
- Streams virtually any audio format available
|
||||
- Reads and uses all your beautifully curated metadata (id3 tags)
|
||||
- Multi-user, each user has their own play counts, playlists, favourites, etc..
|
||||
- Very low resource usage: Ex: with a library of 300GB (~29000 songs), it uses less than 50MB of RAM
|
||||
- Multi-platform, runs on macOS, Linux and Windows. Docker images are also provided
|
||||
- Automatically monitors your library for changes, importing new files and reloading new metadata
|
||||
- Modern and responsive Web interface based on Material UI, to manage users and browse your library
|
||||
- Compatible with the huge selection of clients for [Subsonic](http://www.subsonic.org),
|
||||
[Airsonic](https://airsonic.github.io/) and [Madsonic](https://www.madsonic.org/).
|
||||
See the [complete list of available mobile and web apps](https://airsonic.github.io/docs/apps/)
|
||||
- Transcoding/Downsampling on-the-fly (WIP. Experimental support is available)
|
||||
- Integrated music player (WIP)
|
||||
|
||||
## Road map
|
||||
|
||||
This project is being actively worked on. Expect a more polished experience and new features/releases
|
||||
on a frequent basis. Some upcoming features planned:
|
||||
|
||||
- Last.FM integration
|
||||
- Pre-build binaries for Raspberry Pi
|
||||
- Smart/dynamic playlists (similar to iTunes)
|
||||
- Support for audiobooks (bookmarking)
|
||||
- Jukebox mode
|
||||
- Sharing links to albums/songs/playlists
|
||||
- Podcasts
|
||||
|
||||
To see Navidrome in action, check out our [live demo](https://www.navidrome.org/demo/)
|
||||
|
||||
## Installation
|
||||
|
||||
Various options are available:
|
||||
See instructions in the [project's website](https://www.navidrome.org/docs/installation/)
|
||||
|
||||
### Pre-built executables
|
||||
## Features
|
||||
|
||||
- Handles very **large music collections**
|
||||
- Streams virtually **any audio format** available
|
||||
- Reads and uses all your beautifully curated **metadata**
|
||||
- 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
|
||||
- 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)
|
||||
- **Transcoding** on the fly. Can be set per user/player. **Opus encoding is supported**
|
||||
- Translated to **various languages**
|
||||
|
||||
Just head to the [releases page](https://github.com/deluan/navidrome/releases) and download the latest version for you
|
||||
platform. There are builds available for Linux, macOS and Windows (32 and 64 bits).
|
||||
## Documentation
|
||||
All documentation can be found in the project's website: https://www.navidrome.org/docs.
|
||||
Here are some useful direct links:
|
||||
|
||||
Remember to install [ffmpeg](https://ffmpeg.org/download.html) in your system, a requirement for Navidrome to work properly.
|
||||
You may find the latest static build for your platform here: https://johnvansickle.com/ffmpeg/
|
||||
|
||||
If you have any issues with these binaries, or need a binary for a different platform, please
|
||||
[open an issue](https://github.com/deluan/navidrome/issues)
|
||||
|
||||
### Docker
|
||||
|
||||
[Docker images](https://hub.docker.com/r/deluan/navidrome) are available. They include everything needed to run Navidrome. Example of usage:
|
||||
|
||||
```yaml
|
||||
# This is just an example. Customize it to your needs.
|
||||
|
||||
version: "3"
|
||||
services:
|
||||
navidrome:
|
||||
image: deluan/navidrome:latest
|
||||
ports:
|
||||
- "4533:4533"
|
||||
environment:
|
||||
# All options with their default values:
|
||||
ND_MUSICFOLDER: /music
|
||||
ND_DATAFOLDER: /data
|
||||
ND_SCANINTERVAL: 1m
|
||||
ND_LOGLEVEL: info
|
||||
ND_PORT: 4533
|
||||
volumes:
|
||||
- "./data:/data"
|
||||
- "/path/to/your/music/folder:/music"
|
||||
```
|
||||
|
||||
To get the cutting-edge, latest version from master, use the image `deluan/navidrome:develop`
|
||||
|
||||
### Build from source
|
||||
|
||||
You will need to install [Go 1.13](https://golang.org/dl/) and [Node 13.7.0](http://nodejs.org).
|
||||
You'll also need [ffmpeg](https://ffmpeg.org) installed in your system. The setup is very strict, and
|
||||
the steps bellow only work with these specific versions (enforced in the Makefile)
|
||||
|
||||
After the prerequisites above are installed, clone this repository and build the application with:
|
||||
|
||||
```shell script
|
||||
$ git clone https://github.com/deluan/navidrome
|
||||
$ cd navidrome
|
||||
$ make setup # Install tools required for Navidrome's development
|
||||
$ make buildall # Build UI and server, generates a single executable
|
||||
```
|
||||
|
||||
This will generate the `navidrome` executable binary in the project's root folder.
|
||||
|
||||
### Running for the first time
|
||||
|
||||
Start the server with:
|
||||
```shell script
|
||||
./navidrome
|
||||
```
|
||||
The server should start listening for requests on the default port __4533__
|
||||
|
||||
After starting Navidrome for the first time, go to http://localhost:4533. It will ask you to create your first admin
|
||||
user.
|
||||
|
||||
For more options, run `navidrome --help`
|
||||
- [Overview](https://www.navidrome.org/docs/overview/)
|
||||
- [Installation](https://www.navidrome.org/docs/installation/)
|
||||
- [Docker](https://www.navidrome.org/docs/installation/docker/)
|
||||
- [Binaries](https://www.navidrome.org/docs/installation/pre-built-binaries/)
|
||||
- [Build from source](https://www.navidrome.org/docs/installation/build-from-source/)
|
||||
- [Development](https://www.navidrome.org/docs/developers/)
|
||||
- [Subsonic API Compatibility](https://www.navidrome.org/docs/developers/subsonic-api/)
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
<p float="left">
|
||||
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-login.png">
|
||||
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-player.png">
|
||||
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-album-view.png">
|
||||
<img width="900"src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-desktop-player.png">
|
||||
<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">
|
||||
</p>
|
||||
</p>
|
||||
|
||||
|
||||
## Subsonic API Version Compatibility
|
||||
|
||||
Check the up to date [compatibility table](https://github.com/deluan/navidrome/blob/master/API_COMPATIBILITY.md)
|
||||
for the latest Subsonic features available.
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
)
|
||||
|
||||
@@ -14,7 +13,7 @@ var once sync.Once
|
||||
|
||||
func AssetFile() http.FileSystem {
|
||||
once.Do(func() {
|
||||
log.Warn("Using external assets from " + consts.UIAssetsLocalPath)
|
||||
log.Warn("Using external assets from 'ui/build' folder")
|
||||
})
|
||||
return http.Dir(consts.UIAssetsLocalPath)
|
||||
return http.Dir("ui/build")
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Script to transfort .itc files into images (JPG or PNG)
|
||||
#
|
||||
# .itc files are located in ~/Music/iTunes/Album Artwork
|
||||
#
|
||||
# This script uses (/!\ needs ) ImageMagick's convert, hexdump, printf and dd.
|
||||
#
|
||||
# This script might be a little slow, You might want to look at Simon Kennedy's work at http://www.sffjunkie.co.uk/python-itc.html
|
||||
#
|
||||
# ~/{Library Path}/Album Artwork/Cache/D989408F65D05F99/04/13/04/D989408F65D05F99-EB5B7A9086F4B4D4.itc
|
||||
#
|
||||
# The filenames are an amalgam of the library ID (D989408F65D05F99) and the track's ID (EB5B7A9086F4B4D4).
|
||||
# The directory structure comes from the library ID and the last three digits of the track's ID converted to decimal,
|
||||
# ie 4D4 becomes 04, 13, 04.
|
||||
#
|
||||
|
||||
AlbumArtwork="${HOME}/Music/iTunes 1/Album Artwork"
|
||||
DestinationDir="Artwork"
|
||||
IFS=$'\n'
|
||||
|
||||
|
||||
if [ ! -d "$DestinationDir" ]; then
|
||||
mkdir "$DestinationDir"
|
||||
echo "new Images dir"
|
||||
fi
|
||||
|
||||
for file in `find "$AlbumArtwork" -name '*.itc'`; do
|
||||
start=0x11C
|
||||
exit=0;
|
||||
i=1;
|
||||
echo $file
|
||||
while [ 1 ]; do
|
||||
|
||||
typeOffset=$(($start+0x30))
|
||||
imageType=$(hexdump -n 4 -s $typeOffset -e '"0x"4/1 "%02x" "\n"' $file)
|
||||
|
||||
#If there is no next byte, jump to the next itc file.
|
||||
if [[ -z $imageType ]]; then
|
||||
break
|
||||
fi
|
||||
|
||||
imageOffsetOffset=$(($start+8))
|
||||
|
||||
itemSize=$(hexdump -n 4 -s $start -e '"0x"4/1 "%02x" "\n"' $file)
|
||||
imageOffset=$(hexdump -n 4 -s $imageOffsetOffset -e '"0x"4/1 "%02x" "\n"' $file)
|
||||
|
||||
imageStart=$(($start+$imageOffset))
|
||||
imageSize=$(($itemSize-imageOffset))
|
||||
|
||||
imageWidth=$(hexdump -n 4 -s $(($start+56)) -e '"0x"4/1 "%02x" "\n"' $file)
|
||||
imageWidth=$(printf "%d" $imageWidth)
|
||||
imageHeight=$(hexdump -n 4 -s $(($start+60)) -e '"0x"4/1 "%02x" "\n"' $file)
|
||||
imageHeight=$(printf "%d" $imageHeight)
|
||||
|
||||
dir=$(dirname "$file")
|
||||
xbase=${file##*/} #file.etc
|
||||
xpref=${xbase%.*} #file prefix
|
||||
|
||||
#echo $file
|
||||
#echo itemsize $itemSize
|
||||
#echo start $start
|
||||
#echo imageOffset $imageOffset
|
||||
#echo imageStart $imageStart
|
||||
#echo imageSize $imageSize
|
||||
#echo imageWidth $imageWidth
|
||||
#echo imageHeight $imageHeight
|
||||
|
||||
if [[ $imageType -eq 0x504E4766 ]] || [[ $imageType -eq 0x0000000E ]] ; then
|
||||
targetFile="$DestinationDir/$xpref-$i.png"
|
||||
if [ ! -f "$targetFile" ]; then
|
||||
echo PNG
|
||||
dd skip=$imageStart count=$imageSize if="$file" of="$targetFile" bs=1 &> /dev/null
|
||||
fi
|
||||
elif [[ $imageType -eq 0x41524762 ]] ; then
|
||||
targetFile="$DestinationDir/$xpref-$i.png"
|
||||
if [ ! -f "$targetFile" ]; then
|
||||
echo ARGB
|
||||
dd skip=$imageStart count=$imageSize if="$file" of="$TMPDIR/test$i" bs=1 &> /dev/null
|
||||
|
||||
#Using a matrix to convert ARGB to RGBA since imagemagick does only support rgba input
|
||||
convert -size $imageWidth"x"$imageHeight -depth 8 -color-matrix '0 1 0 0 0 0 1 0 0 0 0 1 1 0 0 0' rgba:"$TMPDIR/test$i" "$targetFile"
|
||||
fi
|
||||
elif [[ $imageType -eq 0x0000000D ]] ; then
|
||||
targetFile="$DestinationDir/$xpref-$i.jpg"
|
||||
if [ ! -f "$targetFile" ]; then
|
||||
echo JPG
|
||||
dd skip=$imageStart count=$imageSize if="$file" of="$targetFile" bs=1 &> /dev/null
|
||||
fi
|
||||
else
|
||||
echo $imageType
|
||||
exit=1
|
||||
break;
|
||||
fi
|
||||
|
||||
start=$(($start+$itemSize))
|
||||
i=$(($i+1))
|
||||
done
|
||||
done
|
||||
14
bin/fmt.sh
14
bin/fmt.sh
@@ -1,14 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
gofmtcmd=`which goimports || echo "gofmt"`
|
||||
|
||||
gofiles=$(git diff --name-only --diff-filter=ACM | grep '.go$')
|
||||
[ -z "$gofiles" ] && exit 0
|
||||
|
||||
unformatted=`$gofmtcmd -l $gofiles`
|
||||
[ -z "$unformatted" ] && exit 0
|
||||
|
||||
for f in $unformatted; do
|
||||
$gofmtcmd -w -l "$f"
|
||||
gofmt -s -w -l "$f"
|
||||
done
|
||||
96
cmd/root.go
Normal file
96
cmd/root.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/db"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var (
|
||||
cfgFile string
|
||||
noBanner bool
|
||||
|
||||
rootCmd = &cobra.Command{
|
||||
Use: "navidrome",
|
||||
Short: "Navidrome is a self-hosted music server and streamer",
|
||||
Long: `Navidrome is a self-hosted music server and streamer.
|
||||
Complete documentation is available at https://www.navidrome.org/docs`,
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
preRun()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
startServer()
|
||||
},
|
||||
Version: consts.Version(),
|
||||
}
|
||||
)
|
||||
|
||||
func Execute() {
|
||||
rootCmd.SetVersionTemplate(`{{println .Version}}`)
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func preRun() {
|
||||
if !noBanner {
|
||||
println(consts.Banner())
|
||||
}
|
||||
conf.Load()
|
||||
}
|
||||
|
||||
func startServer() {
|
||||
db.EnsureLatestVersion()
|
||||
|
||||
subsonic, err := CreateSubsonicAPIRouter()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Could not create the Subsonic API router. Aborting! err=%v", 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 init() {
|
||||
cobra.OnInitialize(func() {
|
||||
conf.InitConfig(cfgFile)
|
||||
})
|
||||
|
||||
rootCmd.PersistentFlags().StringVarP(&cfgFile, "configfile", "c", "", `config file (default "./navidrome.toml")`)
|
||||
rootCmd.PersistentFlags().BoolVarP(&noBanner, "nobanner", "n", false, `don't show banner`)
|
||||
rootCmd.PersistentFlags().String("musicfolder", viper.GetString("musicfolder"), "folder where your music is stored")
|
||||
rootCmd.PersistentFlags().String("datafolder", viper.GetString("datafolder"), "folder to store application data (DB, cache...), needs write access")
|
||||
rootCmd.PersistentFlags().StringP("loglevel", "l", viper.GetString("loglevel"), "log level, possible values: error, info, debug, trace")
|
||||
|
||||
_ = viper.BindPFlag("musicfolder", rootCmd.PersistentFlags().Lookup("musicfolder"))
|
||||
_ = viper.BindPFlag("datafolder", rootCmd.PersistentFlags().Lookup("datafolder"))
|
||||
_ = viper.BindPFlag("loglevel", rootCmd.PersistentFlags().Lookup("loglevel"))
|
||||
|
||||
rootCmd.Flags().StringP("address", "a", viper.GetString("address"), "IP address to bind")
|
||||
rootCmd.Flags().IntP("port", "p", viper.GetInt("port"), "HTTP port Navidrome will use")
|
||||
rootCmd.Flags().Duration("sessiontimeout", viper.GetDuration("sessiontimeout"), "how long Navidrome will wait before closing web ui idle sessions")
|
||||
rootCmd.Flags().Duration("scaninterval", viper.GetDuration("scaninterval"), "how frequently to scan for changes in your music library")
|
||||
rootCmd.Flags().String("baseurl", viper.GetString("baseurl"), "base URL (only the path part) to configure Navidrome behind a proxy (ex: /music)")
|
||||
rootCmd.Flags().String("uiloginbackgroundurl", viper.GetString("uiloginbackgroundurl"), "URL to a backaground image used in the Login page")
|
||||
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")
|
||||
|
||||
_ = viper.BindPFlag("address", rootCmd.Flags().Lookup("address"))
|
||||
_ = viper.BindPFlag("port", rootCmd.Flags().Lookup("port"))
|
||||
_ = viper.BindPFlag("sessiontimeout", rootCmd.Flags().Lookup("sessiontimeout"))
|
||||
_ = viper.BindPFlag("scaninterval", rootCmd.Flags().Lookup("scaninterval"))
|
||||
_ = viper.BindPFlag("baseurl", rootCmd.Flags().Lookup("baseurl"))
|
||||
_ = viper.BindPFlag("uiloginbackgroundurl", rootCmd.Flags().Lookup("uiloginbackgroundurl"))
|
||||
_ = viper.BindPFlag("enabletranscodingconfig", rootCmd.Flags().Lookup("enabletranscodingconfig"))
|
||||
_ = viper.BindPFlag("transcodingcachesize", rootCmd.Flags().Lookup("transcodingcachesize"))
|
||||
_ = viper.BindPFlag("imagecachesize", rootCmd.Flags().Lookup("imagecachesize"))
|
||||
}
|
||||
36
cmd/scan.go
Normal file
36
cmd/scan.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var fullRescan bool
|
||||
|
||||
func init() {
|
||||
scanCmd.Flags().BoolVarP(&fullRescan, "full", "f", false, "check all subfolders, ignoring timestamps")
|
||||
rootCmd.AddCommand(scanCmd)
|
||||
}
|
||||
|
||||
var scanCmd = &cobra.Command{
|
||||
Use: "scan",
|
||||
Short: "Scan music folder",
|
||||
Long: "Scan music folder for updates",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runScanner()
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if fullRescan {
|
||||
log.Info("Finished full rescan")
|
||||
} else {
|
||||
log.Info("Finished rescan")
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,11 @@
|
||||
//go:generate wire
|
||||
//+build !wireinject
|
||||
|
||||
package main
|
||||
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"
|
||||
@@ -24,16 +26,23 @@ func CreateServer(musicFolder string) *server.Server {
|
||||
return serverServer
|
||||
}
|
||||
|
||||
func CreateAppRouter(path string) *app.Router {
|
||||
func CreateScanner(musicFolder string) *scanner.Scanner {
|
||||
dataStore := persistence.New()
|
||||
router := app.New(dataStore, path)
|
||||
scannerScanner := scanner.New(dataStore)
|
||||
return scannerScanner
|
||||
}
|
||||
|
||||
func CreateAppRouter() *app.Router {
|
||||
dataStore := persistence.New()
|
||||
router := app.New(dataStore)
|
||||
return router
|
||||
}
|
||||
|
||||
func CreateSubsonicAPIRouter() *subsonic.Router {
|
||||
func CreateSubsonicAPIRouter() (*subsonic.Router, error) {
|
||||
dataStore := persistence.New()
|
||||
browser := engine.NewBrowser(dataStore)
|
||||
cover := engine.NewCover(dataStore)
|
||||
artworkCache := core.NewImageCache()
|
||||
artwork := core.NewArtwork(dataStore, artworkCache)
|
||||
nowPlayingRepository := engine.NewNowPlayingRepository()
|
||||
listGenerator := engine.NewListGenerator(dataStore, nowPlayingRepository)
|
||||
users := engine.NewUsers(dataStore)
|
||||
@@ -41,11 +50,14 @@ func CreateSubsonicAPIRouter() *subsonic.Router {
|
||||
ratings := engine.NewRatings(dataStore)
|
||||
scrobbler := engine.NewScrobbler(dataStore, nowPlayingRepository)
|
||||
search := engine.NewSearch(dataStore)
|
||||
mediaStreamer := engine.NewMediaStreamer(dataStore)
|
||||
router := subsonic.New(browser, cover, listGenerator, users, playlists, ratings, scrobbler, search, mediaStreamer)
|
||||
return router
|
||||
transcoderTranscoder := transcoder.New()
|
||||
transcodingCache := core.NewTranscodingCache()
|
||||
mediaStreamer := core.NewMediaStreamer(dataStore, transcoderTranscoder, transcodingCache)
|
||||
players := engine.NewPlayers(dataStore)
|
||||
router := subsonic.New(browser, artwork, listGenerator, users, playlists, ratings, scrobbler, search, mediaStreamer, players, dataStore)
|
||||
return router, nil
|
||||
}
|
||||
|
||||
// wire_injectors.go:
|
||||
|
||||
var allProviders = wire.NewSet(engine.Set, scanner.New, subsonic.New, app.New, persistence.New)
|
||||
var allProviders = wire.NewSet(engine.Set, core.Set, scanner.New, subsonic.New, app.New, persistence.New)
|
||||
@@ -1,8 +1,9 @@
|
||||
//+build wireinject
|
||||
|
||||
package main
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/deluan/navidrome/core"
|
||||
"github.com/deluan/navidrome/engine"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
"github.com/deluan/navidrome/scanner"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
|
||||
var allProviders = wire.NewSet(
|
||||
engine.Set,
|
||||
core.Set,
|
||||
scanner.New,
|
||||
subsonic.New,
|
||||
app.New,
|
||||
@@ -27,10 +29,16 @@ func CreateServer(musicFolder string) *server.Server {
|
||||
))
|
||||
}
|
||||
|
||||
func CreateAppRouter(path string) *app.Router {
|
||||
func CreateScanner(musicFolder string) *scanner.Scanner {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
))
|
||||
}
|
||||
|
||||
func CreateAppRouter() *app.Router {
|
||||
panic(wire.Build(allProviders))
|
||||
}
|
||||
|
||||
func CreateSubsonicAPIRouter() *subsonic.Router {
|
||||
func CreateSubsonicAPIRouter() (*subsonic.Router, error) {
|
||||
panic(wire.Build(allProviders))
|
||||
}
|
||||
@@ -1,97 +1,131 @@
|
||||
package conf
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/koding/multiconfig"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type nd struct {
|
||||
Port string `default:"4533"`
|
||||
MusicFolder string `default:"./music"`
|
||||
DataFolder string `default:"./"`
|
||||
DbPath string
|
||||
LogLevel string `default:"info"`
|
||||
type configOptions struct {
|
||||
ConfigFile string
|
||||
Address string
|
||||
Port int
|
||||
MusicFolder string
|
||||
DataFolder string
|
||||
DbPath string
|
||||
LogLevel string
|
||||
ScanInterval time.Duration
|
||||
SessionTimeout time.Duration
|
||||
BaseURL string
|
||||
UILoginBackgroundURL string
|
||||
EnableTranscodingConfig bool
|
||||
TranscodingCacheSize string
|
||||
ImageCacheSize string
|
||||
|
||||
IgnoredArticles string `default:"The El La Los Las Le Les Os As O A"`
|
||||
IndexGroups string `default:"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]([)"`
|
||||
|
||||
EnableDownsampling bool `default:"false"`
|
||||
MaxBitRate int `default:"0"`
|
||||
DownsampleCommand string `default:"ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -f mp3 -"`
|
||||
ProbeCommand string `default:"ffmpeg -i %s -f ffmetadata"`
|
||||
ScanInterval string `default:"1m"`
|
||||
IgnoredArticles string
|
||||
IndexGroups string
|
||||
ProbeCommand string
|
||||
CoverArtPriority string
|
||||
CoverJpegQuality int
|
||||
UIWelcomeMessage string
|
||||
GATrackingID string
|
||||
AuthRequestLimit int
|
||||
AuthWindowLength time.Duration
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
DevDisableBanner bool `default:"false"`
|
||||
DevLogSourceLine bool `default:"false"`
|
||||
DevAutoCreateAdminPassword string `default:""`
|
||||
DevLogSourceLine bool
|
||||
DevAutoCreateAdminPassword string
|
||||
}
|
||||
|
||||
var Server = &nd{}
|
||||
var Server = &configOptions{}
|
||||
|
||||
func newWithPath(path string, skipFlags ...bool) *multiconfig.DefaultLoader {
|
||||
var loaders []multiconfig.Loader
|
||||
|
||||
// Read default values defined via tag fields "default"
|
||||
loaders = append(loaders, &multiconfig.TagLoader{})
|
||||
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
if strings.HasSuffix(path, "toml") {
|
||||
loaders = append(loaders, &multiconfig.TOMLLoader{Path: path})
|
||||
}
|
||||
|
||||
if strings.HasSuffix(path, "json") {
|
||||
loaders = append(loaders, &multiconfig.JSONLoader{Path: path})
|
||||
}
|
||||
|
||||
if strings.HasSuffix(path, "yml") || strings.HasSuffix(path, "yaml") {
|
||||
loaders = append(loaders, &multiconfig.YAMLLoader{Path: path})
|
||||
}
|
||||
}
|
||||
|
||||
e := &multiconfig.EnvironmentLoader{}
|
||||
loaders = append(loaders, e)
|
||||
if len(skipFlags) == 0 || !skipFlags[0] {
|
||||
f := &multiconfig.FlagLoader{}
|
||||
loaders = append(loaders, f)
|
||||
}
|
||||
|
||||
loader := multiconfig.MultiLoader(loaders...)
|
||||
|
||||
d := &multiconfig.DefaultLoader{}
|
||||
d.Loader = loader
|
||||
d.Validator = multiconfig.MultiValidator(&multiconfig.RequiredValidator{})
|
||||
return d
|
||||
}
|
||||
|
||||
func LoadFromFile(confFile string, skipFlags ...bool) {
|
||||
m := newWithPath(confFile, skipFlags...)
|
||||
err := m.Load(Server)
|
||||
if err == flag.ErrHelp {
|
||||
os.Exit(1)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Printf("Error trying to load config '%s'. Error: %v", confFile, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
if Server.DbPath == "" {
|
||||
Server.DbPath = filepath.Join(Server.DataFolder, "navidrome.db")
|
||||
}
|
||||
if os.Getenv("PORT") != "" {
|
||||
Server.Port = os.Getenv("PORT")
|
||||
}
|
||||
log.SetLevelString(Server.LogLevel)
|
||||
log.SetLogSourceLine(Server.DevLogSourceLine)
|
||||
log.Trace("Loaded configuration", "file", confFile, "config", fmt.Sprintf("%#v", Server))
|
||||
func LoadFromFile(confFile string) {
|
||||
viper.SetConfigFile(confFile)
|
||||
Load()
|
||||
}
|
||||
|
||||
func Load() {
|
||||
LoadFromFile(consts.LocalConfigFile)
|
||||
err := viper.Unmarshal(&Server)
|
||||
if err != nil {
|
||||
fmt.Println("Error parsing config:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
err = os.MkdirAll(Server.DataFolder, os.ModePerm)
|
||||
if err != nil {
|
||||
fmt.Println("Error creating data path:", "path", Server.DataFolder, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
Server.ConfigFile = viper.GetViper().ConfigFileUsed()
|
||||
if Server.DbPath == "" {
|
||||
Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath)
|
||||
}
|
||||
|
||||
log.SetLevelString(Server.LogLevel)
|
||||
log.SetLogSourceLine(Server.DevLogSourceLine)
|
||||
log.Debug("Loaded configuration", "file", Server.ConfigFile, "config", fmt.Sprintf("%#v", Server))
|
||||
}
|
||||
|
||||
func init() {
|
||||
viper.SetDefault("musicfolder", filepath.Join(".", "music"))
|
||||
viper.SetDefault("datafolder", ".")
|
||||
viper.SetDefault("loglevel", "info")
|
||||
viper.SetDefault("address", "0.0.0.0")
|
||||
viper.SetDefault("port", 4533)
|
||||
viper.SetDefault("sessiontimeout", consts.DefaultSessionTimeout)
|
||||
viper.SetDefault("scaninterval", time.Minute)
|
||||
viper.SetDefault("baseurl", "")
|
||||
viper.SetDefault("uiloginbackgroundurl", "https://source.unsplash.com/random/1600x900?music")
|
||||
viper.SetDefault("enabletranscodingconfig", false)
|
||||
viper.SetDefault("transcodingcachesize", "100MB")
|
||||
viper.SetDefault("imagecachesize", "100MB")
|
||||
|
||||
// Config options only valid for file/env configuration
|
||||
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("gatrackingid", "")
|
||||
viper.SetDefault("authrequestlimit", 5)
|
||||
viper.SetDefault("authwindowlength", 20*time.Second)
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
viper.SetDefault("devlogsourceline", false)
|
||||
viper.SetDefault("devautocreateadminpassword", "")
|
||||
viper.SetDefault("devoldscanner", false)
|
||||
}
|
||||
|
||||
func InitConfig(cfgFile string) {
|
||||
cfgFile = getConfigFile(cfgFile)
|
||||
if cfgFile != "" {
|
||||
// Use config file from the flag.
|
||||
viper.SetConfigFile(cfgFile)
|
||||
} else {
|
||||
// Search config in local directory with name "navidrome" (without extension).
|
||||
viper.AddConfigPath(".")
|
||||
viper.SetConfigName("navidrome")
|
||||
}
|
||||
|
||||
_ = viper.BindEnv("port")
|
||||
viper.SetEnvPrefix("ND")
|
||||
viper.AutomaticEnv()
|
||||
|
||||
err := viper.ReadInConfig()
|
||||
if cfgFile != "" && err != nil {
|
||||
fmt.Println("Navidrome could not open config file: ", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func getConfigFile(cfgFile string) string {
|
||||
if cfgFile != "" {
|
||||
return cfgFile
|
||||
}
|
||||
return os.Getenv("ND_CONFIGFILE")
|
||||
}
|
||||
|
||||
@@ -3,17 +3,18 @@ package consts
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/deluan/navidrome/static"
|
||||
"github.com/deluan/navidrome/resources"
|
||||
)
|
||||
|
||||
func getBanner() string {
|
||||
data, _ := static.Asset("banner.txt")
|
||||
return strings.TrimSuffix(string(data), "\n")
|
||||
data, _ := resources.Asset("banner.txt")
|
||||
return strings.TrimRightFunc(string(data), unicode.IsSpace)
|
||||
}
|
||||
|
||||
func Banner() string {
|
||||
version := "Version: " + Version()
|
||||
padding := strings.Repeat(" ", 52-len(version))
|
||||
return fmt.Sprintf("%s%s%s\n", getBanner(), padding, version)
|
||||
return fmt.Sprintf("%s\n%s%s\n", getBanner(), padding, version)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,69 @@
|
||||
package consts
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
AppName = "navidrome"
|
||||
|
||||
LocalConfigFile = "./navidrome.toml"
|
||||
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on"
|
||||
InitialSetupFlagKey = "InitialSetup"
|
||||
|
||||
JWTSecretKey = "JWTSecret"
|
||||
JWTIssuer = "ND"
|
||||
JWTTokenExpiration = 30 * time.Minute
|
||||
|
||||
UIAssetsLocalPath = "ui/build"
|
||||
UIAuthorizationHeader = "X-ND-Authorization"
|
||||
JWTSecretKey = "JWTSecret"
|
||||
JWTIssuer = "ND"
|
||||
DefaultSessionTimeout = 24 * time.Hour
|
||||
|
||||
DevInitialUserName = "admin"
|
||||
DevInitialName = "Dev Admin"
|
||||
|
||||
URLPathUI = "/app"
|
||||
URLPathSubsonicAPI = "/rest"
|
||||
|
||||
RequestThrottleBacklogLimit = 100
|
||||
RequestThrottleBacklogTimeout = time.Minute
|
||||
|
||||
I18nFolder = "i18n"
|
||||
SkipScanFile = ".ndignore"
|
||||
|
||||
PlaceholderAlbumArt = "navidrome-600x600.png"
|
||||
)
|
||||
|
||||
// Cache options
|
||||
const (
|
||||
TranscodingCacheDir = "cache/transcoding"
|
||||
DefaultTranscodingCacheMaxItems = 0 // Unlimited
|
||||
|
||||
ImageCacheDir = "cache/images"
|
||||
DefaultImageCacheMaxItems = 0 // Unlimited
|
||||
|
||||
DefaultCacheSize = 100 * 1024 * 1024 // 100MB
|
||||
DefaultCacheCleanUpInterval = 10 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
DefaultTranscodings = []map[string]interface{}{
|
||||
{
|
||||
"name": "mp3 audio",
|
||||
"targetFormat": "mp3",
|
||||
"defaultBitRate": 192,
|
||||
"command": "ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -f mp3 -",
|
||||
},
|
||||
{
|
||||
"name": "opus audio",
|
||||
"targetFormat": "oga",
|
||||
"defaultBitRate": 128,
|
||||
"command": "ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -c:a libopus -f opus -",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
VariousArtists = "Various Artists"
|
||||
VariousArtistsID = fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(VariousArtists))))
|
||||
UnknownArtist = "[Unknown Artist]"
|
||||
)
|
||||
|
||||
@@ -8,7 +8,6 @@ func init() {
|
||||
".ogg": "audio/ogg",
|
||||
".oga": "audio/ogg",
|
||||
".opus": "audio/ogg",
|
||||
".ogx": "application/ogg",
|
||||
".aac": "audio/mp4",
|
||||
".m4a": "audio/mp4",
|
||||
".m4b": "audio/mp4",
|
||||
@@ -18,20 +17,11 @@ func init() {
|
||||
".ape": "audio/x-monkeys-audio",
|
||||
".mpc": "audio/x-musepack",
|
||||
".shn": "audio/x-shn",
|
||||
".flv": "video/x-flv",
|
||||
".avi": "video/avi",
|
||||
".mpg": "video/mpeg",
|
||||
".mpeg": "video/mpeg",
|
||||
".mp4": "video/mp4",
|
||||
".m4v": "video/x-m4v",
|
||||
".mkv": "video/x-matroska",
|
||||
".mov": "video/quicktime",
|
||||
".wmv": "video/x-ms-wmv",
|
||||
".ogv": "video/ogg",
|
||||
".divx": "video/divx",
|
||||
".m2ts": "video/MP2T",
|
||||
".ts": "video/MP2T",
|
||||
".webm": "video/webm",
|
||||
".aif": "audio/x-aiff",
|
||||
".aiff": "audio/x-aiff",
|
||||
".m3u": "audio/x-mpegurl",
|
||||
".pls": "audio/x-scpls",
|
||||
".dsf": "audio/dsd",
|
||||
".gif": "image/gif",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
15
contrib/navidrome
Normal file
15
contrib/navidrome
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/sbin/openrc-run
|
||||
|
||||
name=$RC_SVCNAME
|
||||
command="/opt/navidrome/${RC_SVCNAME}"
|
||||
command_args="-datafolder /opt/navidrome"
|
||||
command_user="${RC_SVCNAME}"
|
||||
pidfile="/var/run/${RC_SVCNAME}.pid"
|
||||
output_log="/opt/navidrome/${RC_SVCNAME}.log"
|
||||
error_log="/opt/navidrome/${RC_SVCNAME}.err"
|
||||
command_background="yes"
|
||||
|
||||
depend() {
|
||||
need net
|
||||
}
|
||||
|
||||
48
contrib/navidrome.service
Normal file
48
contrib/navidrome.service
Normal file
@@ -0,0 +1,48 @@
|
||||
# This file ususaly goes in /etc/systemd/system
|
||||
|
||||
[Unit]
|
||||
Description=Navidrome Music Server and Streamer compatible with Subsonic/Airsonic
|
||||
After=remote-fs.target network.target
|
||||
AssertPathExists=/var/lib/navidrome
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
[Service]
|
||||
User=navidrome
|
||||
Group=navidrome
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/navidrome
|
||||
WorkingDirectory=/var/lib/navidrome
|
||||
TimeoutStopSec=20
|
||||
KillMode=process
|
||||
Restart=on-failure
|
||||
|
||||
EnvironmentFile=-/etc/sysconfig/navidrome
|
||||
|
||||
# See https://www.freedesktop.org/software/systemd/man/systemd.exec.html
|
||||
DevicePolicy=closed
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
PrivateUsers=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelTunables=yes
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
RestrictNamespaces=yes
|
||||
RestrictRealtime=yes
|
||||
SystemCallFilter=~@clock @debug @module @mount @obsolete @privileged @reboot @setuid @swap
|
||||
ReadWritePaths=/var/lib/navidrome
|
||||
|
||||
# You can uncomment the following line if you're not using the jukebox This
|
||||
# will prevent navidrome from accessing any real (physical) devices
|
||||
#PrivateDevices=yes
|
||||
|
||||
# You can change the following line to `strict` instead of `full` if you don't
|
||||
# want navidrome to be able to write anything on your filesystem outside of
|
||||
# /var/lib/navidrome.
|
||||
ProtectSystem=full
|
||||
|
||||
# You can comment the following line if you don't have any media in /home/*.
|
||||
# This will prevent navidrome from ever reading/writing anything there.
|
||||
ProtectHome=true
|
||||
198
core/artwork.go
Normal file
198
core/artwork.go
Normal file
@@ -0,0 +1,198 @@
|
||||
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"
|
||||
)
|
||||
|
||||
type Artwork interface {
|
||||
Get(ctx context.Context, id string, size int, out io.Writer) error
|
||||
}
|
||||
|
||||
type ArtworkCache FileCache
|
||||
|
||||
func NewArtwork(ds model.DataStore, cache ArtworkCache) Artwork {
|
||||
return &artwork{ds: ds, cache: cache}
|
||||
}
|
||||
|
||||
type artwork struct {
|
||||
ds model.DataStore
|
||||
cache FileCache
|
||||
}
|
||||
|
||||
type imageInfo struct {
|
||||
c *artwork
|
||||
path string
|
||||
size int
|
||||
lastUpdate time.Time
|
||||
}
|
||||
|
||||
func (ci *imageInfo) String() string {
|
||||
return fmt.Sprintf("%s.%d.%s.%d", ci.path, ci.size, ci.lastUpdate.Format(time.RFC3339Nano), conf.Server.CoverJpegQuality)
|
||||
}
|
||||
|
||||
func (c *artwork) Get(ctx context.Context, id string, size int, out io.Writer) error {
|
||||
path, lastUpdate, err := c.getImagePath(ctx, id)
|
||||
if err != nil && err != model.ErrNotFound {
|
||||
return err
|
||||
}
|
||||
|
||||
info := &imageInfo{
|
||||
c: c,
|
||||
path: path,
|
||||
size: size,
|
||||
lastUpdate: lastUpdate,
|
||||
}
|
||||
|
||||
r, err := c.cache.Get(ctx, info)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error accessing image cache", "path", path, "size", size, err)
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = io.Copy(out, r)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *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 = 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.getImagePath(ctx, "al-"+mf.AlbumID)
|
||||
}
|
||||
|
||||
func (c *artwork) getArtwork(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 artwork")
|
||||
}
|
||||
|
||||
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() ArtworkCache {
|
||||
return NewFileCache("Image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems,
|
||||
func(ctx context.Context, arg fmt.Stringer) (io.Reader, error) {
|
||||
info := arg.(*imageInfo)
|
||||
reader, err := info.c.getArtwork(ctx, 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
|
||||
})
|
||||
}
|
||||
132
core/artwork_test.go
Normal file
132
core/artwork_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"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("Artwork", func() {
|
||||
var artwork Artwork
|
||||
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() {
|
||||
conf.Server.DataFolder, _ = ioutil.TempDir("", "file_caches")
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
cache := NewImageCache()
|
||||
Eventually(func() bool { return cache.Ready() }).Should(BeTrue())
|
||||
artwork = NewArtwork(ds, cache)
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
os.RemoveAll(conf.Server.DataFolder)
|
||||
})
|
||||
|
||||
It("retrieves the external artwork art for an album", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(artwork.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 artwork art for an album", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(artwork.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 artwork if album does not have artwork", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(artwork.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 artwork if album is not found", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(artwork.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 artwork art from a media_file", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(artwork.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 artwork art if media_file does not have one", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(artwork.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 artwork art as requested", func() {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
Expect(artwork.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(artwork.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(artwork.Get(ctx, "123", 0, buf)).To(MatchError("Error!"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,12 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
@@ -13,14 +15,15 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
JwtSecret []byte
|
||||
TokenAuth *jwtauth.JWTAuth
|
||||
once sync.Once
|
||||
JwtSecret []byte
|
||||
TokenAuth *jwtauth.JWTAuth
|
||||
sessionTimeOut time.Duration
|
||||
)
|
||||
|
||||
func InitTokenAuth(ds model.DataStore) {
|
||||
once.Do(func() {
|
||||
secret, err := ds.Property(nil).DefaultGet(consts.JWTSecretKey, "not so secret")
|
||||
secret, err := ds.Property(context.TODO()).DefaultGet(consts.JWTSecretKey, "not so secret")
|
||||
if err != nil {
|
||||
log.Error("No JWT secret found in DB. Setting a temp one, but please report this error", err)
|
||||
}
|
||||
@@ -39,8 +42,17 @@ func CreateToken(u *model.User) (string, error) {
|
||||
return TouchToken(token)
|
||||
}
|
||||
|
||||
func getSessionTimeOut() time.Duration {
|
||||
if sessionTimeOut == 0 {
|
||||
sessionTimeOut = conf.Server.SessionTimeout
|
||||
log.Info("Setting Session Timeout", "value", sessionTimeOut)
|
||||
}
|
||||
return sessionTimeOut
|
||||
}
|
||||
|
||||
func TouchToken(token *jwt.Token) (string, error) {
|
||||
expireIn := time.Now().Add(consts.JWTTokenExpiration).Unix()
|
||||
timeout := getSessionTimeOut()
|
||||
expireIn := time.Now().Add(timeout).Unix()
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
claims["exp"] = expireIn
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/engine/auth"
|
||||
"github.com/deluan/navidrome/core/auth"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
. "github.com/onsi/ginkgo"
|
||||
15
core/common.go
Normal file
15
core/common.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
)
|
||||
|
||||
func userName(ctx context.Context) string {
|
||||
if user, ok := request.UserFrom(ctx); !ok {
|
||||
return "UNKNOWN"
|
||||
} else {
|
||||
return user.UserName
|
||||
}
|
||||
}
|
||||
17
core/core_suite_test.go
Normal file
17
core/core_suite_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestEngine(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelCritical)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Core Suite")
|
||||
}
|
||||
195
core/file_caches.go
Normal file
195
core/file_caches.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/djherbis/fscache"
|
||||
"github.com/dustin/go-humanize"
|
||||
)
|
||||
|
||||
type ReadFunc func(ctx context.Context, arg fmt.Stringer) (io.Reader, error)
|
||||
|
||||
type FileCache interface {
|
||||
Get(ctx context.Context, arg fmt.Stringer) (*CachedStream, error)
|
||||
Ready() bool
|
||||
}
|
||||
|
||||
func NewFileCache(name, cacheSize, cacheFolder string, maxItems int, getReader ReadFunc) *fileCache {
|
||||
fc := &fileCache{
|
||||
name: name,
|
||||
cacheSize: cacheSize,
|
||||
cacheFolder: filepath.FromSlash(cacheFolder),
|
||||
maxItems: maxItems,
|
||||
getReader: getReader,
|
||||
mutex: &sync.RWMutex{},
|
||||
}
|
||||
|
||||
go func() {
|
||||
cache, err := newFSCache(fc.name, fc.cacheSize, fc.cacheFolder, fc.maxItems)
|
||||
fc.mutex.Lock()
|
||||
defer fc.mutex.Unlock()
|
||||
if err == nil {
|
||||
fc.cache = cache
|
||||
fc.disabled = cache == nil
|
||||
}
|
||||
fc.ready = true
|
||||
if fc.disabled {
|
||||
log.Debug("Cache disabled", "cache", fc.name, "size", fc.cacheSize)
|
||||
}
|
||||
}()
|
||||
|
||||
return fc
|
||||
}
|
||||
|
||||
type fileCache struct {
|
||||
name string
|
||||
cacheSize string
|
||||
cacheFolder string
|
||||
maxItems int
|
||||
cache fscache.Cache
|
||||
getReader ReadFunc
|
||||
disabled bool
|
||||
ready bool
|
||||
mutex *sync.RWMutex
|
||||
}
|
||||
|
||||
func (fc *fileCache) Ready() bool {
|
||||
fc.mutex.RLock()
|
||||
defer fc.mutex.RUnlock()
|
||||
return fc.ready
|
||||
}
|
||||
|
||||
func (fc *fileCache) available(ctx context.Context) bool {
|
||||
fc.mutex.RLock()
|
||||
defer fc.mutex.RUnlock()
|
||||
|
||||
if !fc.ready {
|
||||
log.Debug(ctx, "Cache not initialized yet", "cache", fc.name)
|
||||
}
|
||||
|
||||
return fc.ready && !fc.disabled
|
||||
}
|
||||
|
||||
func (fc *fileCache) Get(ctx context.Context, arg fmt.Stringer) (*CachedStream, error) {
|
||||
if !fc.available(ctx) {
|
||||
reader, err := fc.getReader(ctx, arg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CachedStream{Reader: reader}, nil
|
||||
}
|
||||
|
||||
key := arg.String()
|
||||
r, w, err := fc.cache.Get(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cached := w == nil
|
||||
|
||||
if !cached {
|
||||
log.Trace(ctx, "Cache MISS", "cache", fc.name, "key", key)
|
||||
reader, err := fc.getReader(ctx, arg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
go copyAndClose(ctx, w, reader)
|
||||
}
|
||||
|
||||
// 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.Trace(ctx, "Cache HIT", "cache", fc.name, "key", key, "size", size)
|
||||
sr := io.NewSectionReader(r, 0, size)
|
||||
return &CachedStream{
|
||||
Reader: sr,
|
||||
Seeker: sr,
|
||||
Cached: true,
|
||||
}, nil
|
||||
} else {
|
||||
log.Trace(ctx, "Cache HIT", "cache", fc.name, "key", key)
|
||||
}
|
||||
}
|
||||
|
||||
// All other cases, just return a Reader, without Seek capabilities
|
||||
return &CachedStream{Reader: r, Cached: cached}, nil
|
||||
}
|
||||
|
||||
type CachedStream struct {
|
||||
io.Reader
|
||||
io.Seeker
|
||||
Cached bool
|
||||
}
|
||||
|
||||
func (s *CachedStream) Seekable() bool { return s.Seeker != nil }
|
||||
func (s *CachedStream) Close() error {
|
||||
if c, ok := s.Reader.(io.Closer); ok {
|
||||
return c.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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 copyAndClose(ctx context.Context, w io.WriteCloser, r io.Reader) {
|
||||
_, err := io.Copy(w, r)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error copying data to cache", err)
|
||||
}
|
||||
if c, ok := r.(io.Closer); ok {
|
||||
err = c.Close()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error closing source stream", err)
|
||||
}
|
||||
}
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error closing cache writer", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newFSCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cache, error) {
|
||||
size, err := humanize.ParseBytes(cacheSize)
|
||||
if err != nil {
|
||||
log.Error("Invalid cache size. Using default size", "cache", name, "size", cacheSize,
|
||||
"defaultSize", humanize.Bytes(consts.DefaultCacheSize))
|
||||
size = consts.DefaultCacheSize
|
||||
}
|
||||
if size == 0 {
|
||||
log.Warn(fmt.Sprintf("%s cache disabled", name))
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
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 {
|
||||
log.Error(fmt.Sprintf("Error initializing %s cache", name), err, "elapsedTime", time.Since(start))
|
||||
return nil, err
|
||||
}
|
||||
log.Debug(fmt.Sprintf("%s cache initialized", name), "elapsedTime", time.Since(start))
|
||||
|
||||
return fscache.NewCacheWithHaunter(fs, h)
|
||||
}
|
||||
100
core/file_caches_test.go
Normal file
100
core/file_caches_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// Call NewFileCache and wait for it to be ready
|
||||
func callNewFileCache(name, cacheSize, cacheFolder string, maxItems int, getReader ReadFunc) *fileCache {
|
||||
fc := NewFileCache(name, cacheSize, cacheFolder, maxItems, getReader)
|
||||
Eventually(func() bool { return fc.Ready() }).Should(BeTrue())
|
||||
return fc
|
||||
}
|
||||
|
||||
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(callNewFileCache("test", "1k", "test", 0, nil)).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() {
|
||||
fc := callNewFileCache("test", "abc", "test", 0, nil)
|
||||
Expect(fc.cache).ToNot(BeNil())
|
||||
Expect(fc.disabled).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns empty if cache size is '0'", func() {
|
||||
fc := callNewFileCache("test", "0", "test", 0, nil)
|
||||
Expect(fc.cache).To(BeNil())
|
||||
Expect(fc.disabled).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("FileCache", func() {
|
||||
It("caches data if cache is enabled", func() {
|
||||
called := false
|
||||
fc := callNewFileCache("test", "1KB", "test", 0, func(ctx context.Context, arg fmt.Stringer) (io.Reader, error) {
|
||||
called = true
|
||||
return strings.NewReader(arg.String()), nil
|
||||
})
|
||||
// First call is a MISS
|
||||
s, err := fc.Get(context.TODO(), &testArg{"test"})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(s.Cached).To(BeFalse())
|
||||
Expect(ioutil.ReadAll(s)).To(Equal([]byte("test")))
|
||||
|
||||
// Second call is a HIT
|
||||
called = false
|
||||
s, err = fc.Get(context.TODO(), &testArg{"test"})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(ioutil.ReadAll(s)).To(Equal([]byte("test")))
|
||||
Expect(s.Cached).To(BeTrue())
|
||||
Expect(called).To(BeFalse())
|
||||
})
|
||||
|
||||
It("does not cache data if cache is disabled", func() {
|
||||
called := false
|
||||
fc := callNewFileCache("test", "0", "test", 0, func(ctx context.Context, arg fmt.Stringer) (io.Reader, error) {
|
||||
called = true
|
||||
return strings.NewReader(arg.String()), nil
|
||||
})
|
||||
// First call is a MISS
|
||||
s, err := fc.Get(context.TODO(), &testArg{"test"})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(s.Cached).To(BeFalse())
|
||||
Expect(ioutil.ReadAll(s)).To(Equal([]byte("test")))
|
||||
|
||||
// Second call is also a MISS
|
||||
called = false
|
||||
s, err = fc.Get(context.TODO(), &testArg{"test"})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(ioutil.ReadAll(s)).To(Equal([]byte("test")))
|
||||
Expect(s.Cached).To(BeFalse())
|
||||
Expect(called).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type testArg struct{ s string }
|
||||
|
||||
func (t *testArg) String() string { return t.s }
|
||||
186
core/media_streamer.go
Normal file
186
core/media_streamer.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"os"
|
||||
"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"
|
||||
)
|
||||
|
||||
type MediaStreamer interface {
|
||||
NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error)
|
||||
}
|
||||
|
||||
type TranscodingCache FileCache
|
||||
|
||||
func NewMediaStreamer(ds model.DataStore, ffm transcoder.Transcoder, cache TranscodingCache) MediaStreamer {
|
||||
return &mediaStreamer{ds: ds, ffm: ffm, cache: cache}
|
||||
}
|
||||
|
||||
type mediaStreamer struct {
|
||||
ds model.DataStore
|
||||
ffm transcoder.Transcoder
|
||||
cache FileCache
|
||||
}
|
||||
|
||||
type streamJob struct {
|
||||
ms *mediaStreamer
|
||||
mf *model.MediaFile
|
||||
format string
|
||||
bitRate int
|
||||
}
|
||||
|
||||
func (j *streamJob) String() 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) {
|
||||
mf, err := ms.ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var format string
|
||||
var bitRate int
|
||||
var cached bool
|
||||
defer func() {
|
||||
log.Info("Streaming file", "title", mf.Title, "artist", mf.Artist, "format", format, "cached", cached,
|
||||
"bitRate", bitRate, "user", userName(ctx), "transcoding", format != "raw",
|
||||
"originalFormat", mf.Suffix, "originalBitRate", mf.BitRate)
|
||||
}()
|
||||
|
||||
format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, reqFormat, reqBitRate)
|
||||
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,
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
|
||||
"selectedBitrate", bitRate, "selectedFormat", format)
|
||||
f, err := os.Open(mf.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.ReadCloser = f
|
||||
s.Seeker = f
|
||||
s.format = mf.Suffix
|
||||
return s, nil
|
||||
}
|
||||
|
||||
job := &streamJob{
|
||||
ms: ms,
|
||||
mf: mf,
|
||||
format: format,
|
||||
bitRate: bitRate,
|
||||
}
|
||||
r, err := ms.cache.Get(ctx, job)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err)
|
||||
return nil, err
|
||||
}
|
||||
cached = r.Cached
|
||||
|
||||
log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", mf.Path,
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
|
||||
"selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable())
|
||||
|
||||
s.ReadCloser = r
|
||||
if r.Seekable() {
|
||||
s.Seeker = r
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
type Stream struct {
|
||||
ctx context.Context
|
||||
mf *model.MediaFile
|
||||
bitRate int
|
||||
format string
|
||||
io.ReadCloser
|
||||
io.Seeker
|
||||
}
|
||||
|
||||
func (s *Stream) Seekable() bool { return s.Seeker != nil }
|
||||
func (s *Stream) Duration() float32 { return s.mf.Duration }
|
||||
func (s *Stream) ContentType() string { return mime.TypeByExtension("." + s.format) }
|
||||
func (s *Stream) Name() string { return s.mf.Title + "." + s.format }
|
||||
func (s *Stream) ModTime() time.Time { return s.mf.UpdatedAt }
|
||||
func (s *Stream) EstimatedContentLength() int {
|
||||
return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024)
|
||||
}
|
||||
|
||||
// TODO This function deserves some love (refactoring)
|
||||
func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (format string, bitRate int) {
|
||||
format = "raw"
|
||||
if reqFormat == "raw" {
|
||||
return
|
||||
}
|
||||
if reqFormat == mf.Suffix && reqBitRate == 0 {
|
||||
bitRate = mf.BitRate
|
||||
return
|
||||
}
|
||||
trc, hasDefault := request.TranscodingFrom(ctx)
|
||||
var cFormat string
|
||||
var cBitRate int
|
||||
if reqFormat != "" {
|
||||
cFormat = reqFormat
|
||||
} else {
|
||||
if hasDefault {
|
||||
cFormat = trc.TargetFormat
|
||||
cBitRate = trc.DefaultBitRate
|
||||
if p, ok := request.PlayerFrom(ctx); ok {
|
||||
cBitRate = p.MaxBitRate
|
||||
}
|
||||
}
|
||||
}
|
||||
if reqBitRate > 0 {
|
||||
cBitRate = reqBitRate
|
||||
}
|
||||
if cBitRate == 0 && cFormat == "" {
|
||||
return
|
||||
}
|
||||
t, err := ds.Transcoding(ctx).FindByFormat(cFormat)
|
||||
if err == nil {
|
||||
format = t.TargetFormat
|
||||
if cBitRate != 0 {
|
||||
bitRate = cBitRate
|
||||
} else {
|
||||
bitRate = t.DefaultBitRate
|
||||
}
|
||||
}
|
||||
if format == mf.Suffix && bitRate >= mf.BitRate {
|
||||
format = "raw"
|
||||
bitRate = 0
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func NewTranscodingCache() TranscodingCache {
|
||||
return NewFileCache("Transcoding", conf.Server.TranscodingCacheSize,
|
||||
consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems,
|
||||
func(ctx context.Context, arg fmt.Stringer) (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
|
||||
})
|
||||
}
|
||||
214
core/media_streamer_test.go
Normal file
214
core/media_streamer_test.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("MediaStreamer", func() {
|
||||
var streamer MediaStreamer
|
||||
var ds model.DataStore
|
||||
ffmpeg := &fakeFFmpeg{Data: "fake data"}
|
||||
ctx := log.NewContext(context.TODO())
|
||||
|
||||
BeforeEach(func() {
|
||||
conf.Server.DataFolder, _ = ioutil.TempDir("", "file_caches")
|
||||
conf.Server.TranscodingCacheSize = "100MB"
|
||||
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}]`)
|
||||
testCache := NewTranscodingCache()
|
||||
Eventually(func() bool { return testCache.Ready() }).Should(BeTrue())
|
||||
streamer = NewMediaStreamer(ds, ffmpeg, testCache)
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
os.RemoveAll(conf.Server.DataFolder)
|
||||
})
|
||||
|
||||
Context("NewStream", func() {
|
||||
It("returns a seekable stream if format is 'raw'", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "raw", 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
It("returns a seekable stream if maxBitRate is 0", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
It("returns a seekable stream if maxBitRate is higher than file bitRate", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 320)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
It("returns a NON seekable stream if transcode is required", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 64)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(s.Seekable()).To(BeFalse())
|
||||
Expect(s.Duration()).To(Equal(float32(257.0)))
|
||||
})
|
||||
It("returns a seekable stream if the file is complete in the cache", func() {
|
||||
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())
|
||||
})
|
||||
})
|
||||
|
||||
Context("selectTranscodingOptions", func() {
|
||||
mf := &model.MediaFile{}
|
||||
Context("player is not configured", func() {
|
||||
It("returns raw if raw is requested", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns raw if a transcoder does not exists", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "m4a", 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns the requested format if a transcoder exists", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
||||
Expect(format).To(Equal("mp3"))
|
||||
Expect(bitRate).To(Equal(160)) // Default Bit Rate
|
||||
})
|
||||
It("returns raw if requested format is the same as the original and it is not necessary to downsample", func() {
|
||||
mf.Suffix = "mp3"
|
||||
mf.BitRate = 112
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "mp3", 128)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns the requested format if requested BitRate is lower than original", func() {
|
||||
mf.Suffix = "mp3"
|
||||
mf.BitRate = 320
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192)
|
||||
Expect(format).To(Equal("mp3"))
|
||||
Expect(bitRate).To(Equal(192))
|
||||
})
|
||||
It("returns raw if requested format is the same as the original, but requested BitRate is 0", func() {
|
||||
mf.Suffix = "mp3"
|
||||
mf.BitRate = 320
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
Expect(bitRate).To(Equal(320))
|
||||
})
|
||||
})
|
||||
|
||||
Context("player has format configured", func() {
|
||||
BeforeEach(func() {
|
||||
t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96}
|
||||
ctx = request.WithTranscoding(ctx, t)
|
||||
})
|
||||
It("returns raw if raw is requested", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns configured format/bitrate as default", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(96))
|
||||
})
|
||||
It("returns requested format", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
||||
Expect(format).To(Equal("mp3"))
|
||||
Expect(bitRate).To(Equal(160)) // Default Bit Rate
|
||||
})
|
||||
It("returns requested bitrate", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(80))
|
||||
})
|
||||
It("returns raw if selected bitrate and format is the same as original", func() {
|
||||
mf.Suffix = "mp3"
|
||||
mf.BitRate = 192
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192)
|
||||
Expect(format).To(Equal("raw"))
|
||||
Expect(bitRate).To(Equal(0))
|
||||
})
|
||||
})
|
||||
Context("player has maxBitRate configured", func() {
|
||||
BeforeEach(func() {
|
||||
t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96}
|
||||
p := model.Player{ID: "player1", TranscodingId: t.ID, MaxBitRate: 80}
|
||||
ctx = request.WithTranscoding(ctx, t)
|
||||
ctx = request.WithPlayer(ctx, p)
|
||||
})
|
||||
It("returns raw if raw is requested", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns configured format/bitrate as default", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(80))
|
||||
})
|
||||
It("returns requested format", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
||||
Expect(format).To(Equal("mp3"))
|
||||
Expect(bitRate).To(Equal(160)) // Default Bit Rate
|
||||
})
|
||||
It("returns requested bitrate", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(80))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type fakeFFmpeg struct {
|
||||
Data string
|
||||
r io.Reader
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (ff *fakeFFmpeg) Start(ctx context.Context, cmd, path string, maxBitRate int) (f io.ReadCloser, err error) {
|
||||
ff.r = strings.NewReader(ff.Data)
|
||||
return ff, nil
|
||||
}
|
||||
|
||||
func (ff *fakeFFmpeg) Read(p []byte) (n int, err error) {
|
||||
return ff.r.Read(p)
|
||||
}
|
||||
|
||||
func (ff *fakeFFmpeg) Close() error {
|
||||
ff.closed = true
|
||||
return nil
|
||||
}
|
||||
22
core/mock_transcoding_repo_test.go
Normal file
22
core/mock_transcoding_repo_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
57
core/transcoder/ffmpeg.go
Normal file
57
core/transcoder/ffmpeg.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package transcoder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
)
|
||||
|
||||
type Transcoder interface {
|
||||
Start(ctx context.Context, command, path string, maxBitRate int) (f io.ReadCloser, err error)
|
||||
}
|
||||
|
||||
func New() Transcoder {
|
||||
path, err := exec.LookPath("ffmpeg")
|
||||
if err != nil {
|
||||
log.Error("Unable to find ffmpeg", err)
|
||||
}
|
||||
log.Debug("Found ffmpeg", "path", path)
|
||||
return &ffmpeg{}
|
||||
}
|
||||
|
||||
type ffmpeg struct{}
|
||||
|
||||
func (ff *ffmpeg) Start(ctx context.Context, command, path string, maxBitRate int) (f io.ReadCloser, err error) {
|
||||
args := createTranscodeCommand(command, path, maxBitRate)
|
||||
|
||||
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
|
||||
cmd := exec.Command(args[0], args[1:]...) // #nosec
|
||||
cmd.Stderr = os.Stderr
|
||||
if f, err = cmd.StdoutPipe(); err != nil {
|
||||
return
|
||||
}
|
||||
if err = cmd.Start(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
go func() { _ = cmd.Wait() }() // prevent zombies
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Path will always be an absolute path
|
||||
func createTranscodeCommand(cmd, path string, maxBitRate int) []string {
|
||||
split := strings.Split(cmd, " ")
|
||||
for i, s := range split {
|
||||
s = strings.Replace(s, "%s", path, -1)
|
||||
s = strings.Replace(s, "%b", strconv.Itoa(maxBitRate), -1)
|
||||
split[i] = s
|
||||
}
|
||||
|
||||
return split
|
||||
}
|
||||
24
core/transcoder/ffmpeg_test.go
Normal file
24
core/transcoder/ffmpeg_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package transcoder
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestTranscoder(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelCritical)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Transcoder Suite")
|
||||
}
|
||||
|
||||
var _ = Describe("createTranscodeCommand", func() {
|
||||
It("creates a valid command line", func() {
|
||||
args := createTranscodeCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
|
||||
})
|
||||
})
|
||||
14
core/wire_providers.go
Normal file
14
core/wire_providers.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"github.com/deluan/navidrome/core/transcoder"
|
||||
"github.com/google/wire"
|
||||
)
|
||||
|
||||
var Set = wire.NewSet(
|
||||
NewArtwork,
|
||||
NewMediaStreamer,
|
||||
NewTranscodingCache,
|
||||
NewImageCache,
|
||||
transcoder.New,
|
||||
)
|
||||
28
db/db.go
28
db/db.go
@@ -6,39 +6,43 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
_ "github.com/deluan/navidrome/db/migration"
|
||||
_ "github.com/deluan/navidrome/db/migrations"
|
||||
"github.com/deluan/navidrome/log"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
Driver = "sqlite3"
|
||||
Path string
|
||||
)
|
||||
|
||||
func Init() {
|
||||
var (
|
||||
once sync.Once
|
||||
db *sql.DB
|
||||
)
|
||||
|
||||
func Db() *sql.DB {
|
||||
once.Do(func() {
|
||||
var err error
|
||||
Path = conf.Server.DbPath
|
||||
if Path == ":memory:" {
|
||||
Path = "file::memory:?cache=shared"
|
||||
Path = "file::memory:?cache=shared&_foreign_keys=on"
|
||||
conf.Server.DbPath = Path
|
||||
}
|
||||
log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver)
|
||||
db, err = sql.Open(Driver, Path)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
})
|
||||
return db
|
||||
}
|
||||
|
||||
func EnsureLatestVersion() {
|
||||
Init()
|
||||
db, err := sql.Open(Driver, Path)
|
||||
defer db.Close()
|
||||
if err != nil {
|
||||
log.Error("Failed to open DB", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
db := Db()
|
||||
|
||||
err = goose.SetDialect(Driver)
|
||||
err := goose.SetDialect(Driver)
|
||||
if err != nil {
|
||||
log.Error("Invalid DB driver", "driver", Driver, err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -37,7 +37,7 @@ update annotation set item_type = 'media_file' where item_type = 'mediaFile';
|
||||
}
|
||||
|
||||
func Down20200131183653(tx *sql.Tx) error {
|
||||
tx.Exec(`
|
||||
_, err := tx.Exec(`
|
||||
create table search_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
@@ -59,5 +59,5 @@ create index search_table
|
||||
|
||||
update annotation set item_type = 'mediaFile' where item_type = 'media_file';
|
||||
`)
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -51,6 +51,5 @@ create index annotation_starred
|
||||
}
|
||||
|
||||
func Down20200208222418(tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
129
db/migrations/20200220143731_change_duration_to_float.go
Normal file
129
db/migrations/20200220143731_change_duration_to_float.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200220143731, Down20200220143731)
|
||||
}
|
||||
|
||||
func Up20200220143731(tx *sql.Tx) error {
|
||||
notice(tx, "This migration will force the next scan to be a full rescan!")
|
||||
_, err := tx.Exec(`
|
||||
create table media_file_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
path varchar(255) default '' not null,
|
||||
title varchar(255) default '' not null,
|
||||
album varchar(255) default '' not null,
|
||||
artist varchar(255) default '' not null,
|
||||
artist_id varchar(255) default '' not null,
|
||||
album_artist varchar(255) default '' not null,
|
||||
album_id varchar(255) default '' not null,
|
||||
has_cover_art bool default FALSE not null,
|
||||
track_number integer default 0 not null,
|
||||
disc_number integer default 0 not null,
|
||||
year integer default 0 not null,
|
||||
size integer default 0 not null,
|
||||
suffix varchar(255) default '' not null,
|
||||
duration real default 0 not null,
|
||||
bit_rate integer default 0 not null,
|
||||
genre varchar(255) default '' not null,
|
||||
compilation bool default FALSE not null,
|
||||
created_at datetime,
|
||||
updated_at datetime
|
||||
);
|
||||
|
||||
insert into media_file_dg_tmp(id, path, title, album, artist, artist_id, album_artist, album_id, has_cover_art, track_number, disc_number, year, size, suffix, duration, bit_rate, genre, compilation, created_at, updated_at) select id, path, title, album, artist, artist_id, album_artist, album_id, has_cover_art, track_number, disc_number, year, size, suffix, duration, bit_rate, genre, compilation, created_at, updated_at from media_file;
|
||||
|
||||
drop table media_file;
|
||||
|
||||
alter table media_file_dg_tmp rename to media_file;
|
||||
|
||||
create index media_file_album_id
|
||||
on media_file (album_id);
|
||||
|
||||
create index media_file_genre
|
||||
on media_file (genre);
|
||||
|
||||
create index media_file_path
|
||||
on media_file (path);
|
||||
|
||||
create index media_file_title
|
||||
on media_file (title);
|
||||
|
||||
create table album_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
name varchar(255) default '' not null,
|
||||
artist_id varchar(255) default '' not null,
|
||||
cover_art_path varchar(255) default '' not null,
|
||||
cover_art_id varchar(255) default '' not null,
|
||||
artist varchar(255) default '' not null,
|
||||
album_artist varchar(255) default '' not null,
|
||||
year integer default 0 not null,
|
||||
compilation bool default FALSE not null,
|
||||
song_count integer default 0 not null,
|
||||
duration real default 0 not null,
|
||||
genre varchar(255) default '' not null,
|
||||
created_at datetime,
|
||||
updated_at datetime
|
||||
);
|
||||
|
||||
insert into album_dg_tmp(id, name, artist_id, cover_art_path, cover_art_id, artist, album_artist, year, compilation, song_count, duration, genre, created_at, updated_at) select id, name, artist_id, cover_art_path, cover_art_id, artist, album_artist, year, compilation, song_count, duration, genre, created_at, updated_at from album;
|
||||
|
||||
drop table album;
|
||||
|
||||
alter table album_dg_tmp rename to album;
|
||||
|
||||
create index album_artist
|
||||
on album (artist);
|
||||
|
||||
create index album_artist_id
|
||||
on album (artist_id);
|
||||
|
||||
create index album_genre
|
||||
on album (genre);
|
||||
|
||||
create index album_name
|
||||
on album (name);
|
||||
|
||||
create index album_year
|
||||
on album (year);
|
||||
|
||||
create table playlist_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
name varchar(255) default '' not null,
|
||||
comment varchar(255) default '' not null,
|
||||
duration real default 0 not null,
|
||||
owner varchar(255) default '' not null,
|
||||
public bool default FALSE not null,
|
||||
tracks text not null
|
||||
);
|
||||
|
||||
insert into playlist_dg_tmp(id, name, comment, duration, owner, public, tracks) select id, name, comment, duration, owner, public, tracks from playlist;
|
||||
|
||||
drop table playlist;
|
||||
|
||||
alter table playlist_dg_tmp rename to playlist;
|
||||
|
||||
create index playlist_name
|
||||
on playlist (name);
|
||||
|
||||
-- Force a full rescan
|
||||
delete from property where id like 'LastScan%';
|
||||
update media_file set updated_at = '0001-01-01';
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200220143731(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
20
db/migrations/20200310171621_enable_search_by_albumartist.go
Normal file
20
db/migrations/20200310171621_enable_search_by_albumartist.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200310171621, Down20200310171621)
|
||||
}
|
||||
|
||||
func Up20200310171621(tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed to enable search by Album Artist!")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200310171621(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200310181627, Down20200310181627)
|
||||
}
|
||||
|
||||
func Up20200310181627(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table transcoding
|
||||
(
|
||||
id varchar(255) not null primary key,
|
||||
name varchar(255) not null,
|
||||
target_format varchar(255) not null,
|
||||
command varchar(255) default '' not null,
|
||||
default_bit_rate int default 192,
|
||||
unique (name),
|
||||
unique (target_format)
|
||||
);
|
||||
|
||||
create table player
|
||||
(
|
||||
id varchar(255) not null primary key,
|
||||
name varchar not null,
|
||||
type varchar,
|
||||
user_name varchar not null,
|
||||
client varchar not null,
|
||||
ip_address varchar,
|
||||
last_seen timestamp,
|
||||
max_bit_rate int default 0,
|
||||
transcoding_id varchar,
|
||||
unique (name),
|
||||
foreign key (transcoding_id)
|
||||
references transcoding(id)
|
||||
on update restrict
|
||||
on delete restrict
|
||||
);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200310181627(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
drop table transcoding;
|
||||
drop table player;
|
||||
`)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200319211049, Down20200319211049)
|
||||
}
|
||||
|
||||
func Up20200319211049(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table media_file
|
||||
add full_text varchar(255) default '';
|
||||
create index if not exists media_file_full_text
|
||||
on media_file (full_text);
|
||||
|
||||
alter table album
|
||||
add full_text varchar(255) default '';
|
||||
create index if not exists album_full_text
|
||||
on album (full_text);
|
||||
|
||||
alter table artist
|
||||
add full_text varchar(255) default '';
|
||||
create index if not exists artist_full_text
|
||||
on artist (full_text);
|
||||
|
||||
drop table if exists search;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan will be performed!")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200319211049(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
34
db/migrations/20200325185135_add_album_artist_id.go
Normal file
34
db/migrations/20200325185135_add_album_artist_id.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200325185135, Down20200325185135)
|
||||
}
|
||||
|
||||
func Up20200325185135(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table album
|
||||
add album_artist_id varchar(255) default '';
|
||||
create index album_artist_album_id
|
||||
on album (album_artist_id);
|
||||
|
||||
alter table media_file
|
||||
add album_artist_id varchar(255) default '';
|
||||
create index media_file_artist_album_id
|
||||
on media_file (album_artist_id);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan will be performed!")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200325185135(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
20
db/migrations/20200326090707_fix_album_artists_importing.go
Normal file
20
db/migrations/20200326090707_fix_album_artists_importing.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200326090707, Down20200326090707)
|
||||
}
|
||||
|
||||
func Up20200326090707(tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed!")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200326090707(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
80
db/migrations/20200327193744_add_year_range_to_album.go
Normal file
80
db/migrations/20200327193744_add_year_range_to_album.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200327193744, Down20200327193744)
|
||||
}
|
||||
|
||||
func Up20200327193744(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table album_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
name varchar(255) default '' not null,
|
||||
artist_id varchar(255) default '' not null,
|
||||
cover_art_path varchar(255) default '' not null,
|
||||
cover_art_id varchar(255) default '' not null,
|
||||
artist varchar(255) default '' not null,
|
||||
album_artist varchar(255) default '' not null,
|
||||
min_year int default 0 not null,
|
||||
max_year integer default 0 not null,
|
||||
compilation bool default FALSE not null,
|
||||
song_count integer default 0 not null,
|
||||
duration real default 0 not null,
|
||||
genre varchar(255) default '' not null,
|
||||
created_at datetime,
|
||||
updated_at datetime,
|
||||
full_text varchar(255) default '',
|
||||
album_artist_id varchar(255) default ''
|
||||
);
|
||||
|
||||
insert into album_dg_tmp(id, name, artist_id, cover_art_path, cover_art_id, artist, album_artist, max_year, compilation, song_count, duration, genre, created_at, updated_at, full_text, album_artist_id) select id, name, artist_id, cover_art_path, cover_art_id, artist, album_artist, year, compilation, song_count, duration, genre, created_at, updated_at, full_text, album_artist_id from album;
|
||||
|
||||
drop table album;
|
||||
|
||||
alter table album_dg_tmp rename to album;
|
||||
|
||||
create index album_artist
|
||||
on album (artist);
|
||||
|
||||
create index album_artist_album
|
||||
on album (artist);
|
||||
|
||||
create index album_artist_album_id
|
||||
on album (album_artist_id);
|
||||
|
||||
create index album_artist_id
|
||||
on album (artist_id);
|
||||
|
||||
create index album_full_text
|
||||
on album (full_text);
|
||||
|
||||
create index album_genre
|
||||
on album (genre);
|
||||
|
||||
create index album_name
|
||||
on album (name);
|
||||
|
||||
create index album_min_year
|
||||
on album (min_year);
|
||||
|
||||
create index album_max_year
|
||||
on album (max_year);
|
||||
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan will be performed!")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200327193744(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
29
db/migrations/20200404214704_add_indexes.go
Normal file
29
db/migrations/20200404214704_add_indexes.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200404214704, Down20200404214704)
|
||||
}
|
||||
|
||||
func Up20200404214704(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create index if not exists media_file_year
|
||||
on media_file (year);
|
||||
|
||||
create index if not exists media_file_duration
|
||||
on media_file (duration);
|
||||
|
||||
create index if not exists media_file_track_number
|
||||
on media_file (disc_number, track_number);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200404214704(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200409002249, Down20200409002249)
|
||||
}
|
||||
|
||||
func Up20200409002249(tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed to enable search by individual Artist in an Album!")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200409002249(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200411164603, Down20200411164603)
|
||||
}
|
||||
|
||||
func Up20200411164603(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table playlist
|
||||
add created_at datetime;
|
||||
alter table playlist
|
||||
add updated_at datetime;
|
||||
update playlist
|
||||
set created_at = datetime('now'), updated_at = datetime('now');
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200411164603(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
20
db/migrations/20200418110522_reindex_to_fix_album_years.go
Normal file
20
db/migrations/20200418110522_reindex_to_fix_album_years.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200418110522, Down20200418110522)
|
||||
}
|
||||
|
||||
func Up20200418110522(tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed to fix search Albums by year")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200418110522(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200419222708, Down20200419222708)
|
||||
}
|
||||
|
||||
func Up20200419222708(tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed to change the search behaviour")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200419222708(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
65
db/migrations/20200423204116_add_sort_fields.go
Normal file
65
db/migrations/20200423204116_add_sort_fields.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200423204116, Down20200423204116)
|
||||
}
|
||||
|
||||
func Up20200423204116(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table artist
|
||||
add order_artist_name varchar(255) collate nocase;
|
||||
alter table artist
|
||||
add sort_artist_name varchar(255) collate nocase;
|
||||
create index if not exists artist_order_artist_name
|
||||
on artist (order_artist_name);
|
||||
|
||||
alter table album
|
||||
add order_album_name varchar(255) collate nocase;
|
||||
alter table album
|
||||
add order_album_artist_name varchar(255) collate nocase;
|
||||
alter table album
|
||||
add sort_album_name varchar(255) collate nocase;
|
||||
alter table album
|
||||
add sort_artist_name varchar(255) collate nocase;
|
||||
alter table album
|
||||
add sort_album_artist_name varchar(255) collate nocase;
|
||||
create index if not exists album_order_album_name
|
||||
on album (order_album_name);
|
||||
create index if not exists album_order_album_artist_name
|
||||
on album (order_album_artist_name);
|
||||
|
||||
alter table media_file
|
||||
add order_album_name varchar(255) collate nocase;
|
||||
alter table media_file
|
||||
add order_album_artist_name varchar(255) collate nocase;
|
||||
alter table media_file
|
||||
add order_artist_name varchar(255) collate nocase;
|
||||
alter table media_file
|
||||
add sort_album_name varchar(255) collate nocase;
|
||||
alter table media_file
|
||||
add sort_artist_name varchar(255) collate nocase;
|
||||
alter table media_file
|
||||
add sort_album_artist_name varchar(255) collate nocase;
|
||||
alter table media_file
|
||||
add sort_title varchar(255) collate nocase;
|
||||
create index if not exists media_file_order_album_name
|
||||
on media_file (order_album_name);
|
||||
create index if not exists media_file_order_artist_name
|
||||
on media_file (order_artist_name);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan will be performed to change the search behaviour")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200423204116(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
27
db/migrations/20200508093059_add_artist_song_count.go
Normal file
27
db/migrations/20200508093059_add_artist_song_count.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200508093059, Down20200508093059)
|
||||
}
|
||||
|
||||
func Up20200508093059(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table artist
|
||||
add song_count integer default 0 not null;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan will be performed to calculate artists' song counts")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200508093059(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
27
db/migrations/20200512104202_add_disc_subtitle.go
Normal file
27
db/migrations/20200512104202_add_disc_subtitle.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200512104202, Down20200512104202)
|
||||
}
|
||||
|
||||
func Up20200512104202(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table media_file
|
||||
add disc_subtitle varchar(255);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan will be performed to import disc subtitles")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200512104202(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
100
db/migrations/20200516140647_add_playlist_tracks_table.go
Normal file
100
db/migrations/20200516140647_add_playlist_tracks_table.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200516140647, Down20200516140647)
|
||||
}
|
||||
|
||||
func Up20200516140647(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table if not exists playlist_tracks
|
||||
(
|
||||
id integer default 0 not null,
|
||||
playlist_id varchar(255) not null,
|
||||
media_file_id varchar(255) not null
|
||||
);
|
||||
|
||||
create unique index if not exists playlist_tracks_pos
|
||||
on playlist_tracks (playlist_id, id);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := tx.Query("select id, tracks from playlist")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
var id, tracks string
|
||||
for rows.Next() {
|
||||
err := rows.Scan(&id, &tracks)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = Up20200516140647UpdatePlaylistTracks(tx, id, tracks)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
create table playlist_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
name varchar(255) default '' not null,
|
||||
comment varchar(255) default '' not null,
|
||||
duration real default 0 not null,
|
||||
song_count integer default 0 not null,
|
||||
owner varchar(255) default '' not null,
|
||||
public bool default FALSE not null,
|
||||
created_at datetime,
|
||||
updated_at datetime
|
||||
);
|
||||
|
||||
insert into playlist_dg_tmp(id, name, comment, duration, owner, public, created_at, updated_at)
|
||||
select id, name, comment, duration, owner, public, created_at, updated_at from playlist;
|
||||
|
||||
drop table playlist;
|
||||
|
||||
alter table playlist_dg_tmp rename to playlist;
|
||||
|
||||
create index playlist_name
|
||||
on playlist (name);
|
||||
|
||||
update playlist set song_count = (select count(*) from playlist_tracks where playlist_id = playlist.id)
|
||||
where id <> ''
|
||||
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Up20200516140647UpdatePlaylistTracks(tx *sql.Tx, id string, tracks string) error {
|
||||
trackList := strings.Split(tracks, ",")
|
||||
stmt, err := tx.Prepare("insert into playlist_tracks (playlist_id, media_file_id, id) values (?, ?, ?)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i, trackId := range trackList {
|
||||
_, err := stmt.Exec(id, trackId, i+1)
|
||||
if err != nil {
|
||||
log.Error("Error adding track to playlist", "playlistId", id, "trackId", trackId, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Down20200516140647(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
137
db/migrations/20200608153717_referential_integrity.go
Normal file
137
db/migrations/20200608153717_referential_integrity.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200608153717, Down20200608153717)
|
||||
}
|
||||
|
||||
func Up20200608153717(tx *sql.Tx) error {
|
||||
// First delete dangling players
|
||||
_, err := tx.Exec(`
|
||||
delete from player where user_name not in (select user_name from user)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Also delete dangling players
|
||||
_, err = tx.Exec(`
|
||||
delete from playlist where owner not in (select user_name from user)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Also delete dangling playlist tracks
|
||||
_, err = tx.Exec(`
|
||||
delete from playlist_tracks where playlist_id not in (select id from playlist)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add foreign key to player table
|
||||
err = updatePlayer_20200608153717(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add foreign key to playlist table
|
||||
err = updatePlaylist_20200608153717(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add foreign keys to playlist_tracks table
|
||||
return updatePlaylistTracks_20200608153717(tx)
|
||||
}
|
||||
|
||||
func updatePlayer_20200608153717(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table player_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
name varchar not null
|
||||
unique,
|
||||
type varchar,
|
||||
user_name varchar not null
|
||||
references user (user_name)
|
||||
on update cascade on delete cascade,
|
||||
client varchar not null,
|
||||
ip_address varchar,
|
||||
last_seen timestamp,
|
||||
max_bit_rate int default 0,
|
||||
transcoding_id varchar null
|
||||
);
|
||||
|
||||
insert into player_dg_tmp(id, name, type, user_name, client, ip_address, last_seen, max_bit_rate, transcoding_id) select id, name, type, user_name, client, ip_address, last_seen, max_bit_rate, transcoding_id from player;
|
||||
|
||||
drop table player;
|
||||
|
||||
alter table player_dg_tmp rename to player;
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func updatePlaylist_20200608153717(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table playlist_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
name varchar(255) default '' not null,
|
||||
comment varchar(255) default '' not null,
|
||||
duration real default 0 not null,
|
||||
song_count integer default 0 not null,
|
||||
owner varchar(255) default '' not null
|
||||
constraint playlist_user_user_name_fk
|
||||
references user (user_name)
|
||||
on update cascade on delete cascade,
|
||||
public bool default FALSE not null,
|
||||
created_at datetime,
|
||||
updated_at datetime
|
||||
);
|
||||
|
||||
insert into playlist_dg_tmp(id, name, comment, duration, song_count, owner, public, created_at, updated_at) select id, name, comment, duration, song_count, owner, public, created_at, updated_at from playlist;
|
||||
|
||||
drop table playlist;
|
||||
|
||||
alter table playlist_dg_tmp rename to playlist;
|
||||
|
||||
create index playlist_name
|
||||
on playlist (name);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func updatePlaylistTracks_20200608153717(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table playlist_tracks_dg_tmp
|
||||
(
|
||||
id integer default 0 not null,
|
||||
playlist_id varchar(255) not null
|
||||
constraint playlist_tracks_playlist_id_fk
|
||||
references playlist
|
||||
on update cascade on delete cascade,
|
||||
media_file_id varchar(255) not null
|
||||
);
|
||||
|
||||
insert into playlist_tracks_dg_tmp(id, playlist_id, media_file_id) select id, playlist_id, media_file_id from playlist_tracks;
|
||||
|
||||
drop table playlist_tracks;
|
||||
|
||||
alter table playlist_tracks_dg_tmp rename to playlist_tracks;
|
||||
|
||||
create unique index playlist_tracks_pos
|
||||
on playlist_tracks (playlist_id, id);
|
||||
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200608153717(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
43
db/migrations/20200706231659_add_default_transcodings.go
Normal file
43
db/migrations/20200706231659_add_default_transcodings.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upAddDefaultTranscodings, downAddDefaultTranscodings)
|
||||
}
|
||||
|
||||
func upAddDefaultTranscodings(tx *sql.Tx) error {
|
||||
row := tx.QueryRow("SELECT COUNT(*) FROM transcoding")
|
||||
var count int
|
||||
err := row.Scan(&count)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
stmt, err := tx.Prepare("insert into transcoding (id, name, target_format, default_bit_rate, command) values (?, ?, ?, ?, ?)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, t := range consts.DefaultTranscodings {
|
||||
r, _ := uuid.NewRandom()
|
||||
_, err := stmt.Exec(r.String(), t["name"], t["targetFormat"], t["defaultBitRate"], t["command"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func downAddDefaultTranscodings(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
27
db/migrations/20200710211442_add_playlist_path.go
Normal file
27
db/migrations/20200710211442_add_playlist_path.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upAddPlaylistPath, downAddPlaylistPath)
|
||||
}
|
||||
|
||||
func upAddPlaylistPath(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table playlist
|
||||
add path string default '' not null;
|
||||
|
||||
alter table playlist
|
||||
add sync bool default false not null;
|
||||
`)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddPlaylistPath(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
36
db/migrations/20200731095603_create_play_queues_table.go
Normal file
36
db/migrations/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/migrations/20200801101355_create_bookmark_table.go
Normal file
53
db/migrations/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
|
||||
}
|
||||
55
db/migrations/migration.go
Normal file
55
db/migrations/migration.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/deluan/navidrome/consts"
|
||||
)
|
||||
|
||||
// Use this in migrations that need to communicate something important (braking changes, forced reindexes, etc...)
|
||||
func notice(tx *sql.Tx, msg string) {
|
||||
if isDBInitialized(tx) {
|
||||
fmt.Printf(`
|
||||
*************************************************************************************
|
||||
NOTICE: %s
|
||||
*************************************************************************************
|
||||
|
||||
`, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Call this in migrations that requires a full rescan
|
||||
func forceFullRescan(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
delete from property where id like 'LastScan%';
|
||||
update media_file set updated_at = '0001-01-01';
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
var once sync.Once
|
||||
|
||||
func isDBInitialized(tx *sql.Tx) (initialized bool) {
|
||||
once.Do(func() {
|
||||
rows, err := tx.Query("select count(*) from property where id='" + consts.InitialSetupFlagKey + "'")
|
||||
checkErr(err)
|
||||
initialized = checkCount(rows) > 0
|
||||
})
|
||||
return initialized
|
||||
}
|
||||
|
||||
func checkCount(rows *sql.Rows) (count int) {
|
||||
for rows.Next() {
|
||||
err := rows.Scan(&count)
|
||||
checkErr(err)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func checkErr(err error) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
# This is just an example. Customize it to your needs.
|
||||
|
||||
version: "3"
|
||||
services:
|
||||
navidrome:
|
||||
image: deluan/navidrome:latest
|
||||
ports:
|
||||
- "4533:4533"
|
||||
environment:
|
||||
# All options with their default values:
|
||||
ND_MUSICFOLDER: /music
|
||||
ND_DATAFOLDER: /data
|
||||
ND_SCANINTERVAL: 1m
|
||||
ND_LOGLEVEL: info
|
||||
ND_PORT: 4533
|
||||
volumes:
|
||||
- "./data:/data"
|
||||
- "./music:/music"
|
||||
@@ -37,7 +37,7 @@ func (b *browser) MediaFolders(ctx context.Context) (model.MediaFolders, error)
|
||||
|
||||
func (b *browser) Indexes(ctx context.Context, mediaFolderId string, ifModifiedSince time.Time) (model.ArtistIndexes, time.Time, error) {
|
||||
// TODO Proper handling of mediaFolderId param
|
||||
folder, err := b.ds.MediaFolder(ctx).Get(mediaFolderId)
|
||||
folder, _ := b.ds.MediaFolder(ctx).Get(mediaFolderId)
|
||||
|
||||
l, err := b.ds.Property(ctx).DefaultGet(model.PropLastScan+"-"+folder.Path, "-1")
|
||||
ms, _ := strconv.ParseInt(l, 10, 64)
|
||||
@@ -61,7 +61,7 @@ type DirectoryInfo struct {
|
||||
Entries Entries
|
||||
Parent string
|
||||
Starred time.Time
|
||||
PlayCount int32
|
||||
PlayCount int64
|
||||
UserRating int
|
||||
AlbumCount int
|
||||
CoverArt string
|
||||
@@ -80,10 +80,6 @@ func (b *browser) Artist(ctx context.Context, id string) (*DirectoryInfo, error)
|
||||
return nil, err
|
||||
}
|
||||
log.Debug(ctx, "Found Artist", "id", id, "name", a.Name)
|
||||
var albumIds []string
|
||||
for _, al := range albums {
|
||||
albumIds = append(albumIds, al.ID)
|
||||
}
|
||||
return b.buildArtistDir(a, albums), nil
|
||||
}
|
||||
|
||||
@@ -93,11 +89,6 @@ func (b *browser) Album(ctx context.Context, id string) (*DirectoryInfo, error)
|
||||
return nil, err
|
||||
}
|
||||
log.Debug(ctx, "Found Album", "id", id, "name", al.Name)
|
||||
var mfIds []string
|
||||
for _, mf := range tracks {
|
||||
mfIds = append(mfIds, mf.ID)
|
||||
}
|
||||
|
||||
return b.buildAlbumDir(al, tracks), nil
|
||||
}
|
||||
|
||||
@@ -144,9 +135,10 @@ func (b *browser) buildArtistDir(a *model.Artist, albums model.Albums) *Director
|
||||
}
|
||||
|
||||
dir.Entries = make(Entries, len(albums))
|
||||
for i, al := range albums {
|
||||
for i := range albums {
|
||||
al := albums[i]
|
||||
dir.Entries[i] = FromAlbum(&al)
|
||||
dir.PlayCount += int32(al.PlayCount)
|
||||
dir.PlayCount += al.PlayCount
|
||||
}
|
||||
return dir
|
||||
}
|
||||
@@ -155,20 +147,23 @@ func (b *browser) buildAlbumDir(al *model.Album, tracks model.MediaFiles) *Direc
|
||||
dir := &DirectoryInfo{
|
||||
Id: al.ID,
|
||||
Name: al.Name,
|
||||
Parent: al.ArtistID,
|
||||
Artist: al.Artist,
|
||||
ArtistId: al.ArtistID,
|
||||
Parent: al.AlbumArtistID,
|
||||
Artist: al.AlbumArtist,
|
||||
ArtistId: al.AlbumArtistID,
|
||||
SongCount: al.SongCount,
|
||||
Duration: al.Duration,
|
||||
Duration: int(al.Duration),
|
||||
Created: al.CreatedAt,
|
||||
Year: al.Year,
|
||||
Year: al.MaxYear,
|
||||
Genre: al.Genre,
|
||||
CoverArt: al.CoverArtId,
|
||||
PlayCount: int32(al.PlayCount),
|
||||
Starred: al.StarredAt,
|
||||
PlayCount: al.PlayCount,
|
||||
UserRating: al.Rating,
|
||||
}
|
||||
|
||||
if al.Starred {
|
||||
dir.Starred = al.StarredAt
|
||||
}
|
||||
|
||||
dir.Entries = FromMediaFiles(tracks)
|
||||
return dir
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ var _ = Describe("Browser", func() {
|
||||
var repo *mockGenreRepository
|
||||
var b Browser
|
||||
|
||||
BeforeSuite(func() {
|
||||
BeforeEach(func() {
|
||||
repo = &mockGenreRepository{data: model.Genres{
|
||||
{Name: "Rock", SongCount: 1000, AlbumCount: 100},
|
||||
{Name: "", SongCount: 13, AlbumCount: 13},
|
||||
|
||||
130
engine/common.go
130
engine/common.go
@@ -1,46 +1,45 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
Id string
|
||||
Title string
|
||||
IsDir bool
|
||||
Parent string
|
||||
Album string
|
||||
Year int
|
||||
Artist string
|
||||
Genre string
|
||||
CoverArt string
|
||||
Starred time.Time
|
||||
Track int
|
||||
Duration int
|
||||
Size int
|
||||
Suffix string
|
||||
BitRate int
|
||||
ContentType string
|
||||
Path string
|
||||
PlayCount int32
|
||||
DiscNumber int
|
||||
Created time.Time
|
||||
AlbumId string
|
||||
ArtistId string
|
||||
Type string
|
||||
UserRating int
|
||||
SongCount int
|
||||
|
||||
UserName string
|
||||
MinutesAgo int
|
||||
PlayerId int
|
||||
PlayerName string
|
||||
AlbumCount int
|
||||
|
||||
AbsolutePath string
|
||||
Id string
|
||||
Title string
|
||||
IsDir bool
|
||||
Parent string
|
||||
Album string
|
||||
Year int
|
||||
Artist string
|
||||
Genre string
|
||||
CoverArt string
|
||||
Starred time.Time
|
||||
Track int
|
||||
Duration int
|
||||
Size int64
|
||||
Suffix string
|
||||
BitRate int
|
||||
ContentType string
|
||||
Path string
|
||||
PlayCount int32
|
||||
DiscNumber int
|
||||
Created time.Time
|
||||
AlbumId string
|
||||
ArtistId string
|
||||
Type string
|
||||
UserRating int
|
||||
SongCount int
|
||||
UserName string
|
||||
MinutesAgo int
|
||||
PlayerId int
|
||||
PlayerName string
|
||||
AlbumCount int
|
||||
BookmarkPosition int64
|
||||
}
|
||||
|
||||
type Entries []Entry
|
||||
@@ -51,7 +50,9 @@ func FromArtist(ar *model.Artist) Entry {
|
||||
e.Title = ar.Name
|
||||
e.AlbumCount = ar.AlbumCount
|
||||
e.IsDir = true
|
||||
e.Starred = ar.StarredAt
|
||||
if ar.Starred {
|
||||
e.Starred = ar.StarredAt
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
@@ -60,18 +61,20 @@ func FromAlbum(al *model.Album) Entry {
|
||||
e.Id = al.ID
|
||||
e.Title = al.Name
|
||||
e.IsDir = true
|
||||
e.Parent = al.ArtistID
|
||||
e.Parent = al.AlbumArtistID
|
||||
e.Album = al.Name
|
||||
e.Year = al.Year
|
||||
e.Year = al.MaxYear
|
||||
e.Artist = al.AlbumArtist
|
||||
e.Genre = al.Genre
|
||||
e.CoverArt = al.CoverArtId
|
||||
e.Created = al.CreatedAt
|
||||
e.AlbumId = al.ID
|
||||
e.ArtistId = al.ArtistID
|
||||
e.Duration = al.Duration
|
||||
e.ArtistId = al.AlbumArtistID
|
||||
e.Duration = int(al.Duration)
|
||||
e.SongCount = al.SongCount
|
||||
e.Starred = al.StarredAt
|
||||
if al.Starred {
|
||||
e.Starred = al.StarredAt
|
||||
}
|
||||
e.PlayCount = int32(al.PlayCount)
|
||||
e.UserRating = al.Rating
|
||||
return e
|
||||
@@ -88,44 +91,35 @@ func FromMediaFile(mf *model.MediaFile) Entry {
|
||||
e.Artist = mf.Artist
|
||||
e.Genre = mf.Genre
|
||||
e.Track = mf.TrackNumber
|
||||
e.Duration = mf.Duration
|
||||
e.Duration = int(mf.Duration)
|
||||
e.Size = mf.Size
|
||||
e.Suffix = mf.Suffix
|
||||
e.BitRate = mf.BitRate
|
||||
if mf.HasCoverArt {
|
||||
e.CoverArt = mf.ID
|
||||
} else {
|
||||
e.CoverArt = "al-" + mf.AlbumID
|
||||
}
|
||||
e.ContentType = mf.ContentType()
|
||||
e.AbsolutePath = mf.Path
|
||||
// Creates a "pseudo" Path, to avoid sending absolute paths to the client
|
||||
if mf.Path != "" {
|
||||
e.Path = fmt.Sprintf("%s/%s/%s.%s", realArtistName(mf), mf.Album, mf.Title, mf.Suffix)
|
||||
}
|
||||
e.Path = mf.Path
|
||||
e.DiscNumber = mf.DiscNumber
|
||||
e.Created = mf.CreatedAt
|
||||
e.AlbumId = mf.AlbumID
|
||||
e.ArtistId = mf.ArtistID
|
||||
e.Type = "music" // TODO Hardcoded for now
|
||||
e.Type = "music"
|
||||
e.PlayCount = int32(mf.PlayCount)
|
||||
e.Starred = mf.StarredAt
|
||||
e.UserRating = mf.Rating
|
||||
return e
|
||||
}
|
||||
|
||||
func realArtistName(mf *model.MediaFile) string {
|
||||
switch {
|
||||
case mf.Compilation:
|
||||
return "Various Artists"
|
||||
case mf.AlbumArtist != "":
|
||||
return mf.AlbumArtist
|
||||
if mf.Starred {
|
||||
e.Starred = mf.StarredAt
|
||||
}
|
||||
|
||||
return mf.Artist
|
||||
e.UserRating = mf.Rating
|
||||
e.BookmarkPosition = mf.BookmarkPosition
|
||||
return e
|
||||
}
|
||||
|
||||
func FromAlbums(albums model.Albums) Entries {
|
||||
entries := make(Entries, len(albums))
|
||||
for i, al := range albums {
|
||||
for i := range albums {
|
||||
al := albums[i]
|
||||
entries[i] = FromAlbum(&al)
|
||||
}
|
||||
return entries
|
||||
@@ -133,7 +127,8 @@ func FromAlbums(albums model.Albums) Entries {
|
||||
|
||||
func FromMediaFiles(mfs model.MediaFiles) Entries {
|
||||
entries := make(Entries, len(mfs))
|
||||
for i, mf := range mfs {
|
||||
for i := range mfs {
|
||||
mf := mfs[i]
|
||||
entries[i] = FromMediaFile(&mf)
|
||||
}
|
||||
return entries
|
||||
@@ -141,8 +136,17 @@ func FromMediaFiles(mfs model.MediaFiles) Entries {
|
||||
|
||||
func FromArtists(ars model.Artists) Entries {
|
||||
entries := make(Entries, len(ars))
|
||||
for i, ar := range ars {
|
||||
for i := range ars {
|
||||
ar := ars[i]
|
||||
entries[i] = FromArtist(&ar)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func userName(ctx context.Context) string {
|
||||
if user, ok := request.UserFrom(ctx); !ok {
|
||||
return "UNKNOWN"
|
||||
} else {
|
||||
return user.UserName
|
||||
}
|
||||
}
|
||||
|
||||
112
engine/cover.go
112
engine/cover.go
@@ -1,112 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
"image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/static"
|
||||
"github.com/dhowden/tag"
|
||||
"github.com/nfnt/resize"
|
||||
)
|
||||
|
||||
type Cover interface {
|
||||
Get(ctx context.Context, id string, size int, out io.Writer) error
|
||||
}
|
||||
|
||||
type cover struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func NewCover(ds model.DataStore) Cover {
|
||||
return &cover{ds}
|
||||
}
|
||||
|
||||
func (c *cover) getCoverPath(ctx context.Context, id string) (string, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(id, "al-"):
|
||||
id = id[3:]
|
||||
al, err := c.ds.Album(ctx).Get(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return al.CoverArtPath, nil
|
||||
default:
|
||||
mf, err := c.ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if mf.HasCoverArt {
|
||||
return mf.Path, nil
|
||||
}
|
||||
}
|
||||
return "", model.ErrNotFound
|
||||
}
|
||||
|
||||
func (c *cover) Get(ctx context.Context, id string, size int, out io.Writer) error {
|
||||
path, err := c.getCoverPath(ctx, id)
|
||||
if err != nil && err != model.ErrNotFound {
|
||||
return err
|
||||
}
|
||||
|
||||
var reader io.Reader
|
||||
|
||||
if err != model.ErrNotFound {
|
||||
reader, err = readFromTag(path)
|
||||
} else {
|
||||
var f http.File
|
||||
f, err = static.AssetFile().Open("default_cover.jpg")
|
||||
if err == nil {
|
||||
defer f.Close()
|
||||
reader = f
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
|
||||
if size > 0 {
|
||||
return resizeImage(reader, size, out)
|
||||
}
|
||||
_, err = io.Copy(out, reader)
|
||||
return err
|
||||
}
|
||||
|
||||
func resizeImage(reader io.Reader, size int, out io.Writer) error {
|
||||
img, _, err := image.Decode(reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m := resize.Resize(uint(size), 0, img, resize.NearestNeighbor)
|
||||
return jpeg.Encode(out, m, &jpeg.Options{Quality: 75})
|
||||
}
|
||||
|
||||
func readFromTag(path string) (io.Reader, 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("error extracting art from file " + path)
|
||||
}
|
||||
return bytes.NewReader(picture.Data), nil
|
||||
}
|
||||
@@ -9,88 +9,108 @@ import (
|
||||
)
|
||||
|
||||
type ListGenerator interface {
|
||||
GetNewest(ctx context.Context, offset int, size int) (Entries, error)
|
||||
GetRecent(ctx context.Context, offset int, size int) (Entries, error)
|
||||
GetFrequent(ctx context.Context, offset int, size int) (Entries, error)
|
||||
GetHighest(ctx context.Context, offset int, size int) (Entries, error)
|
||||
GetRandom(ctx context.Context, offset int, size int) (Entries, error)
|
||||
GetByName(ctx context.Context, offset int, size int) (Entries, error)
|
||||
GetByArtist(ctx context.Context, offset int, size int) (Entries, error)
|
||||
GetStarred(ctx context.Context, offset int, size int) (Entries, error)
|
||||
GetAllStarred(ctx context.Context) (artists Entries, albums Entries, mediaFiles Entries, err error)
|
||||
GetNowPlaying(ctx context.Context) (Entries, error)
|
||||
GetRandomSongs(ctx context.Context, size int, genre string) (Entries, error)
|
||||
GetSongs(ctx context.Context, offset, size int, filter ListFilter) (Entries, error)
|
||||
GetAlbums(ctx context.Context, offset, size int, filter ListFilter) (Entries, error)
|
||||
}
|
||||
|
||||
func NewListGenerator(ds model.DataStore, npRepo NowPlayingRepository) ListGenerator {
|
||||
return &listGenerator{ds, npRepo}
|
||||
}
|
||||
|
||||
type ListFilter model.QueryOptions
|
||||
|
||||
func ByNewest() ListFilter {
|
||||
return ListFilter{Sort: "createdAt", Order: "desc"}
|
||||
}
|
||||
|
||||
func ByRecent() ListFilter {
|
||||
return ListFilter{Sort: "playDate", Order: "desc", Filters: squirrel.Gt{"play_date": time.Time{}}}
|
||||
}
|
||||
|
||||
func ByFrequent() ListFilter {
|
||||
return ListFilter{Sort: "playCount", Order: "desc", Filters: squirrel.Gt{"play_count": 0}}
|
||||
}
|
||||
|
||||
func ByRandom() ListFilter {
|
||||
return ListFilter{Sort: "random()"}
|
||||
}
|
||||
|
||||
func ByName() ListFilter {
|
||||
return ListFilter{Sort: "name"}
|
||||
}
|
||||
|
||||
func ByArtist() ListFilter {
|
||||
return ListFilter{Sort: "artist"}
|
||||
}
|
||||
|
||||
func ByStarred() ListFilter {
|
||||
return ListFilter{Sort: "starred_at", Order: "desc", Filters: squirrel.Eq{"starred": true}}
|
||||
}
|
||||
|
||||
func ByRating() ListFilter {
|
||||
return ListFilter{Sort: "Rating", Order: "desc", Filters: squirrel.Gt{"rating": 0}}
|
||||
}
|
||||
|
||||
func ByGenre(genre string) ListFilter {
|
||||
return ListFilter{
|
||||
Sort: "genre asc, name asc",
|
||||
Filters: squirrel.Eq{"genre": genre},
|
||||
}
|
||||
}
|
||||
|
||||
func ByYear(fromYear, toYear int) ListFilter {
|
||||
return ListFilter{
|
||||
Sort: "max_year, name",
|
||||
Filters: squirrel.Or{
|
||||
squirrel.And{
|
||||
squirrel.GtOrEq{"min_year": fromYear},
|
||||
squirrel.LtOrEq{"min_year": toYear},
|
||||
},
|
||||
squirrel.And{
|
||||
squirrel.GtOrEq{"max_year": fromYear},
|
||||
squirrel.LtOrEq{"max_year": toYear},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func SongsByGenre(genre string) ListFilter {
|
||||
return ListFilter{
|
||||
Sort: "genre asc, title asc",
|
||||
Filters: squirrel.Eq{"genre": genre},
|
||||
}
|
||||
}
|
||||
|
||||
func SongsByRandom(genre string, fromYear, toYear int) ListFilter {
|
||||
options := ListFilter{
|
||||
Sort: "random()",
|
||||
}
|
||||
ff := squirrel.And{}
|
||||
if genre != "" {
|
||||
ff = append(ff, squirrel.Eq{"genre": genre})
|
||||
}
|
||||
if fromYear != 0 {
|
||||
ff = append(ff, squirrel.GtOrEq{"year": fromYear})
|
||||
}
|
||||
if toYear != 0 {
|
||||
ff = append(ff, squirrel.LtOrEq{"year": toYear})
|
||||
}
|
||||
options.Filters = ff
|
||||
return options
|
||||
}
|
||||
|
||||
type listGenerator struct {
|
||||
ds model.DataStore
|
||||
npRepo NowPlayingRepository
|
||||
}
|
||||
|
||||
func (g *listGenerator) query(ctx context.Context, qo model.QueryOptions) (Entries, error) {
|
||||
albums, err := g.ds.Album(ctx).GetAll(qo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
albumIds := make([]string, len(albums))
|
||||
for i, al := range albums {
|
||||
albumIds[i] = al.ID
|
||||
}
|
||||
return FromAlbums(albums), err
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetNewest(ctx context.Context, offset int, size int) (Entries, error) {
|
||||
qo := model.QueryOptions{Sort: "CreatedAt", Order: "desc", Offset: offset, Max: size}
|
||||
return g.query(ctx, qo)
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetRecent(ctx context.Context, offset int, size int) (Entries, error) {
|
||||
qo := model.QueryOptions{Sort: "PlayDate", Order: "desc", Offset: offset, Max: size,
|
||||
Filters: squirrel.Gt{"play_date": time.Time{}}}
|
||||
return g.query(ctx, qo)
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetFrequent(ctx context.Context, offset int, size int) (Entries, error) {
|
||||
qo := model.QueryOptions{Sort: "PlayCount", Order: "desc", Offset: offset, Max: size,
|
||||
Filters: squirrel.Gt{"play_count": 0}}
|
||||
return g.query(ctx, qo)
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetHighest(ctx context.Context, offset int, size int) (Entries, error) {
|
||||
qo := model.QueryOptions{Sort: "Rating", Order: "desc", Offset: offset, Max: size,
|
||||
Filters: squirrel.Gt{"rating": 0}}
|
||||
return g.query(ctx, qo)
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetByName(ctx context.Context, offset int, size int) (Entries, error) {
|
||||
qo := model.QueryOptions{Sort: "Name", Offset: offset, Max: size}
|
||||
return g.query(ctx, qo)
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetByArtist(ctx context.Context, offset int, size int) (Entries, error) {
|
||||
qo := model.QueryOptions{Sort: "Artist", Offset: offset, Max: size}
|
||||
return g.query(ctx, qo)
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetRandom(ctx context.Context, offset int, size int) (Entries, error) {
|
||||
albums, err := g.ds.Album(ctx).GetRandom(model.QueryOptions{Max: size, Offset: offset})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return FromAlbums(albums), nil
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetRandomSongs(ctx context.Context, size int, genre string) (Entries, error) {
|
||||
options := model.QueryOptions{Max: size}
|
||||
if genre != "" {
|
||||
options.Filters = squirrel.Eq{"genre": genre}
|
||||
}
|
||||
mediaFiles, err := g.ds.MediaFile(ctx).GetRandom(options)
|
||||
func (g *listGenerator) GetSongs(ctx context.Context, offset, size int, filter ListFilter) (Entries, error) {
|
||||
qo := model.QueryOptions(filter)
|
||||
qo.Offset = offset
|
||||
qo.Max = size
|
||||
mediaFiles, err := g.ds.MediaFile(ctx).GetAll(qo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -98,6 +118,18 @@ func (g *listGenerator) GetRandomSongs(ctx context.Context, size int, genre stri
|
||||
return FromMediaFiles(mediaFiles), nil
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetAlbums(ctx context.Context, offset, size int, filter ListFilter) (Entries, error) {
|
||||
qo := model.QueryOptions(filter)
|
||||
qo.Offset = offset
|
||||
qo.Max = size
|
||||
albums, err := g.ds.Album(ctx).GetAll(qo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return FromAlbums(albums), nil
|
||||
}
|
||||
|
||||
func (g *listGenerator) GetStarred(ctx context.Context, offset int, size int) (Entries, error) {
|
||||
qo := model.QueryOptions{Offset: offset, Max: size, Sort: "starred_at", Order: "desc"}
|
||||
albums, err := g.ds.Album(ctx).GetStarred(qo)
|
||||
@@ -126,16 +158,6 @@ func (g *listGenerator) GetAllStarred(ctx context.Context) (artists Entries, alb
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
var mfIds []string
|
||||
for _, mf := range mfs {
|
||||
mfIds = append(mfIds, mf.ID)
|
||||
}
|
||||
|
||||
var artistIds []string
|
||||
for _, ar := range ars {
|
||||
artistIds = append(artistIds, ar.ID)
|
||||
}
|
||||
|
||||
artists = FromArtists(ars)
|
||||
albums = FromAlbums(als)
|
||||
mediaFiles = FromMediaFiles(mfs)
|
||||
@@ -156,10 +178,9 @@ func (g *listGenerator) GetNowPlaying(ctx context.Context) (Entries, error) {
|
||||
}
|
||||
entries[i] = FromMediaFile(mf)
|
||||
entries[i].UserName = np.Username
|
||||
entries[i].MinutesAgo = int(time.Now().Sub(np.Start).Minutes())
|
||||
entries[i].MinutesAgo = int(time.Since(np.Start).Minutes())
|
||||
entries[i].PlayerId = np.PlayerId
|
||||
entries[i].PlayerName = np.PlayerName
|
||||
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"mime"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
)
|
||||
|
||||
type MediaStreamer interface {
|
||||
NewStream(ctx context.Context, id string, maxBitRate int, format string) (mediaStream, error)
|
||||
}
|
||||
|
||||
func NewMediaStreamer(ds model.DataStore) MediaStreamer {
|
||||
return &mediaStreamer{ds: ds}
|
||||
}
|
||||
|
||||
type mediaStream interface {
|
||||
io.ReadSeeker
|
||||
ContentType() string
|
||||
Name() string
|
||||
ModTime() time.Time
|
||||
Close() error
|
||||
Duration() int
|
||||
}
|
||||
|
||||
type mediaStreamer struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, maxBitRate int, format string) (mediaStream, error) {
|
||||
mf, err := ms.ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var bitRate int
|
||||
|
||||
if format == "raw" || !conf.Server.EnableDownsampling {
|
||||
bitRate = mf.BitRate
|
||||
format = mf.Suffix
|
||||
} else {
|
||||
if maxBitRate == 0 {
|
||||
bitRate = mf.BitRate
|
||||
} else {
|
||||
bitRate = utils.MinInt(mf.BitRate, maxBitRate)
|
||||
}
|
||||
format = mf.Suffix
|
||||
}
|
||||
if conf.Server.MaxBitRate != 0 {
|
||||
bitRate = utils.MinInt(bitRate, conf.Server.MaxBitRate)
|
||||
}
|
||||
|
||||
var stream mediaStream
|
||||
|
||||
if bitRate == mf.BitRate && mime.TypeByExtension("."+format) == mf.ContentType() {
|
||||
log.Debug(ctx, "Streaming raw file", "id", mf.ID, "path", mf.Path,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix)
|
||||
|
||||
f, err := os.Open(mf.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stream = &rawMediaStream{ctx: ctx, mf: mf, file: f}
|
||||
return stream, nil
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Streaming transcoded file", "id", mf.ID, "path", mf.Path,
|
||||
"requestBitrate", bitRate, "requestFormat", format,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix)
|
||||
|
||||
f := &transcodedMediaStream{ctx: ctx, mf: mf, bitRate: bitRate, format: format}
|
||||
return f, err
|
||||
}
|
||||
|
||||
type rawMediaStream struct {
|
||||
file *os.File
|
||||
ctx context.Context
|
||||
mf *model.MediaFile
|
||||
}
|
||||
|
||||
func (m *rawMediaStream) Read(p []byte) (n int, err error) {
|
||||
return m.file.Read(p)
|
||||
}
|
||||
|
||||
func (m *rawMediaStream) Seek(offset int64, whence int) (int64, error) {
|
||||
return m.file.Seek(offset, whence)
|
||||
}
|
||||
|
||||
func (m *rawMediaStream) ContentType() string {
|
||||
return m.mf.ContentType()
|
||||
}
|
||||
|
||||
func (m *rawMediaStream) Name() string {
|
||||
return m.mf.Path
|
||||
}
|
||||
|
||||
func (m *rawMediaStream) ModTime() time.Time {
|
||||
return m.mf.UpdatedAt
|
||||
}
|
||||
|
||||
func (m *rawMediaStream) Duration() int {
|
||||
return m.mf.Duration
|
||||
}
|
||||
|
||||
func (m *rawMediaStream) Close() error {
|
||||
log.Trace(m.ctx, "Closing file", "id", m.mf.ID, "path", m.mf.Path)
|
||||
return m.file.Close()
|
||||
}
|
||||
|
||||
type transcodedMediaStream struct {
|
||||
ctx context.Context
|
||||
mf *model.MediaFile
|
||||
pipe io.ReadCloser
|
||||
bitRate int
|
||||
format string
|
||||
skip int64
|
||||
pos int64
|
||||
}
|
||||
|
||||
func (m *transcodedMediaStream) Read(p []byte) (n int, err error) {
|
||||
// Open the pipe and optionally skip a initial chunk of the stream (to simulate a Seek)
|
||||
if m.pipe == nil {
|
||||
m.pipe, err = newTranscode(m.ctx, m.mf.Path, m.bitRate, m.format)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if m.skip > 0 {
|
||||
_, err := io.CopyN(ioutil.Discard, m.pipe, m.skip)
|
||||
m.pos = m.skip
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
n, err = m.pipe.Read(p)
|
||||
m.pos += int64(n)
|
||||
if err == io.EOF {
|
||||
m.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// This is an attempt to make a pipe seekable. It is very wasteful, restarting the stream every time
|
||||
// a Seek happens. This is ok-ish for audio, but would kill the server for video.
|
||||
func (m *transcodedMediaStream) Seek(offset int64, whence int) (int64, error) {
|
||||
size := int64((m.mf.Duration)*m.bitRate*1000) / 8
|
||||
log.Trace(m.ctx, "Seeking transcoded stream", "path", m.mf.Path, "offset", offset, "whence", whence, "size", size)
|
||||
|
||||
switch whence {
|
||||
case io.SeekEnd:
|
||||
m.skip = size - offset
|
||||
offset = size
|
||||
case io.SeekStart:
|
||||
m.skip = offset
|
||||
case io.SeekCurrent:
|
||||
io.CopyN(ioutil.Discard, m.pipe, offset)
|
||||
m.pos += offset
|
||||
offset = m.pos
|
||||
}
|
||||
|
||||
// If need to Seek to a previous position, close the pipe (will be restarted on next Read)
|
||||
var err error
|
||||
if whence != io.SeekCurrent {
|
||||
if m.pipe != nil {
|
||||
err = m.Close()
|
||||
}
|
||||
}
|
||||
return offset, err
|
||||
}
|
||||
|
||||
func (m *transcodedMediaStream) ContentType() string {
|
||||
return mime.TypeByExtension(".mp3")
|
||||
}
|
||||
|
||||
func (m *transcodedMediaStream) Name() string {
|
||||
return m.mf.Path
|
||||
}
|
||||
|
||||
func (m *transcodedMediaStream) ModTime() time.Time {
|
||||
return m.mf.UpdatedAt
|
||||
}
|
||||
|
||||
func (m *transcodedMediaStream) Duration() int {
|
||||
return m.mf.Duration
|
||||
}
|
||||
|
||||
func (m *transcodedMediaStream) Close() error {
|
||||
log.Trace(m.ctx, "Closing stream", "id", m.mf.ID, "path", m.mf.Path)
|
||||
err := m.pipe.Close()
|
||||
m.pipe = nil
|
||||
m.pos = 0
|
||||
return err
|
||||
}
|
||||
|
||||
func newTranscode(ctx context.Context, path string, maxBitRate int, format string) (f io.ReadCloser, err error) {
|
||||
cmdLine, args := createTranscodeCommand(path, maxBitRate, format)
|
||||
|
||||
log.Trace(ctx, "Executing ffmpeg command", "arg0", cmdLine, "args", args)
|
||||
cmd := exec.Command(cmdLine, args...)
|
||||
cmd.Stderr = os.Stderr
|
||||
if f, err = cmd.StdoutPipe(); err != nil {
|
||||
return f, err
|
||||
}
|
||||
if err = cmd.Start(); err != nil {
|
||||
return f, err
|
||||
}
|
||||
go cmd.Wait() // prevent zombies
|
||||
return f, err
|
||||
}
|
||||
|
||||
func createTranscodeCommand(path string, maxBitRate int, format string) (string, []string) {
|
||||
cmd := conf.Server.DownsampleCommand
|
||||
|
||||
split := strings.Split(cmd, " ")
|
||||
for i, s := range split {
|
||||
s = strings.Replace(s, "%s", path, -1)
|
||||
s = strings.Replace(s, "%b", strconv.Itoa(maxBitRate), -1)
|
||||
split[i] = s
|
||||
}
|
||||
|
||||
return split[0], split[1:]
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"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("MediaStreamer", func() {
|
||||
|
||||
var streamer MediaStreamer
|
||||
var ds model.DataStore
|
||||
ctx := log.NewContext(nil)
|
||||
|
||||
BeforeEach(func() {
|
||||
conf.Server.EnableDownsampling = true
|
||||
ds = &persistence.MockDataStore{}
|
||||
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "bitRate": 128}]`, 1)
|
||||
streamer = NewMediaStreamer(ds)
|
||||
})
|
||||
|
||||
Context("NewStream", func() {
|
||||
It("returns a rawMediaStream if format is 'raw'", func() {
|
||||
Expect(streamer.NewStream(ctx, "123", 0, "raw")).To(BeAssignableToTypeOf(&rawMediaStream{}))
|
||||
})
|
||||
It("returns a rawMediaStream if maxBitRate is 0", func() {
|
||||
Expect(streamer.NewStream(ctx, "123", 0, "mp3")).To(BeAssignableToTypeOf(&rawMediaStream{}))
|
||||
})
|
||||
It("returns a rawMediaStream if maxBitRate is higher than file bitRate", func() {
|
||||
Expect(streamer.NewStream(ctx, "123", 256, "mp3")).To(BeAssignableToTypeOf(&rawMediaStream{}))
|
||||
})
|
||||
It("returns a transcodedMediaStream if maxBitRate is lower than file bitRate", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", 64, "mp3")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(s).To(BeAssignableToTypeOf(&transcodedMediaStream{}))
|
||||
Expect(s.(*transcodedMediaStream).bitRate).To(Equal(64))
|
||||
})
|
||||
})
|
||||
|
||||
Context("rawMediaStream", func() {
|
||||
var rawStream mediaStream
|
||||
var modTime time.Time
|
||||
|
||||
BeforeEach(func() {
|
||||
modTime = time.Now()
|
||||
mf := &model.MediaFile{ID: "123", Path: "test.mp3", UpdatedAt: modTime, Suffix: "mp3"}
|
||||
rawStream = &rawMediaStream{mf: mf, ctx: ctx}
|
||||
})
|
||||
|
||||
It("returns the ContentType", func() {
|
||||
Expect(rawStream.ContentType()).To(Equal("audio/mpeg"))
|
||||
})
|
||||
|
||||
It("returns the ModTime", func() {
|
||||
Expect(rawStream.ModTime()).To(Equal(modTime))
|
||||
})
|
||||
})
|
||||
|
||||
Context("createTranscodeCommand", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DownsampleCommand = "ffmpeg -i %s -b:a %bk mp3 -"
|
||||
})
|
||||
It("creates a valid command line", func() {
|
||||
cmd, args := createTranscodeCommand("/music library/file.mp3", 123, "")
|
||||
Expect(cmd).To(Equal("ffmpeg"))
|
||||
Expect(args).To(Equal([]string{"-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
@@ -1,86 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
func CreateMockNowPlayingRepo() *MockNowPlaying {
|
||||
return &MockNowPlaying{}
|
||||
}
|
||||
|
||||
type MockNowPlaying struct {
|
||||
NowPlayingRepository
|
||||
data []NowPlayingInfo
|
||||
t time.Time
|
||||
err bool
|
||||
}
|
||||
|
||||
func (m *MockNowPlaying) SetError(err bool) {
|
||||
m.err = err
|
||||
}
|
||||
|
||||
func (m *MockNowPlaying) Enqueue(info *NowPlayingInfo) error {
|
||||
if m.err {
|
||||
return errors.New("Error!")
|
||||
}
|
||||
|
||||
m.data = append(m.data, NowPlayingInfo{})
|
||||
copy(m.data[1:], m.data[0:])
|
||||
m.data[0] = *info
|
||||
|
||||
if !m.t.IsZero() {
|
||||
m.data[0].Start = m.t
|
||||
m.t = time.Time{}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockNowPlaying) Dequeue(playerId int) (*NowPlayingInfo, error) {
|
||||
if len(m.data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
l := len(m.data)
|
||||
info := m.data[l-1]
|
||||
m.data = m.data[:l-1]
|
||||
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
func (m *MockNowPlaying) Count(playerId int) (int64, error) {
|
||||
return int64(len(m.data)), nil
|
||||
}
|
||||
|
||||
func (m *MockNowPlaying) GetAll() ([]*NowPlayingInfo, error) {
|
||||
np, err := m.Head(1)
|
||||
if np == nil || err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []*NowPlayingInfo{np}, err
|
||||
}
|
||||
|
||||
func (m *MockNowPlaying) Head(playerId int) (*NowPlayingInfo, error) {
|
||||
if len(m.data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
info := m.data[0]
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
func (m *MockNowPlaying) Tail(playerId int) (*NowPlayingInfo, error) {
|
||||
if len(m.data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
info := m.data[len(m.data)-1]
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
func (m *MockNowPlaying) ClearAll() {
|
||||
m.data = make([]NowPlayingInfo, 0)
|
||||
m.err = false
|
||||
}
|
||||
|
||||
func (m *MockNowPlaying) OverrideNow(t time.Time) {
|
||||
m.t = t
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
func CreateMockPropertyRepo() *MockProperty {
|
||||
return &MockProperty{data: make(map[string]string)}
|
||||
}
|
||||
|
||||
type MockProperty struct {
|
||||
model.PropertyRepository
|
||||
data map[string]string
|
||||
err bool
|
||||
}
|
||||
|
||||
func (m *MockProperty) SetError(err bool) {
|
||||
m.err = err
|
||||
}
|
||||
|
||||
func (m *MockProperty) Put(id string, value string) error {
|
||||
if m.err {
|
||||
return errors.New("Error!")
|
||||
}
|
||||
m.data[id] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockProperty) Get(id string) (string, error) {
|
||||
if m.err {
|
||||
return "", errors.New("Error!")
|
||||
}
|
||||
return m.data[id], nil
|
||||
}
|
||||
|
||||
func (m *MockProperty) DefaultGet(id string, defaultValue string) (string, error) {
|
||||
v, err := m.Get(id)
|
||||
|
||||
if v == "" {
|
||||
v = defaultValue
|
||||
}
|
||||
|
||||
return v, err
|
||||
}
|
||||
22
engine/mock_transcoding_repo_test.go
Normal file
22
engine/mock_transcoding_repo_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package engine
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,7 @@ func checkExpired(l *list.List, f func() *list.Element) *list.Element {
|
||||
return nil
|
||||
}
|
||||
start := e.Value.(*NowPlayingInfo).Start
|
||||
if time.Now().Sub(start) < NowPlayingExpire {
|
||||
if time.Since(start) < NowPlayingExpire {
|
||||
return e
|
||||
}
|
||||
l.Remove(e)
|
||||
|
||||
68
engine/players.go
Normal file
68
engine/players.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Players interface {
|
||||
Get(ctx context.Context, playerId string) (*model.Player, error)
|
||||
Register(ctx context.Context, id, client, typ, ip string) (*model.Player, *model.Transcoding, error)
|
||||
}
|
||||
|
||||
func NewPlayers(ds model.DataStore) Players {
|
||||
return &players{ds}
|
||||
}
|
||||
|
||||
type players struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func (p *players) Register(ctx context.Context, id, client, typ, ip string) (*model.Player, *model.Transcoding, error) {
|
||||
var plr *model.Player
|
||||
var trc *model.Transcoding
|
||||
var err error
|
||||
userName, _ := request.UsernameFrom(ctx)
|
||||
if id != "" {
|
||||
plr, err = p.ds.Player(ctx).Get(id)
|
||||
if err == nil && plr.Client != client {
|
||||
id = ""
|
||||
}
|
||||
}
|
||||
if err != nil || id == "" {
|
||||
plr, err = p.ds.Player(ctx).FindByName(client, userName)
|
||||
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(),
|
||||
Name: fmt.Sprintf("%s (%s)", client, userName),
|
||||
UserName: userName,
|
||||
Client: client,
|
||||
}
|
||||
log.Info("Registering new player", "id", plr.ID, "client", client, "username", userName)
|
||||
}
|
||||
}
|
||||
plr.LastSeen = time.Now()
|
||||
plr.Type = typ
|
||||
plr.IPAddress = ip
|
||||
err = p.ds.Player(ctx).Put(plr)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if plr.TranscodingId != "" {
|
||||
trc, err = p.ds.Transcoding(ctx).Get(plr.TranscodingId)
|
||||
}
|
||||
return plr, trc, err
|
||||
}
|
||||
|
||||
func (p *players) Get(ctx context.Context, playerId string) (*model.Player, error) {
|
||||
return p.ds.Player(ctx).Get(playerId)
|
||||
}
|
||||
140
engine/players_test.go
Normal file
140
engine/players_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package engine
|
||||
|
||||
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/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Players", func() {
|
||||
var players Players
|
||||
var repo *mockPlayerRepository
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "johndoe"})
|
||||
ctx = request.WithUsername(ctx, "johndoe")
|
||||
var beforeRegister time.Time
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = &mockPlayerRepository{}
|
||||
ds := &persistence.MockDataStore{MockedPlayer: repo, MockedTranscoding: &mockTranscodingRepository{}}
|
||||
players = NewPlayers(ds)
|
||||
beforeRegister = time.Now()
|
||||
})
|
||||
|
||||
Describe("Register", func() {
|
||||
It("creates a new player when no ID is specified", func() {
|
||||
p, trc, err := players.Register(ctx, "", "client", "chrome", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.ID).ToNot(BeEmpty())
|
||||
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
|
||||
Expect(p.Client).To(Equal("client"))
|
||||
Expect(p.UserName).To(Equal("johndoe"))
|
||||
Expect(p.Type).To(Equal("chrome"))
|
||||
Expect(repo.lastSaved).To(Equal(p))
|
||||
Expect(trc).To(BeNil())
|
||||
})
|
||||
|
||||
It("creates a new player if it cannot find any matching player", func() {
|
||||
p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.ID).ToNot(BeEmpty())
|
||||
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
|
||||
Expect(repo.lastSaved).To(Equal(p))
|
||||
Expect(trc).To(BeNil())
|
||||
})
|
||||
|
||||
It("creates a new player if client does not match the one in DB", func() {
|
||||
plr := &model.Player{ID: "123", Name: "A Player", Client: "client1111", LastSeen: time.Time{}}
|
||||
repo.add(plr)
|
||||
p, trc, err := players.Register(ctx, "123", "client2222", "chrome", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.ID).ToNot(BeEmpty())
|
||||
Expect(p.ID).ToNot(Equal("123"))
|
||||
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
|
||||
Expect(p.Client).To(Equal("client2222"))
|
||||
Expect(trc).To(BeNil())
|
||||
})
|
||||
|
||||
It("finds players by ID", func() {
|
||||
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", LastSeen: time.Time{}}
|
||||
repo.add(plr)
|
||||
p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.ID).To(Equal("123"))
|
||||
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
|
||||
Expect(repo.lastSaved).To(Equal(p))
|
||||
Expect(trc).To(BeNil())
|
||||
})
|
||||
|
||||
It("finds player by client and user names when ID is not found", func() {
|
||||
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserName: "johndoe", LastSeen: time.Time{}}
|
||||
repo.add(plr)
|
||||
p, _, err := players.Register(ctx, "999", "client", "chrome", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.ID).To(Equal("123"))
|
||||
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
|
||||
Expect(repo.lastSaved).To(Equal(p))
|
||||
})
|
||||
|
||||
It("finds player by client and user names when not ID is provided", func() {
|
||||
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserName: "johndoe", LastSeen: time.Time{}}
|
||||
repo.add(plr)
|
||||
p, _, err := players.Register(ctx, "", "client", "chrome", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.ID).To(Equal("123"))
|
||||
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
|
||||
Expect(repo.lastSaved).To(Equal(p))
|
||||
})
|
||||
|
||||
It("finds player by ID and return its transcoding", func() {
|
||||
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", LastSeen: time.Time{}, TranscodingId: "1"}
|
||||
repo.add(plr)
|
||||
p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.ID).To(Equal("123"))
|
||||
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
|
||||
Expect(repo.lastSaved).To(Equal(p))
|
||||
Expect(trc.ID).To(Equal("1"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockPlayerRepository struct {
|
||||
model.PlayerRepository
|
||||
lastSaved *model.Player
|
||||
data map[string]model.Player
|
||||
}
|
||||
|
||||
func (m *mockPlayerRepository) add(p *model.Player) {
|
||||
if m.data == nil {
|
||||
m.data = make(map[string]model.Player)
|
||||
}
|
||||
m.data[p.ID] = *p
|
||||
}
|
||||
|
||||
func (m *mockPlayerRepository) Get(id string) (*model.Player, error) {
|
||||
if p, ok := m.data[id]; ok {
|
||||
return &p, nil
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockPlayerRepository) FindByName(client, userName string) (*model.Player, error) {
|
||||
for _, p := range m.data {
|
||||
if p.Client == client && p.UserName == userName {
|
||||
return &p, nil
|
||||
}
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockPlayerRepository) Put(p *model.Player) error {
|
||||
m.lastSaved = p
|
||||
return nil
|
||||
}
|
||||
@@ -2,8 +2,10 @@ package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/model/request"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
)
|
||||
|
||||
@@ -24,34 +26,37 @@ type playlists struct {
|
||||
}
|
||||
|
||||
func (p *playlists) Create(ctx context.Context, playlistId, name string, ids []string) error {
|
||||
owner := p.getUser(ctx)
|
||||
var pls *model.Playlist
|
||||
var err error
|
||||
// If playlistID is present, override tracks
|
||||
if playlistId != "" {
|
||||
pls, err = p.ds.Playlist(ctx).Get(playlistId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if owner != pls.Owner {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
pls.Tracks = nil
|
||||
} else {
|
||||
pls = &model.Playlist{
|
||||
Name: name,
|
||||
Owner: owner,
|
||||
}
|
||||
}
|
||||
for _, id := range ids {
|
||||
pls.Tracks = append(pls.Tracks, model.MediaFile{ID: id})
|
||||
}
|
||||
return p.ds.WithTx(func(tx model.DataStore) error {
|
||||
owner := p.getUser(ctx)
|
||||
var pls *model.Playlist
|
||||
var err error
|
||||
|
||||
return p.ds.Playlist(ctx).Put(pls)
|
||||
// If playlistID is present, override tracks
|
||||
if playlistId != "" {
|
||||
pls, err = tx.Playlist(ctx).Get(playlistId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if owner != pls.Owner {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
pls.Tracks = nil
|
||||
} else {
|
||||
pls = &model.Playlist{
|
||||
Name: name,
|
||||
Owner: owner,
|
||||
}
|
||||
}
|
||||
for _, id := range ids {
|
||||
pls.Tracks = append(pls.Tracks, model.MediaFile{ID: id})
|
||||
}
|
||||
|
||||
return tx.Playlist(ctx).Put(pls)
|
||||
})
|
||||
}
|
||||
|
||||
func (p *playlists) getUser(ctx context.Context) string {
|
||||
user, ok := ctx.Value("user").(*model.User)
|
||||
user, ok := request.UserFrom(ctx)
|
||||
if ok {
|
||||
return user.UserName
|
||||
}
|
||||
@@ -59,50 +64,54 @@ func (p *playlists) getUser(ctx context.Context) string {
|
||||
}
|
||||
|
||||
func (p *playlists) Delete(ctx context.Context, playlistId string) error {
|
||||
pls, err := p.ds.Playlist(ctx).Get(playlistId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.ds.WithTx(func(tx model.DataStore) error {
|
||||
pls, err := tx.Playlist(ctx).Get(playlistId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
owner := p.getUser(ctx)
|
||||
if owner != pls.Owner {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
return p.ds.Playlist(nil).Delete(playlistId)
|
||||
owner := p.getUser(ctx)
|
||||
if owner != pls.Owner {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
return tx.Playlist(ctx).Delete(playlistId)
|
||||
})
|
||||
}
|
||||
|
||||
func (p *playlists) Update(ctx context.Context, playlistId string, name *string, idsToAdd []string, idxToRemove []int) error {
|
||||
pls, err := p.ds.Playlist(ctx).Get(playlistId)
|
||||
|
||||
owner := p.getUser(ctx)
|
||||
if owner != pls.Owner {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if name != nil {
|
||||
pls.Name = *name
|
||||
}
|
||||
newTracks := model.MediaFiles{}
|
||||
for i, t := range pls.Tracks {
|
||||
if utils.IntInSlice(i, idxToRemove) {
|
||||
continue
|
||||
return p.ds.WithTx(func(tx model.DataStore) error {
|
||||
pls, err := tx.Playlist(ctx).Get(playlistId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newTracks = append(newTracks, t)
|
||||
}
|
||||
|
||||
for _, id := range idsToAdd {
|
||||
newTracks = append(newTracks, model.MediaFile{ID: id})
|
||||
}
|
||||
pls.Tracks = newTracks
|
||||
owner := p.getUser(ctx)
|
||||
if owner != pls.Owner {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
|
||||
return p.ds.Playlist(ctx).Put(pls)
|
||||
if name != nil {
|
||||
pls.Name = *name
|
||||
}
|
||||
newTracks := model.MediaFiles{}
|
||||
for i, t := range pls.Tracks {
|
||||
if utils.IntInSlice(i, idxToRemove) {
|
||||
continue
|
||||
}
|
||||
newTracks = append(newTracks, t)
|
||||
}
|
||||
|
||||
for _, id := range idsToAdd {
|
||||
newTracks = append(newTracks, model.MediaFile{ID: id})
|
||||
}
|
||||
pls.Tracks = newTracks
|
||||
|
||||
return tx.Playlist(ctx).Put(pls)
|
||||
})
|
||||
}
|
||||
|
||||
func (p *playlists) GetAll(ctx context.Context) (model.Playlists, error) {
|
||||
return p.ds.Playlist(ctx).GetAll(model.QueryOptions{})
|
||||
return p.ds.Playlist(ctx).GetAll()
|
||||
}
|
||||
|
||||
type PlaylistInfo struct {
|
||||
@@ -114,10 +123,12 @@ type PlaylistInfo struct {
|
||||
Public bool
|
||||
Owner string
|
||||
Comment string
|
||||
Created time.Time
|
||||
Changed time.Time
|
||||
}
|
||||
|
||||
func (p *playlists) Get(ctx context.Context, id string) (*PlaylistInfo, error) {
|
||||
pl, err := p.ds.Playlist(ctx).GetWithTracks(id)
|
||||
pl, err := p.ds.Playlist(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -126,11 +137,13 @@ func (p *playlists) Get(ctx context.Context, id string) (*PlaylistInfo, error) {
|
||||
plsInfo := &PlaylistInfo{
|
||||
Id: pl.ID,
|
||||
Name: pl.Name,
|
||||
SongCount: len(pl.Tracks),
|
||||
Duration: pl.Duration,
|
||||
SongCount: pl.SongCount,
|
||||
Duration: int(pl.Duration),
|
||||
Public: pl.Public,
|
||||
Owner: pl.Owner,
|
||||
Comment: pl.Comment,
|
||||
Changed: pl.UpdatedAt,
|
||||
Created: pl.CreatedAt,
|
||||
}
|
||||
|
||||
plsInfo.Entries = FromMediaFiles(pl.Tracks)
|
||||
|
||||
@@ -2,10 +2,10 @@ package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
@@ -42,6 +42,13 @@ func (s *scrobbler) Register(ctx context.Context, playerId int, trackId string,
|
||||
err = s.ds.Artist(ctx).IncPlayCount(mf.ArtistID, playTime)
|
||||
return err
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Error("Error while scrobbling", "trackId", trackId, err)
|
||||
} else {
|
||||
log.Info("Scrobbled", "title", mf.Title, "artist", mf.Artist, "user", userName(ctx))
|
||||
}
|
||||
|
||||
return mf, err
|
||||
}
|
||||
|
||||
@@ -53,9 +60,11 @@ func (s *scrobbler) NowPlaying(ctx context.Context, playerId int, playerName, tr
|
||||
}
|
||||
|
||||
if mf == nil {
|
||||
return nil, errors.New(fmt.Sprintf(`ID "%s" not found`, trackId))
|
||||
return nil, fmt.Errorf(`ID "%s" not found`, trackId)
|
||||
}
|
||||
|
||||
log.Info("Now Playing", "title", mf.Title, "artist", mf.Artist, "user", userName(ctx))
|
||||
|
||||
info := &NowPlayingInfo{TrackID: trackId, Username: username, Start: time.Now(), PlayerId: playerId, PlayerName: playerName}
|
||||
return mf, s.npRepo.Enqueue(info)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/engine/auth"
|
||||
"github.com/deluan/navidrome/core/auth"
|
||||
"github.com/deluan/navidrome/model"
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package engine
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/deluan/navidrome/engine/auth"
|
||||
"github.com/deluan/navidrome/core/auth"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo"
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package engine
|
||||
|
||||
import "github.com/google/wire"
|
||||
import (
|
||||
"github.com/google/wire"
|
||||
)
|
||||
|
||||
var Set = wire.NewSet(
|
||||
NewBrowser,
|
||||
NewCover,
|
||||
NewListGenerator,
|
||||
NewPlaylists,
|
||||
NewRatings,
|
||||
@@ -12,5 +13,5 @@ var Set = wire.NewSet(
|
||||
NewSearch,
|
||||
NewNowPlayingRepository,
|
||||
NewUsers,
|
||||
NewMediaStreamer,
|
||||
NewPlayers,
|
||||
)
|
||||
|
||||
@@ -10,7 +10,11 @@
|
||||
#
|
||||
# This script does not handle file names that contain spaces.
|
||||
|
||||
gofmtcmd=`which goimports || echo "gofmt"`
|
||||
if which goimports > /dev/null; then
|
||||
gofmtcmd=goimports
|
||||
else
|
||||
gofmtcmd=gofmt
|
||||
fi
|
||||
|
||||
gofiles=$(git diff --cached --name-only --diff-filter=ACM | grep '.go$')
|
||||
[ -z "$gofiles" ] && exit 0
|
||||
@@ -20,7 +24,7 @@ unformatted=$($gofmtcmd -l $gofiles)
|
||||
|
||||
# Some files are not gofmt'd. Print message and fail.
|
||||
|
||||
echo >&2 "Go files must be formatted with $gofmcmd. Please run:"
|
||||
echo >&2 "Go files must be formatted with '$gofmtcmd'. Please run:"
|
||||
for fn in $unformatted; do
|
||||
echo >&2 " $gofmtcmd -w $PWD/$fn"
|
||||
done
|
||||
9
git/pre-push
Executable file
9
git/pre-push
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "#### Linting"
|
||||
golangci-lint run -v
|
||||
|
||||
echo
|
||||
echo "#### Running tests"
|
||||
make test
|
||||
59
go.mod
59
go.mod
@@ -1,42 +1,47 @@
|
||||
module github.com/deluan/navidrome
|
||||
|
||||
go 1.13
|
||||
go 1.14
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v0.3.1 // indirect
|
||||
github.com/Masterminds/squirrel v1.2.0
|
||||
github.com/astaxie/beego v1.12.1
|
||||
github.com/Masterminds/squirrel v1.4.0
|
||||
github.com/astaxie/beego v1.12.2
|
||||
github.com/bradleyjkemp/cupaloy v2.3.0+incompatible
|
||||
github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4
|
||||
github.com/deluan/rest v0.0.0-20200327222046-b71e558c45d0
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible
|
||||
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8
|
||||
github.com/fatih/camelcase v0.0.0-20160318181535-f6a740d52f96 // indirect
|
||||
github.com/fatih/structs v1.0.0 // indirect
|
||||
github.com/go-chi/chi v4.0.3+incompatible
|
||||
github.com/go-chi/cors v1.0.0
|
||||
github.com/dhowden/tag v0.0.0-20200412032933-5d76b8eaae27
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/djherbis/fscache v0.10.1
|
||||
github.com/dustin/go-humanize v1.0.0
|
||||
github.com/go-chi/chi v4.1.2+incompatible
|
||||
github.com/go-chi/cors v1.1.1
|
||||
github.com/go-chi/httprate v0.4.0
|
||||
github.com/go-chi/jwtauth v4.0.4+incompatible
|
||||
github.com/go-sql-driver/mysql v1.5.0 // indirect
|
||||
github.com/golang/protobuf v1.3.1 // indirect
|
||||
github.com/google/uuid v1.1.1
|
||||
github.com/google/wire v0.4.0
|
||||
github.com/kennygrant/sanitize v0.0.0-20170120101633-6a0bfdde8629
|
||||
github.com/koding/multiconfig v0.0.0-20170327155832-26b6dfd3a84a
|
||||
github.com/kr/pretty v0.1.0 // indirect
|
||||
github.com/lib/pq v1.3.0 // indirect
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible
|
||||
github.com/nfnt/resize v0.0.0-20160724205520-891127d8d1b5
|
||||
github.com/onsi/ginkgo v1.12.0
|
||||
github.com/onsi/gomega v1.9.0
|
||||
github.com/microcosm-cc/bluemonday v1.0.3
|
||||
github.com/mitchellh/mapstructure v1.3.2 // indirect
|
||||
github.com/onsi/ginkgo v1.14.0
|
||||
github.com/onsi/gomega v1.10.1
|
||||
github.com/pelletier/go-toml v1.8.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pressly/goose v2.6.0+incompatible
|
||||
github.com/sirupsen/logrus v1.4.2
|
||||
github.com/smartystreets/assertions v1.0.1 // indirect
|
||||
github.com/smartystreets/goconvey v1.6.4
|
||||
github.com/stretchr/testify v1.4.0 // indirect
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2 // indirect
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5 // indirect
|
||||
golang.org/x/text v0.3.2 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
|
||||
gopkg.in/yaml.v2 v2.2.8 // indirect
|
||||
github.com/sirupsen/logrus v1.6.0
|
||||
github.com/spf13/afero v1.3.1 // indirect
|
||||
github.com/spf13/cast v1.3.1 // indirect
|
||||
github.com/spf13/cobra v1.0.0
|
||||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/spf13/viper v1.7.0
|
||||
golang.org/x/net v0.0.0-20200625001655-4c5254603344 // indirect
|
||||
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae // indirect
|
||||
golang.org/x/text v0.3.3 // indirect
|
||||
google.golang.org/protobuf v1.25.0 // indirect
|
||||
gopkg.in/djherbis/atime.v1 v1.0.0 // indirect
|
||||
gopkg.in/djherbis/stream.v1 v1.3.1 // indirect
|
||||
gopkg.in/ini.v1 v1.57.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/dhowden/tag => github.com/wader/tag v0.0.0-20200426234345-d072771f6a51
|
||||
|
||||
480
go.sum
480
go.sum
@@ -1,79 +1,213 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
||||
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
|
||||
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
||||
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
|
||||
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
|
||||
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
||||
cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
|
||||
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
||||
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/Knetic/govaluate v3.0.0+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
|
||||
github.com/Masterminds/squirrel v1.2.0 h1:K1NhbTO21BWG47IVR0OnIZuE0LZcXAYqywrC3Ko53KI=
|
||||
github.com/Masterminds/squirrel v1.2.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA=
|
||||
github.com/OwnLocal/goes v1.0.0/go.mod h1:8rIFjBGTue3lCU0wplczcUgt9Gxgrkkrw7etMIcn8TM=
|
||||
github.com/astaxie/beego v1.12.1 h1:dfpuoxpzLVgclveAXe4PyNKqkzgm5zF4tgF2B3kkM2I=
|
||||
github.com/astaxie/beego v1.12.1/go.mod h1:kPBWpSANNbSdIqOc8SUL9h+1oyBMZhROeYsXQDbidWQ=
|
||||
github.com/Masterminds/squirrel v1.4.0 h1:he5i/EXixZxrBUWcxzDYMiju9WZ3ld/l7QBNuo/eN3w=
|
||||
github.com/Masterminds/squirrel v1.4.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA=
|
||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/alicebob/gopher-json v0.0.0-20180125190556-5a6b3ba71ee6/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
|
||||
github.com/alicebob/miniredis v2.5.0+incompatible/go.mod h1:8HZjEj4yU0dwhYHky+DxYx+6BMjkBbe5ONFIF1MXffk=
|
||||
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
|
||||
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
|
||||
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
|
||||
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/astaxie/beego v1.12.2 h1:CajUexhSX5ONWDiSCpeQBNVfTzOtPb9e9d+3vuU5FuU=
|
||||
github.com/astaxie/beego v1.12.2/go.mod h1:TMcqhsbhN3UFpN+RCfysaxPAbrhox6QSS3NIAEp/uzE=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/beego/goyaml2 v0.0.0-20130207012346-5545475820dd/go.mod h1:1b+Y/CofkYwXMUU0OhQqGvsY2Bvgr4j6jfT699wyZKQ=
|
||||
github.com/beego/x2j v0.0.0-20131220205130-a0352aadc542/go.mod h1:kSeGC/p1AbBiEp5kat81+DSQrZenVBZXklMLaELspWU=
|
||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
||||
github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84=
|
||||
github.com/bradfitz/gomemcache v0.0.0-20180710155616-bc664df96737/go.mod h1:PmM6Mmwb0LSuEubjR8N7PtNe1KxZLtOUHtbeikc5h60=
|
||||
github.com/bradleyjkemp/cupaloy v2.3.0+incompatible h1:UafIjBvWQmS9i/xRg+CamMrnLTKNzo+bdmT/oH34c2Y=
|
||||
github.com/bradleyjkemp/cupaloy v2.3.0+incompatible/go.mod h1:Au1Xw1sgaJ5iSFktEhYsS0dbQiS1B0/XMXl+42y9Ilk=
|
||||
github.com/casbin/casbin v1.7.0/go.mod h1:c67qKN6Oum3UF5Q1+BByfFxkwKvhwW57ITjqwtzR1KE=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
|
||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||
github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chris-ramon/douceur v0.2.0 h1:IDMEdxlEUUBYBKE4z/mJnFyVXox+MjuEVDJNN27glkU=
|
||||
github.com/chris-ramon/douceur v0.2.0/go.mod h1:wDW5xjJdeoMm1mRt4sD4c/LbF/mWdEpRXQKjTR8nIBE=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58/go.mod h1:EOBUe0h4xcZ5GoxqC5SDxFQ8gwyZPKQoEzownBlhI80=
|
||||
github.com/couchbase/go-couchbase v0.0.0-20181122212707-3e9b6e1258bb/go.mod h1:TWI8EKQMs5u5jLKW/tsb9VwauIrMIxQG1r5fMsswK5U=
|
||||
github.com/couchbase/gomemcached v0.0.0-20181122193126-5125a94a666c/go.mod h1:srVSlQLB8iXBVXHgnqemxUXqN6FCvClgCMPCsjBDR7c=
|
||||
github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
|
||||
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||
github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
|
||||
github.com/couchbase/go-couchbase v0.0.0-20200519150804-63f3cdb75e0d/go.mod h1:TWI8EKQMs5u5jLKW/tsb9VwauIrMIxQG1r5fMsswK5U=
|
||||
github.com/couchbase/gomemcached v0.0.0-20200526233749-ec430f949808/go.mod h1:srVSlQLB8iXBVXHgnqemxUXqN6FCvClgCMPCsjBDR7c=
|
||||
github.com/couchbase/goutils v0.0.0-20180530154633-e865a1461c8a/go.mod h1:BQwMFlJzDjFDG3DJUdU0KORxn88UlsOULuxLExMh3Hs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/cupcake/rdb v0.0.0-20161107195141-43ba34106c76 h1:Lgdd/Qp96Qj8jqLpq2cI1I1X7BJnu06efS+XkhRoLUQ=
|
||||
github.com/cupcake/rdb v0.0.0-20161107195141-43ba34106c76/go.mod h1:vYwsqCOLxGiisLwp9rITslkFNpZD5rz43tf41QFkTWY=
|
||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4 h1:VSoAcWJvj656TSyWbJ5KuGsi/J8dO5+iO9+5/7I8wao=
|
||||
github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
|
||||
github.com/deluan/rest v0.0.0-20200327222046-b71e558c45d0 h1:qHX6TTBIsrpg0JkYNkoePIpstV37lhRVLj23bHUDNwk=
|
||||
github.com/deluan/rest v0.0.0-20200327222046-b71e558c45d0/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8 h1:nmFnmD8VZXkjDHIb1Gnfz50cgzUvGN72zLjPRXBW/hU=
|
||||
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8/go.mod h1:SniNVYuaD1jmdEEvi+7ywb1QFR7agjeTdGKyFb0p7Rw=
|
||||
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/djherbis/fscache v0.10.1 h1:hDv+RGyvD+UDKyRYuLoVNbuRTnf2SrA2K3VyR1br9lk=
|
||||
github.com/djherbis/fscache v0.10.1/go.mod h1:yyPYtkNnnPXsW+81lAcQS6yab3G2CRfnPLotBvtbf0c=
|
||||
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712 h1:aaQcKT9WumO6JEJcRyTqFVq4XUZiUcKR2/GI31TOcz8=
|
||||
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
|
||||
github.com/elastic/go-elasticsearch/v6 v6.8.5/go.mod h1:UwaDJsD3rWLM5rKNFzv9hgox93HoX8utj1kxD9aFUcI=
|
||||
github.com/elazarl/go-bindata-assetfs v1.0.0/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
|
||||
github.com/fatih/camelcase v0.0.0-20160318181535-f6a740d52f96 h1:5e8GDOdG6jKeeqNGbR+tlmqhf4vQVs3atTTMEWeEcAk=
|
||||
github.com/fatih/camelcase v0.0.0-20160318181535-f6a740d52f96/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc=
|
||||
github.com/fatih/structs v1.0.0 h1:BrX964Rv5uQ3wwS+KRUAJCBBw5PQmgJfJ6v4yly5QwU=
|
||||
github.com/fatih/structs v1.0.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/go-chi/chi v4.0.3+incompatible h1:gakN3pDJnzZN5jqFV2TEdF66rTfKeITyR8qu6ekICEY=
|
||||
github.com/go-chi/chi v4.0.3+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
|
||||
github.com/go-chi/cors v1.0.0 h1:e6x8k7uWbUwYs+aXDoiUzeQFT6l0cygBYyNhD7/1Tg0=
|
||||
github.com/go-chi/cors v1.0.0/go.mod h1:K2Yje0VW/SJzxiyMYu6iPQYa7hMjQX2i/F491VChg1I=
|
||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/glendc/gopher-json v0.0.0-20170414221815-dc4743023d0c/go.mod h1:Gja1A+xZ9BoviGJNA2E9vFkPjjsl+CoJxSXiQM1UXtw=
|
||||
github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec=
|
||||
github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
|
||||
github.com/go-chi/cors v1.1.1 h1:eHuqxsIw89iXcWnWUN8R72JMibABJTN/4IOYI5WERvw=
|
||||
github.com/go-chi/cors v1.1.1/go.mod h1:K2Yje0VW/SJzxiyMYu6iPQYa7hMjQX2i/F491VChg1I=
|
||||
github.com/go-chi/httprate v0.4.0 h1:M2qVV0w6ksgLs6L8lTrvqNeaVm0ZJNVdbYM8u2T8HaE=
|
||||
github.com/go-chi/httprate v0.4.0/go.mod h1:7e7qjQtHzEbdyW5TYQrl4X2uNRCnlTajictc7B4ftgc=
|
||||
github.com/go-chi/jwtauth v4.0.4+incompatible h1:LGIxg6YfvSBzxU2BljXbrzVc1fMlgqSKBQgKOGAVtPY=
|
||||
github.com/go-chi/jwtauth v4.0.4+incompatible/go.mod h1:Q5EIArY/QnD6BdS+IyDw7B2m6iNbnPxtfd6/BcmtWbs=
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
github.com/go-redis/redis v6.14.2+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
|
||||
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
|
||||
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
|
||||
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/snappy v0.0.0-20170215233205-553a64147049/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db h1:woRePGFeVFfLKN/pOkfl+p/TAqKOfFu+7KPlMVpok/w=
|
||||
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0 h1:/QaMHBdZ26BB3SSst0Iwl10Epc+xhTquomWX0oZEB6w=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
|
||||
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/wire v0.4.0 h1:kXcsA/rIGzJImVqPdhfnr6q0xsS9gU0515q1EPpJ9fE=
|
||||
github.com/google/wire v0.4.0/go.mod h1:ngWDr9Qvq3yZA10YrxfyGELY/AFWGVpy9c1LTRi1EoU=
|
||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
|
||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
|
||||
github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
|
||||
github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
|
||||
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
|
||||
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
|
||||
github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
|
||||
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
|
||||
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
|
||||
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
|
||||
github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
|
||||
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
|
||||
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
|
||||
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
|
||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
github.com/kennygrant/sanitize v0.0.0-20170120101633-6a0bfdde8629 h1:m1E9veL+2sjZOMSM7y3a6jJ9fNVaGyIJCXYDPm9U+/0=
|
||||
github.com/kennygrant/sanitize v0.0.0-20170120101633-6a0bfdde8629/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak=
|
||||
github.com/koding/multiconfig v0.0.0-20170327155832-26b6dfd3a84a h1:KZAp4Cn6Wybs23MKaIrKyb/6+qs2rncDspTuRYwOmvU=
|
||||
github.com/koding/multiconfig v0.0.0-20170327155832-26b6dfd3a84a/go.mod h1:Y2SaZf2Rzd0pXkLVhLlCiAXFCLSXAIbTKDivVgff/AM=
|
||||
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
@@ -83,103 +217,363 @@ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
|
||||
github.com/ledisdb/ledisdb v0.0.0-20200510135210-d35789ec47e6/go.mod h1:n931TsDuKuq+uX4v1fulaMbA/7ZLLhjc85h7chZGBCQ=
|
||||
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU=
|
||||
github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY=
|
||||
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4=
|
||||
github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/nfnt/resize v0.0.0-20160724205520-891127d8d1b5 h1:BvoENQQU+fZ9uukda/RzCAL/191HHwJA5b13R6diVlY=
|
||||
github.com/nfnt/resize v0.0.0-20160724205520-891127d8d1b5/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/microcosm-cc/bluemonday v1.0.3 h1:EjVH7OqbU219kdm8acbveoclh2zZFqPJTJw6VUlTLAQ=
|
||||
github.com/microcosm-cc/bluemonday v1.0.3/go.mod h1:8iwZnFn2CDDNZ0r6UXhF4xawGvzaqzCRa1n3/lO3W2w=
|
||||
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
|
||||
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
|
||||
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
|
||||
github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
|
||||
github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
|
||||
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/mitchellh/mapstructure v1.3.2 h1:mRS76wmkOn3KkKAyXDu42V+6ebnXWIztFSYGN7GeoRg=
|
||||
github.com/mitchellh/mapstructure v1.3.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.0 h1:Iw5WCbBcaAAd0fpRb1c9r5YCylv4XDoCSigm1zLevwU=
|
||||
github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg=
|
||||
github.com/onsi/ginkgo v1.12.1 h1:mFwc4LvZ0xpSvDZ3E+k8Yte0hLOMxXUlP+yXtJqkYfQ=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.14.0 h1:2mOpI4JVVPBN+WQRa0WKH2eXR+Ey+uK4n7Zj0aYpIQA=
|
||||
github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.9.0 h1:R1uwffexN6Pr340GtYRIdZmAiN4J+iw6WG4wog1DUXg=
|
||||
github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA=
|
||||
github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||
github.com/pelletier/go-toml v1.0.1/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/pelletier/go-toml v1.8.0 h1:Keo9qb7iRJs2voHvunFtuuYFsbWeOBh8/P9v/kVMFtw=
|
||||
github.com/pelletier/go-toml v1.8.0/go.mod h1:D6yutnOGMveHEPV7VQOuvI/gXY61bv+9bAOTRnLElKs=
|
||||
github.com/peterh/liner v1.0.1-0.20171122030339-3681c2a91233/go.mod h1:xIteQHvHuaLYG9IFj6mSxM0fCKrs34IrEQUhOYuGPHc=
|
||||
github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
|
||||
github.com/pressly/goose v2.6.0+incompatible h1:3f8zIQ8rfgP9tyI0Hmcs2YNAqUCL1c+diLe3iU8Qd/k=
|
||||
github.com/pressly/goose v2.6.0+incompatible/go.mod h1:m+QHWCqxR3k8D9l7qfzuC/djtlfzxr34mozWDYEu1z8=
|
||||
github.com/siddontang/go v0.0.0-20180604090527-bdc77568d726 h1:xT+JlYxNGqyT+XcU8iUrN18JYed2TvG9yN5ULG2jATM=
|
||||
github.com/siddontang/go v0.0.0-20180604090527-bdc77568d726/go.mod h1:3yhqj7WBBfRhbBlzyOC3gUxftwsU0u8gqevxwIHQpMw=
|
||||
github.com/siddontang/ledisdb v0.0.0-20181029004158-becf5f38d373 h1:p6IxqQMjab30l4lb9mmkIkkcE1yv6o0SKbPhW5pxqHI=
|
||||
github.com/siddontang/ledisdb v0.0.0-20181029004158-becf5f38d373/go.mod h1:mF1DpOSOUiJRMR+FDqaqu3EBqrybQtrDDszLUZ6oxPg=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
|
||||
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
||||
github.com/prometheus/client_golang v1.7.0 h1:wCi7urQOGBsYcQROHqpUUX4ct84xp40t9R9JX0FuA/U=
|
||||
github.com/prometheus/client_golang v1.7.0/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
|
||||
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M=
|
||||
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
|
||||
github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
|
||||
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
|
||||
github.com/prometheus/common v0.10.0 h1:RyRA7RzGXQZiW+tGMr7sxa85G1z0yOpM1qq5c8lNawc=
|
||||
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
|
||||
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||
github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
||||
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
||||
github.com/prometheus/procfs v0.1.3 h1:F0+tqvhOksq22sc6iCHF5WGlWjdwj92p0udFh1VFBS8=
|
||||
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
|
||||
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
|
||||
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
||||
github.com/shiena/ansicolor v0.0.0-20151119151921-a422bbe96644 h1:X+yvsM2yrEktyI+b2qND5gpH8YhURn0k8OCaeRnkINo=
|
||||
github.com/shiena/ansicolor v0.0.0-20151119151921-a422bbe96644/go.mod h1:nkxAfR/5quYxwPZhyDxgasBMnRtBZd0FCEpawpjMUFg=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/siddontang/go v0.0.0-20170517070808-cb568a3e5cc0/go.mod h1:3yhqj7WBBfRhbBlzyOC3gUxftwsU0u8gqevxwIHQpMw=
|
||||
github.com/siddontang/goredis v0.0.0-20150324035039-760763f78400/go.mod h1:DDcKzU3qCuvj/tPnimWSsZZzvk9qvkvrIL5naVBPh5s=
|
||||
github.com/siddontang/rdb v0.0.0-20150307021120-fc89ed2e418d h1:NVwnfyR3rENtlz62bcrkXME3INVUa4lcdGt+opvxExs=
|
||||
github.com/siddontang/rdb v0.0.0-20150307021120-fc89ed2e418d/go.mod h1:AMEsy7v5z92TR1JKMkLLoaOQk++LVnOKL3ScbJ8GNGA=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I=
|
||||
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/assertions v1.0.1 h1:voD4ITNjPL5jjBfgR/r8fPIIBrliWrWHeiJApdr3r4w=
|
||||
github.com/smartystreets/assertions v1.0.1/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM=
|
||||
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
|
||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI=
|
||||
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
|
||||
github.com/spf13/afero v1.3.1 h1:GPTpEAuNr98px18yNQ66JllNil98wfRZ/5Ukny8FeQA=
|
||||
github.com/spf13/afero v1.3.1/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4=
|
||||
github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8=
|
||||
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
|
||||
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
github.com/spf13/cobra v1.0.0 h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8=
|
||||
github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
|
||||
github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk=
|
||||
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
||||
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
|
||||
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
|
||||
github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
|
||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.4.0 h1:yXHLWeravcrgGyFSyCgdYpXQ9dR9c/WED3pg1RhxqEU=
|
||||
github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
|
||||
github.com/spf13/viper v1.7.0 h1:xVKxvI7ouOI5I+U9s2eeiUfMaWBVoXA3AWskkrqK0VM=
|
||||
github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
|
||||
github.com/ssdb/gossdb v0.0.0-20180723034631-88f6b59b84ec/go.mod h1:QBvMkMya+gXctz3kmljlUCu/yB3GZ6oee+dUozsezQE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
|
||||
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
|
||||
github.com/syndtr/goleveldb v0.0.0-20160425020131-cfa635847112/go.mod h1:Z4AUp2Km+PwemOoO/VB5AOx9XSsIItzFjoJlOSiYmn0=
|
||||
github.com/syndtr/goleveldb v0.0.0-20181127023241-353a9fca669c h1:3eGShk3EQf5gJCYW+WzA0TEJQd37HLOmlYF7N0YJwv0=
|
||||
github.com/syndtr/goleveldb v0.0.0-20181127023241-353a9fca669c/go.mod h1:Z4AUp2Km+PwemOoO/VB5AOx9XSsIItzFjoJlOSiYmn0=
|
||||
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
|
||||
github.com/ugorji/go v0.0.0-20171122102828-84cb69a8af83/go.mod h1:hnLbHMwcvSihnDhEfx2/BzKp2xb0Y+ErdfYcrs9tkJQ=
|
||||
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
|
||||
github.com/wader/tag v0.0.0-20200426234345-d072771f6a51 h1:WAxntH7YQD6fIboAvewi7eU+2PQ7Y1K9OOXh67CM4bY=
|
||||
github.com/wader/tag v0.0.0-20200426234345-d072771f6a51/go.mod h1:f3YqVk9PEeVf7T4JQ2+TdRqqjTg2fkaROZv0EMQOuKo=
|
||||
github.com/wendal/errors v0.0.0-20130201093226-f66c77a7882b/go.mod h1:Q12BUT7DqIlHRmgv3RskH+UCM/4eqVMgI0EMmlSpAXc=
|
||||
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
|
||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||
github.com/yuin/gopher-lua v0.0.0-20171031051903-609c9cd26973/go.mod h1:aEV29XrmTYFr3CiRxZeGHpkvbwq+prZduBqMaascyCU=
|
||||
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
|
||||
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
|
||||
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2 h1:CCH4IOTTfewWjGOlSp+zGcjutRKlBEZQ6wTn8ozI/nI=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7 h1:AeiKBIuRw3UomYXSbLy0Mc2dDLfdtbT/IVn4keq83P0=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200625001655-4c5254603344 h1:vGXIOMxbNfDTk/aXCmfdLgkrSV+Z2tcbze+pEc3v5W4=
|
||||
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f h1:wMNYb4v58l5UBM7MYRLPG6ZhfOqbKu7X5eyFl8ZhKvA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5 h1:LfCXLvNmTYH9kEmVgqbnsWfruoXZIrh4YBgqVHtDvw0=
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299 h1:DYfZAGf2WMFjMxbgTjaC+2HC7NkNAQs+6Q8b9WEB/F4=
|
||||
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1 h1:ogLJMz+qpzav7lGMh10LMvAkM/fAoGlaiiHYiFYdm80=
|
||||
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae h1:Ih9Yo4hSPImZOpfGuA4bR/ORKTAbhZo2AbWNRCnevdo=
|
||||
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384 h1:TFlARGu6Czu1z7q93HTxcP1P+/ZFC/IKythI5RzrnRg=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b h1:NVD8gBK33xpdqCaZVVtd6OFJp+3dxkXuz7+U7KaVN6s=
|
||||
golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200117065230-39095c1d176c h1:FodBYPZKH5tAN2O60HlglMwXGAeV/4k+NKbli79M/2c=
|
||||
golang.org/x/tools v0.0.0-20200117065230-39095c1d176c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
|
||||
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0 h1:4MY060fB1DLGMB/7MBTLnwQUY6+F09GEiz6SsrNqyzM=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/djherbis/atime.v1 v1.0.0 h1:eMRqB/JrLKocla2PBPKgQYg/p5UG4L6AUAs92aP7F60=
|
||||
gopkg.in/djherbis/atime.v1 v1.0.0/go.mod h1:hQIUStKmJfvf7xdh/wtK84qe+DsTV5LnA9lzxxtPpJ8=
|
||||
gopkg.in/djherbis/stream.v1 v1.3.1 h1:uGfmsOY1qqMjQQphhRBSGLyA9qumJ56exkRu9ASTjCw=
|
||||
gopkg.in/djherbis/stream.v1 v1.3.1/go.mod h1:aEV8CBVRmSpLamVJfM903Npic1IKmb2qS30VAZ+sssg=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno=
|
||||
gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/ini.v1 v1.57.0 h1:9unxIsFcTt4I55uWluz+UmL95q4kdJ0buvQ1ZIqVQww=
|
||||
gopkg.in/ini.v1 v1.57.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
|
||||
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||
|
||||
@@ -24,6 +24,10 @@ const (
|
||||
LevelTrace = Level(logrus.TraceLevel)
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const loggerCtxKey = contextKey("logger")
|
||||
|
||||
var (
|
||||
currentLevel Level
|
||||
defaultLogger = logrus.New()
|
||||
@@ -66,7 +70,7 @@ func NewContext(ctx context.Context, keyValuePairs ...interface{}) context.Conte
|
||||
}
|
||||
|
||||
logger := addFields(createNewLogger(), keyValuePairs)
|
||||
ctx = context.WithValue(ctx, "logger", logger)
|
||||
ctx = context.WithValue(ctx, loggerCtxKey, logger)
|
||||
|
||||
return ctx
|
||||
}
|
||||
@@ -176,10 +180,11 @@ func extractLogger(ctx interface{}) (*logrus.Entry, error) {
|
||||
case *logrus.Entry:
|
||||
return ctx, nil
|
||||
case context.Context:
|
||||
logger := ctx.Value("logger")
|
||||
logger := ctx.Value(loggerCtxKey)
|
||||
if logger != nil {
|
||||
return logger.(*logrus.Entry), nil
|
||||
}
|
||||
return extractLogger(NewContext(ctx))
|
||||
case *http.Request:
|
||||
return extractLogger(ctx.Context())
|
||||
}
|
||||
|
||||
170
log/log_test.go
170
log/log_test.go
@@ -6,105 +6,175 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestLog(t *testing.T) {
|
||||
SetLevel(LevelInfo)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Log Suite")
|
||||
}
|
||||
|
||||
Convey("Test Logger", t, func() {
|
||||
l, hook := test.NewNullLogger()
|
||||
var _ = Describe("Logger", func() {
|
||||
var l *logrus.Logger
|
||||
var hook *test.Hook
|
||||
|
||||
BeforeEach(func() {
|
||||
l, hook = test.NewNullLogger()
|
||||
SetLevel(LevelInfo)
|
||||
SetDefaultLogger(l)
|
||||
})
|
||||
|
||||
Convey("Plain message", func() {
|
||||
Context("Logging", func() {
|
||||
It("logs a simple message", func() {
|
||||
Error("Simple Message")
|
||||
So(hook.LastEntry().Message, ShouldEqual, "Simple Message")
|
||||
So(hook.LastEntry().Data, ShouldBeEmpty)
|
||||
Expect(hook.LastEntry().Message).To(Equal("Simple Message"))
|
||||
Expect(hook.LastEntry().Data).To(BeEmpty())
|
||||
})
|
||||
|
||||
Convey("Passing nil as context", func() {
|
||||
It("logs a message when context is nil", func() {
|
||||
Error(nil, "Simple Message")
|
||||
So(hook.LastEntry().Message, ShouldEqual, "Simple Message")
|
||||
So(hook.LastEntry().Data, ShouldBeEmpty)
|
||||
Expect(hook.LastEntry().Message).To(Equal("Simple Message"))
|
||||
Expect(hook.LastEntry().Data).To(BeEmpty())
|
||||
})
|
||||
|
||||
SkipConvey("Empty context", func() {
|
||||
Error(context.Background(), "Simple Message")
|
||||
So(hook.LastEntry().Message, ShouldEqual, "Simple Message")
|
||||
So(hook.LastEntry().Data, ShouldBeEmpty)
|
||||
It("Empty context", func() {
|
||||
Error(context.TODO(), "Simple Message")
|
||||
Expect(hook.LastEntry().Message).To(Equal("Simple Message"))
|
||||
Expect(hook.LastEntry().Data).To(BeEmpty())
|
||||
})
|
||||
|
||||
Convey("Message with two kv pairs", func() {
|
||||
It("logs messages with two kv pairs", func() {
|
||||
Error("Simple Message", "key1", "value1", "key2", "value2")
|
||||
So(hook.LastEntry().Message, ShouldEqual, "Simple Message")
|
||||
So(hook.LastEntry().Data["key1"], ShouldEqual, "value1")
|
||||
So(hook.LastEntry().Data["key2"], ShouldEqual, "value2")
|
||||
So(hook.LastEntry().Data, ShouldHaveLength, 2)
|
||||
Expect(hook.LastEntry().Message).To(Equal("Simple Message"))
|
||||
Expect(hook.LastEntry().Data["key1"]).To(Equal("value1"))
|
||||
Expect(hook.LastEntry().Data["key2"]).To(Equal("value2"))
|
||||
Expect(hook.LastEntry().Data).To(HaveLen(2))
|
||||
})
|
||||
|
||||
Convey("Only error", func() {
|
||||
It("logs error objects as simple messages", func() {
|
||||
Error(errors.New("error test"))
|
||||
So(hook.LastEntry().Message, ShouldEqual, "error test")
|
||||
So(hook.LastEntry().Data, ShouldBeEmpty)
|
||||
Expect(hook.LastEntry().Message).To(Equal("error test"))
|
||||
Expect(hook.LastEntry().Data).To(BeEmpty())
|
||||
})
|
||||
|
||||
Convey("Error as last argument", func() {
|
||||
It("logs errors passed as last argument", func() {
|
||||
Error("Error scrobbling track", "id", 1, errors.New("some issue"))
|
||||
So(hook.LastEntry().Message, ShouldEqual, "Error scrobbling track")
|
||||
So(hook.LastEntry().Data["id"], ShouldEqual, 1)
|
||||
So(hook.LastEntry().Data["error"], ShouldEqual, "some issue")
|
||||
So(hook.LastEntry().Data, ShouldHaveLength, 2)
|
||||
Expect(hook.LastEntry().Message).To(Equal("Error scrobbling track"))
|
||||
Expect(hook.LastEntry().Data["id"]).To(Equal(1))
|
||||
Expect(hook.LastEntry().Data["error"]).To(Equal("some issue"))
|
||||
Expect(hook.LastEntry().Data).To(HaveLen(2))
|
||||
})
|
||||
|
||||
Convey("Passing a request", func() {
|
||||
ctx := NewContext(nil, "foo", "bar")
|
||||
It("can get data from the request's context", func() {
|
||||
ctx := NewContext(context.TODO(), "foo", "bar")
|
||||
req := httptest.NewRequest("get", "/", nil).WithContext(ctx)
|
||||
|
||||
Error(req, "Simple Message", "key1", "value1")
|
||||
So(hook.LastEntry().Message, ShouldEqual, "Simple Message")
|
||||
So(hook.LastEntry().Data["foo"], ShouldEqual, "bar")
|
||||
So(hook.LastEntry().Data["key1"], ShouldEqual, "value1")
|
||||
So(hook.LastEntry().Data, ShouldHaveLength, 2)
|
||||
|
||||
Expect(hook.LastEntry().Message).To(Equal("Simple Message"))
|
||||
Expect(hook.LastEntry().Data["foo"]).To(Equal("bar"))
|
||||
Expect(hook.LastEntry().Data["key1"]).To(Equal("value1"))
|
||||
Expect(hook.LastEntry().Data).To(HaveLen(2))
|
||||
})
|
||||
|
||||
Convey("Skip if level is lower", func() {
|
||||
It("does not log anything if level is lower", func() {
|
||||
SetLevel(LevelError)
|
||||
Info("Simple Message")
|
||||
So(hook.LastEntry(), ShouldBeNil)
|
||||
Expect(hook.LastEntry()).To(BeNil())
|
||||
})
|
||||
|
||||
It("logs source file and line number, if requested", func() {
|
||||
SetLogSourceLine(true)
|
||||
Error("A crash happened")
|
||||
Expect(hook.LastEntry().Message).To(Equal("A crash happened"))
|
||||
// NOTE: This assertions breaks if the line number changes
|
||||
Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:92"))
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Test extractLogger", t, func() {
|
||||
Convey("It returns an error if the context is nil", func() {
|
||||
Context("Levels", func() {
|
||||
BeforeEach(func() {
|
||||
SetLevel(LevelTrace)
|
||||
})
|
||||
It("logs error messages", func() {
|
||||
Error("msg")
|
||||
Expect(hook.LastEntry().Level).To(Equal(logrus.ErrorLevel))
|
||||
})
|
||||
It("logs warn messages", func() {
|
||||
Warn("msg")
|
||||
Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel))
|
||||
})
|
||||
It("logs info messages", func() {
|
||||
Info("msg")
|
||||
Expect(hook.LastEntry().Level).To(Equal(logrus.InfoLevel))
|
||||
})
|
||||
It("logs debug messages", func() {
|
||||
Debug("msg")
|
||||
Expect(hook.LastEntry().Level).To(Equal(logrus.DebugLevel))
|
||||
})
|
||||
It("logs info messages", func() {
|
||||
Trace("msg")
|
||||
Expect(hook.LastEntry().Level).To(Equal(logrus.TraceLevel))
|
||||
})
|
||||
})
|
||||
|
||||
Context("extractLogger", func() {
|
||||
It("returns an error if the context is nil", func() {
|
||||
_, err := extractLogger(nil)
|
||||
So(err, ShouldNotBeNil)
|
||||
Expect(err).ToNot(BeNil())
|
||||
})
|
||||
|
||||
Convey("It returns an error if the context is a string", func() {
|
||||
It("returns an error if the context is a string", func() {
|
||||
_, err := extractLogger("any msg")
|
||||
So(err, ShouldNotBeNil)
|
||||
Expect(err).ToNot(BeNil())
|
||||
})
|
||||
|
||||
Convey("It returns the logger from context if it has one", func() {
|
||||
It("returns the logger from context if it has one", func() {
|
||||
logger := logrus.NewEntry(logrus.New())
|
||||
ctx := context.Background()
|
||||
ctx = context.WithValue(ctx, "logger", logger)
|
||||
ctx = context.WithValue(ctx, loggerCtxKey, logger)
|
||||
|
||||
l, err := extractLogger(ctx)
|
||||
So(err, ShouldBeNil)
|
||||
So(l, ShouldEqual, logger)
|
||||
Expect(extractLogger(ctx)).To(Equal(logger))
|
||||
})
|
||||
|
||||
Convey("It returns the logger from request's context if it has one", func() {
|
||||
It("returns the logger from request's context if it has one", func() {
|
||||
logger := logrus.NewEntry(logrus.New())
|
||||
ctx := context.Background()
|
||||
ctx = context.WithValue(ctx, "logger", logger)
|
||||
ctx = context.WithValue(ctx, loggerCtxKey, logger)
|
||||
req := httptest.NewRequest("get", "/", nil).WithContext(ctx)
|
||||
l, err := extractLogger(req)
|
||||
So(err, ShouldBeNil)
|
||||
So(l, ShouldEqual, logger)
|
||||
|
||||
Expect(extractLogger(req)).To(Equal(logger))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Context("SetLevelString", func() {
|
||||
It("converts Critical level", func() {
|
||||
SetLevelString("Critical")
|
||||
Expect(CurrentLevel()).To(Equal(LevelCritical))
|
||||
})
|
||||
It("converts Error level", func() {
|
||||
SetLevelString("ERROR")
|
||||
Expect(CurrentLevel()).To(Equal(LevelError))
|
||||
})
|
||||
It("converts Warn level", func() {
|
||||
SetLevelString("warn")
|
||||
Expect(CurrentLevel()).To(Equal(LevelWarn))
|
||||
})
|
||||
It("converts Info level", func() {
|
||||
SetLevelString("info")
|
||||
Expect(CurrentLevel()).To(Equal(LevelInfo))
|
||||
})
|
||||
It("converts Debug level", func() {
|
||||
SetLevelString("debug")
|
||||
Expect(CurrentLevel()).To(Equal(LevelDebug))
|
||||
})
|
||||
It("converts Trace level", func() {
|
||||
SetLevelString("trace")
|
||||
Expect(CurrentLevel()).To(Equal(LevelTrace))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
18
main.go
18
main.go
@@ -1,21 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/db"
|
||||
)
|
||||
import "github.com/deluan/navidrome/cmd"
|
||||
|
||||
func main() {
|
||||
if !conf.Server.DevDisableBanner {
|
||||
println(consts.Banner())
|
||||
}
|
||||
|
||||
conf.Load()
|
||||
db.EnsureLatestVersion()
|
||||
|
||||
a := CreateServer(conf.Server.MusicFolder)
|
||||
a.MountRouter("/rest", CreateSubsonicAPIRouter())
|
||||
a.MountRouter("/app", CreateAppRouter("/app"))
|
||||
a.Run(":" + conf.Server.Port)
|
||||
cmd.Execute()
|
||||
}
|
||||
|
||||
@@ -3,27 +3,30 @@ package model
|
||||
import "time"
|
||||
|
||||
type Album struct {
|
||||
ID string `json:"id" orm:"column(id)"`
|
||||
Name string `json:"name"`
|
||||
ArtistID string `json:"artistId" orm:"pk;column(artist_id)"`
|
||||
CoverArtPath string `json:"coverArtPath"`
|
||||
CoverArtId string `json:"coverArtId"`
|
||||
Artist string `json:"artist"`
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
Year int `json:"year"`
|
||||
Compilation bool `json:"compilation"`
|
||||
SongCount int `json:"songCount"`
|
||||
Duration int `json:"duration"`
|
||||
Genre string `json:"genre"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Annotations
|
||||
|
||||
// Annotations
|
||||
PlayCount int `json:"-" orm:"-"`
|
||||
PlayDate time.Time `json:"-" orm:"-"`
|
||||
Rating int `json:"-" orm:"-"`
|
||||
Starred bool `json:"-" orm:"-"`
|
||||
StarredAt time.Time `json:"-" orm:"-"`
|
||||
ID string `json:"id" orm:"column(id)"`
|
||||
Name string `json:"name"`
|
||||
CoverArtPath string `json:"coverArtPath"`
|
||||
CoverArtId string `json:"coverArtId"`
|
||||
ArtistID string `json:"artistId" orm:"pk;column(artist_id)"`
|
||||
Artist string `json:"artist"`
|
||||
AlbumArtistID string `json:"albumArtistId" orm:"pk;column(album_artist_id)"`
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
MaxYear int `json:"maxYear"`
|
||||
MinYear int `json:"minYear"`
|
||||
Compilation bool `json:"compilation"`
|
||||
SongCount int `json:"songCount"`
|
||||
Duration float32 `json:"duration"`
|
||||
Genre string `json:"genre"`
|
||||
FullText string `json:"fullText"`
|
||||
SortAlbumName string `json:"sortAlbumName"`
|
||||
SortArtistName string `json:"sortArtistName"`
|
||||
SortAlbumArtistName string `json:"sortAlbumArtistName"`
|
||||
OrderAlbumName string `json:"orderAlbumName"`
|
||||
OrderAlbumArtistName string `json:"orderAlbumArtistName"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Albums []Album
|
||||
@@ -31,14 +34,16 @@ type Albums []Album
|
||||
type AlbumRepository interface {
|
||||
CountAll(...QueryOptions) (int64, error)
|
||||
Exists(id string) (bool, error)
|
||||
Put(m *Album) error
|
||||
Get(id string) (*Album, error)
|
||||
FindByArtist(artistId string) (Albums, error)
|
||||
FindByArtist(albumArtistId string) (Albums, error)
|
||||
GetAll(...QueryOptions) (Albums, error)
|
||||
GetRandom(...QueryOptions) (Albums, error)
|
||||
GetStarred(options ...QueryOptions) (Albums, error)
|
||||
Search(q string, offset int, size int) (Albums, error)
|
||||
Refresh(ids ...string) error
|
||||
PurgeEmpty() error
|
||||
AnnotatedRepository
|
||||
}
|
||||
|
||||
func (a Album) GetAnnotations() Annotations {
|
||||
return a.Annotations
|
||||
}
|
||||
|
||||
@@ -2,8 +2,24 @@ package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Annotations struct {
|
||||
PlayCount int64 `json:"playCount"`
|
||||
PlayDate time.Time `json:"playDate"`
|
||||
Rating int `json:"rating"`
|
||||
Starred bool `json:"starred"`
|
||||
StarredAt time.Time `json:"starredAt"`
|
||||
}
|
||||
|
||||
type AnnotatedModel interface {
|
||||
GetAnnotations() Annotations
|
||||
}
|
||||
|
||||
type AnnotatedRepository interface {
|
||||
IncPlayCount(itemID string, ts time.Time) error
|
||||
SetStar(starred bool, itemIDs ...string) error
|
||||
SetRating(rating int, itemID string) error
|
||||
}
|
||||
|
||||
// While I can't find a better way to make these fields optional in the models, I keep this list here
|
||||
// to be used in other packages
|
||||
var AnnotationFields = []string{"playCount", "playDate", "rating", "starred", "starredAt"}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user