mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-06 13:58:09 -05:00
Compare commits
534 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bbb878bb3 | ||
|
|
8591a9acdf | ||
|
|
b6e30cd01f | ||
|
|
058e7e4374 | ||
|
|
152836a01c | ||
|
|
6139338e80 | ||
|
|
f0c11916ca | ||
|
|
a6311259fd | ||
|
|
dbde0ffa0c | ||
|
|
fba733708c | ||
|
|
e673360087 | ||
|
|
2b105ca77b | ||
|
|
9c29ee3651 | ||
|
|
6c3e45de41 | ||
|
|
2ab4647420 | ||
|
|
91e36a2c18 | ||
|
|
0cbba80284 | ||
|
|
f9d910473f | ||
|
|
be3a6dc7a3 | ||
|
|
b1e7760996 | ||
|
|
ad45ab4a04 | ||
|
|
24fef584ad | ||
|
|
e17d436902 | ||
|
|
73659e5669 | ||
|
|
71b1e7f870 | ||
|
|
694056010c | ||
|
|
4fda895a64 | ||
|
|
f664af5559 | ||
|
|
c6868ff8a0 | ||
|
|
0b65a4e34e | ||
|
|
24872e6c2a | ||
|
|
b4e5c662dc | ||
|
|
6752e0a17d | ||
|
|
5680e53949 | ||
|
|
482c2dec0c | ||
|
|
210dc6b12e | ||
|
|
73a2271cdd | ||
|
|
0c0bd2967d | ||
|
|
15ae3d47cf | ||
|
|
1365adbb39 | ||
|
|
e12a14a87d | ||
|
|
0079a9b938 | ||
|
|
892c2bfd58 | ||
|
|
5fb7328965 | ||
|
|
86479a6d06 | ||
|
|
76bd20e8ff | ||
|
|
7a15ed0740 | ||
|
|
d574df5fd7 | ||
|
|
a7ace48efb | ||
|
|
84d98010d3 | ||
|
|
e63804fbdd | ||
|
|
a5101fa9b4 | ||
|
|
f2ed3f2d86 | ||
|
|
b23ab1ccec | ||
|
|
abf6318a8b | ||
|
|
b55f3a6946 | ||
|
|
ab2912b4fa | ||
|
|
9f00aad216 | ||
|
|
79363d6c07 | ||
|
|
a7017e4bb0 | ||
|
|
dc0ec32dbf | ||
|
|
06b1a1a25c | ||
|
|
6ac2fefaf3 | ||
|
|
2e921cd793 | ||
|
|
c2251e6ddc | ||
|
|
25aab8bcb5 | ||
|
|
94083f85d2 | ||
|
|
f0ef5187b2 | ||
|
|
79e79b6366 | ||
|
|
a961ffe2e1 | ||
|
|
402aaaaf45 | ||
|
|
5c7784f842 | ||
|
|
a4e96d25a8 | ||
|
|
8444c28bed | ||
|
|
fb11080545 | ||
|
|
173b30cd59 | ||
|
|
af7c87dd7b | ||
|
|
8df056b797 | ||
|
|
54f98497c2 | ||
|
|
c55e65902b | ||
|
|
d37231f319 | ||
|
|
47fa32ec93 | ||
|
|
cf042ed83d | ||
|
|
d481864035 | ||
|
|
c2927e105b | ||
|
|
6983390ca3 | ||
|
|
143f5ba9d5 | ||
|
|
05e27095b2 | ||
|
|
aa72d3d41b | ||
|
|
a20bd5fe05 | ||
|
|
99aeaabf7d | ||
|
|
0a5f966047 | ||
|
|
5338567346 | ||
|
|
50526024b2 | ||
|
|
db4165f27c | ||
|
|
a8cf887e87 | ||
|
|
63000ab5ae | ||
|
|
b44ef81c6f | ||
|
|
e9d0abe0bc | ||
|
|
bcafe88ef9 | ||
|
|
1710730b45 | ||
|
|
aa1571e074 | ||
|
|
c831dc4cdf | ||
|
|
344d7a4392 | ||
|
|
c0fc36da63 | ||
|
|
e68b22ea5d | ||
|
|
fb4eefced5 | ||
|
|
615cac2ec4 | ||
|
|
72f9e3e80a | ||
|
|
763bcafdac | ||
|
|
5b81f7a73a | ||
|
|
7bd506accc | ||
|
|
b66c39451f | ||
|
|
4ed01bad86 | ||
|
|
d3975d206a | ||
|
|
6175629bb4 | ||
|
|
6c550819fd | ||
|
|
91325071a6 | ||
|
|
876dda83f2 | ||
|
|
86c0b422f6 | ||
|
|
1cef44a543 | ||
|
|
4fcb238295 | ||
|
|
4f9d546da4 | ||
|
|
eeb14f0243 | ||
|
|
a89bdfbb8d | ||
|
|
8afa2cd833 | ||
|
|
774ad65155 | ||
|
|
7540881695 | ||
|
|
08840f6170 | ||
|
|
cddd1b3f6b | ||
|
|
bc6b175414 | ||
|
|
b6e9ec4db4 | ||
|
|
1471e1240d | ||
|
|
95181d748d | ||
|
|
254e5673e1 | ||
|
|
00e418cb2a | ||
|
|
2742977c63 | ||
|
|
69f71be98a | ||
|
|
58ee4c60ca | ||
|
|
21cd50d81c | ||
|
|
054b5eafdb | ||
|
|
e2233779f1 | ||
|
|
3a356499ae | ||
|
|
a0cd585401 | ||
|
|
20b7e5c49b | ||
|
|
c56c7c865e | ||
|
|
b56e034ce3 | ||
|
|
1d8607ef6a | ||
|
|
5e54925520 | ||
|
|
39da741a80 | ||
|
|
7cd3a8ba67 | ||
|
|
1f0314021e | ||
|
|
19c2ef3803 | ||
|
|
d886c63122 | ||
|
|
5b828cd7ef | ||
|
|
ef60db3a5f | ||
|
|
882b02c747 | ||
|
|
44e7502aef | ||
|
|
e61cf3217d | ||
|
|
03ad6e972a | ||
|
|
eb8ffc6f76 | ||
|
|
b0fc684cb6 | ||
|
|
8d56ec898e | ||
|
|
5064cb2a46 | ||
|
|
f78257235e | ||
|
|
1a6a284bc1 | ||
|
|
1d948beb1f | ||
|
|
deefd7a2fd | ||
|
|
97f87416a4 | ||
|
|
5d8b90b168 | ||
|
|
8396b51a9c | ||
|
|
4a25fa0920 | ||
|
|
8e71f308c2 | ||
|
|
53fe2e98c5 | ||
|
|
334068c8bf | ||
|
|
b34d77f85a | ||
|
|
24d4c81b34 | ||
|
|
189d0c0ab3 | ||
|
|
1922eaaab2 | ||
|
|
5d9bea5087 | ||
|
|
69afb69959 | ||
|
|
27ba267b38 | ||
|
|
114fdce09e | ||
|
|
fa8b4d40b4 | ||
|
|
ace5c905eb | ||
|
|
26b5e6b1b4 | ||
|
|
77f6bc83ac | ||
|
|
94e36d7f60 | ||
|
|
f49205733b | ||
|
|
cfb113bd33 | ||
|
|
289da56f64 | ||
|
|
fb183e58e9 | ||
|
|
ed286c7103 | ||
|
|
452c8dc44b | ||
|
|
0c2ca2a5e4 | ||
|
|
5bd33455a1 | ||
|
|
4ea0f235e1 | ||
|
|
b16d473d4c | ||
|
|
fd82b8f2dc | ||
|
|
a73f885afb | ||
|
|
167fe46288 | ||
|
|
cb1827ccbf | ||
|
|
25f0e11562 | ||
|
|
292cf99f49 | ||
|
|
d2fcab78a5 | ||
|
|
94533e585c | ||
|
|
6dd38376f7 | ||
|
|
26bcf0b877 | ||
|
|
92634a7408 | ||
|
|
ee21f3957e | ||
|
|
a1551074bb | ||
|
|
823fef8e43 | ||
|
|
82105c3a16 | ||
|
|
b684a47f80 | ||
|
|
da2334e10c | ||
|
|
4853760fb5 | ||
|
|
0cbb0acad3 | ||
|
|
5040f6fd97 | ||
|
|
abe8015745 | ||
|
|
5001518260 | ||
|
|
265f33ed9d | ||
|
|
99be8444d3 | ||
|
|
f4ddd201f2 | ||
|
|
056f0b944f | ||
|
|
76acd7da89 | ||
|
|
8af7dab23d | ||
|
|
a7509c9ff7 | ||
|
|
d5461d0ae9 | ||
|
|
f9fa9667a3 | ||
|
|
5fbfd9c81e | ||
|
|
8b62a58b4c | ||
|
|
743e469795 | ||
|
|
1f997357a9 | ||
|
|
143cde37e5 | ||
|
|
502a719e96 | ||
|
|
8ee5c1f245 | ||
|
|
0495e421fe | ||
|
|
ffa76bba6a | ||
|
|
a4f91b74d2 | ||
|
|
73e1a8fa06 | ||
|
|
877f01bd38 | ||
|
|
47bcf719f2 | ||
|
|
197d430d15 | ||
|
|
4e1957ca71 | ||
|
|
25db2cb075 | ||
|
|
80b2c2f3cf | ||
|
|
97434c1789 | ||
|
|
f8ee6db72a | ||
|
|
0df0ac0715 | ||
|
|
c09468e135 | ||
|
|
cf553ce812 | ||
|
|
31ea033880 | ||
|
|
66b74c81f1 | ||
|
|
d42dfafad4 | ||
|
|
84413b542e | ||
|
|
b590c31e4e | ||
|
|
c4623d7bc3 | ||
|
|
e0fd1c6ad8 | ||
|
|
86271f0412 | ||
|
|
fb7229a53e | ||
|
|
521d1ff2bf | ||
|
|
d3db41ae7d | ||
|
|
8bf0089abf | ||
|
|
b65e76293a | ||
|
|
5f6f74ff2d | ||
|
|
8383527aab | ||
|
|
8a56584aed | ||
|
|
667701be02 | ||
|
|
59b99d2206 | ||
|
|
d54129ecd2 | ||
|
|
03efc48137 | ||
|
|
bed2f017af | ||
|
|
6bd4c0f6bf | ||
|
|
b445cdd641 | ||
|
|
e31802d2d3 | ||
|
|
cefc939909 | ||
|
|
2afb2db7ef | ||
|
|
7f85ecd515 | ||
|
|
cb6aa49439 | ||
|
|
b7f47c8833 | ||
|
|
adb09c9c69 | ||
|
|
0c9e0ff886 | ||
|
|
f9eec5e4dc | ||
|
|
6c1ba8f0d0 | ||
|
|
110e17b004 | ||
|
|
779571a086 | ||
|
|
af210c8903 | ||
|
|
e80cf80d05 | ||
|
|
182e3ec78e | ||
|
|
65ccd4c99d | ||
|
|
bebfe296a5 | ||
|
|
9da9d73c1d | ||
|
|
cd242695ba | ||
|
|
519c89345e | ||
|
|
336d891e58 | ||
|
|
9b4b28f685 | ||
|
|
39c560a5c2 | ||
|
|
c5abdc19bc | ||
|
|
ead2095dd0 | ||
|
|
7b05c49215 | ||
|
|
327c259a3d | ||
|
|
675cbe11b3 | ||
|
|
91a91f7e06 | ||
|
|
7bbb09e546 | ||
|
|
dd56a7798e | ||
|
|
a38e478a47 | ||
|
|
1940267a18 | ||
|
|
01f3ce0228 | ||
|
|
48b6fa7feb | ||
|
|
25d62cd751 | ||
|
|
ed01946ace | ||
|
|
89b12b34be | ||
|
|
4e0177ee53 | ||
|
|
b398053223 | ||
|
|
db11b6b8f8 | ||
|
|
60d50de8c9 | ||
|
|
0941fbc0cd | ||
|
|
4217c75c9f | ||
|
|
409020a502 | ||
|
|
b4832c36b7 | ||
|
|
1de7366ece | ||
|
|
ab1bc6194a | ||
|
|
ad4db122fb | ||
|
|
200b815c67 | ||
|
|
5631493cc4 | ||
|
|
d9f268266c | ||
|
|
882519738f | ||
|
|
86d3a219a9 | ||
|
|
1d0e75151a | ||
|
|
107a11b445 | ||
|
|
cf8ee251ee | ||
|
|
6a17717e30 | ||
|
|
b8a274e4e8 | ||
|
|
9800823015 | ||
|
|
02606f43b8 | ||
|
|
e529390034 | ||
|
|
0ec7a305a2 | ||
|
|
b6cb81c3a3 | ||
|
|
e60f2bfa3d | ||
|
|
666c006579 | ||
|
|
6ad94548f3 | ||
|
|
fa0e6dda5b | ||
|
|
e047008f7d | ||
|
|
3cac00ad13 | ||
|
|
39d68e8287 | ||
|
|
751e2d6147 | ||
|
|
74300adbc8 | ||
|
|
a484adfcfb | ||
|
|
25bd36dbc5 | ||
|
|
87298f616f | ||
|
|
4699902369 | ||
|
|
978933aa48 | ||
|
|
77e736ccfd | ||
|
|
a77635e883 | ||
|
|
0c93db816c | ||
|
|
c0243580c0 | ||
|
|
22ce5b6282 | ||
|
|
fa9083ddec | ||
|
|
da684ff44c | ||
|
|
7d96167abc | ||
|
|
fb5840705e | ||
|
|
089d4abab1 | ||
|
|
62ccbaad8b | ||
|
|
8419a2a5d1 | ||
|
|
71c2ed9922 | ||
|
|
72ec808a2c | ||
|
|
702a65059f | ||
|
|
3e8d3e78c2 | ||
|
|
47f4e0a4de | ||
|
|
1f8949929d | ||
|
|
c92a24b3e2 | ||
|
|
cbe0d9763b | ||
|
|
44dd414d25 | ||
|
|
d85db8ffff | ||
|
|
c7378c0fa5 | ||
|
|
18696c5517 | ||
|
|
5a5d763c19 | ||
|
|
f8dbc41b6d | ||
|
|
1d6aa70033 | ||
|
|
30bb3f7b43 | ||
|
|
fb33aa4496 | ||
|
|
9e559311ad | ||
|
|
a5fc5f0ff6 | ||
|
|
73efbd90ab | ||
|
|
cbc4cb483d | ||
|
|
986473393f | ||
|
|
66b31644fa | ||
|
|
b478b0af02 | ||
|
|
c3316e201e | ||
|
|
874b17b8f6 | ||
|
|
5808b9fb71 | ||
|
|
c33ebabde8 | ||
|
|
7feda4bea4 | ||
|
|
2ff1c79b64 | ||
|
|
cfbc39fb7f | ||
|
|
2372f1d12b | ||
|
|
490a7fcf52 | ||
|
|
ad153f5f63 | ||
|
|
b8138ebad6 | ||
|
|
e3fe8399c8 | ||
|
|
88105d5c30 | ||
|
|
b180386d23 | ||
|
|
70e7bf6b5b | ||
|
|
d41137ad8e | ||
|
|
88f2fc35cd | ||
|
|
bc62efb059 | ||
|
|
eaf40efdf4 | ||
|
|
71dc0dddaf | ||
|
|
bcda53f115 | ||
|
|
8a07bac2a2 | ||
|
|
a35de2bfd1 | ||
|
|
22582392a0 | ||
|
|
932c108e82 | ||
|
|
20d2726faa | ||
|
|
771c91d2dd | ||
|
|
b8173124f4 | ||
|
|
d1605dcfbe | ||
|
|
10cfaad95c | ||
|
|
07f6a7cc9f | ||
|
|
6e73c23704 | ||
|
|
862c6d3c73 | ||
|
|
692663680b | ||
|
|
0d409e37e2 | ||
|
|
f1bd736b20 | ||
|
|
cde6626016 | ||
|
|
1c7d4c5630 | ||
|
|
c75314c605 | ||
|
|
b10f491de8 | ||
|
|
b671d0ff7b | ||
|
|
4b5a5abe1b | ||
|
|
3cede28161 | ||
|
|
79bbff0e98 | ||
|
|
0142352280 | ||
|
|
d5c7a81888 | ||
|
|
1e539f4e54 | ||
|
|
e83a0b23a3 | ||
|
|
9f39f062d8 | ||
|
|
df57cd6bb5 | ||
|
|
d829a63686 | ||
|
|
4b061427ad | ||
|
|
aa9cf8ef17 | ||
|
|
240de70026 | ||
|
|
6da9dee7d3 | ||
|
|
467eb345ad | ||
|
|
31b553e972 | ||
|
|
da30923a95 | ||
|
|
e7be2f6f9c | ||
|
|
9a509c749a | ||
|
|
abaecf2b88 | ||
|
|
f63a912341 | ||
|
|
b6f525bda5 | ||
|
|
0063720cc2 | ||
|
|
b441260186 | ||
|
|
16a5ac323b | ||
|
|
749f5d45c6 | ||
|
|
a81ef0923b | ||
|
|
c86d2a93b1 | ||
|
|
b55d582882 | ||
|
|
6635149f3c | ||
|
|
01b34f4f14 | ||
|
|
cb9cabe0ec | ||
|
|
29aff05f70 | ||
|
|
f48bfb6ad1 | ||
|
|
ef9a16ac9f | ||
|
|
ca9d42714f | ||
|
|
efe8240c0a | ||
|
|
f7dfabaa40 | ||
|
|
d8a1773d50 | ||
|
|
e105e2d2a2 | ||
|
|
f41bc31ba8 | ||
|
|
96a14ec484 | ||
|
|
48ae4f7479 | ||
|
|
840521ffe2 | ||
|
|
5178f44094 | ||
|
|
10dcc3fb37 | ||
|
|
49b1c40fbd | ||
|
|
156a53c2ac | ||
|
|
9913b92905 | ||
|
|
52812fa48b | ||
|
|
c57fa7a8fc | ||
|
|
dbda8712f2 | ||
|
|
5f685bca30 | ||
|
|
37f6ff02cf | ||
|
|
01ef4d2f21 | ||
|
|
32ad982b11 | ||
|
|
87b04607ad | ||
|
|
4e41ef7542 | ||
|
|
6af45d6eea | ||
|
|
69fe771819 | ||
|
|
ce675d478a | ||
|
|
6988b9a086 | ||
|
|
55c2431b17 | ||
|
|
69ee17402f | ||
|
|
cdfdf78c73 | ||
|
|
ca51372d8d | ||
|
|
a4d07734cd | ||
|
|
3d58c5ab54 | ||
|
|
12223b2a83 | ||
|
|
c7dc3628e2 | ||
|
|
9871919fae | ||
|
|
0cb7d3853f | ||
|
|
d0d18e8512 | ||
|
|
0d95c4bb9d | ||
|
|
ea65da484b | ||
|
|
5128c049d7 | ||
|
|
16f6d9466f | ||
|
|
cf72bbfad4 | ||
|
|
20f5778694 | ||
|
|
46d4c48d44 | ||
|
|
166410eb50 | ||
|
|
43cbde97ad | ||
|
|
13e80e651c | ||
|
|
16e495a80f | ||
|
|
1f2b5294c3 | ||
|
|
a36a8c2372 | ||
|
|
5245b4c62b | ||
|
|
3b0defefec | ||
|
|
404253a881 | ||
|
|
5dfcb316cf | ||
|
|
90cf118349 | ||
|
|
b42532dd7c | ||
|
|
ac37bf3631 | ||
|
|
db208600e4 | ||
|
|
01ba00ccdd | ||
|
|
e575825c33 | ||
|
|
5abc215270 | ||
|
|
210f34bbbe | ||
|
|
5e70e0702c | ||
|
|
a85b70e9db | ||
|
|
515aa7108b | ||
|
|
cdb387b22f | ||
|
|
d5434d4169 | ||
|
|
c46aa72ede | ||
|
|
4b68260b83 |
@@ -4,10 +4,10 @@
|
||||
"dockerfile": "Dockerfile",
|
||||
"args": {
|
||||
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
|
||||
"VARIANT": "1.16",
|
||||
"VARIANT": "1.17",
|
||||
// Options
|
||||
"INSTALL_NODE": "true",
|
||||
"NODE_VERSION": "v14"
|
||||
"NODE_VERSION": "v16"
|
||||
}
|
||||
},
|
||||
"runArgs": [
|
||||
|
||||
37
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
37
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Use this template for submitting a bug report.
|
||||
title: ""
|
||||
labels: bug
|
||||
assignees: ""
|
||||
---
|
||||
<!-- Please check that another issue for the same bug has not been already made by searching the [issues](https://github.com/navidrome/navidrome/issues) -->
|
||||
|
||||
### Description
|
||||
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
### Expected Behaviour
|
||||
|
||||
What you would have expected to happen instead.
|
||||
|
||||
### Steps to reproduce
|
||||
|
||||
1. Open the '...'
|
||||
2. Click on '...'
|
||||
3. Scroll down to '...'
|
||||
4. See error
|
||||
|
||||
### Platform information
|
||||
|
||||
- Navidrome version: <!-- e.g. v0.40.0 -->
|
||||
- Browser and version: <!-- e.g. Firefox v87.0b9 -->
|
||||
- Operating System: <!-- e.g. Ubuntu 20.04 and whether using a binary, docker or built from source -->
|
||||
|
||||
### Additional information
|
||||
|
||||
Any other information that may be relevant or give context to the problem.
|
||||
|
||||
- Screenshots (if applicable)?
|
||||
- Logs? <!-- Turn the log level up to trace -->
|
||||
- Client used? <!-- e.g. DSub v5.5.2R2 -->
|
||||
24
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
24
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Use this template to request for a feature.
|
||||
title: ""
|
||||
labels: enhancement
|
||||
assignees: ""
|
||||
---
|
||||
<!-- Please check that another issue for the same feature request has not been already made by searching the [issues](https://github.com/navidrome/navidrome/issues) -->
|
||||
|
||||
### Is your feature request related to a problem? Please describe.
|
||||
|
||||
A clear and concise description of what the problem is. For e.g. I'm always frustrated when '...'
|
||||
|
||||
### Describe the solution you'd like
|
||||
|
||||
A clear and concise description of what you would like to happen.
|
||||
|
||||
### Describe alternative solutions that would also satisfy this problem
|
||||
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
### Additional context
|
||||
|
||||
Add any other context or screenshots about the feature request here.
|
||||
1
.github/workflows/pipeline.dockerfile
vendored
1
.github/workflows/pipeline.dockerfile
vendored
@@ -7,6 +7,7 @@ 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/386" ]; then cp navidrome_linux_386_linux_386/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
|
||||
|
||||
61
.github/workflows/pipeline.yml
vendored
61
.github/workflows/pipeline.yml
vendored
@@ -9,28 +9,46 @@ on:
|
||||
branches:
|
||||
- master
|
||||
jobs:
|
||||
golangci-lint:
|
||||
name: Lint Server
|
||||
go-lint:
|
||||
name: Lint Go code
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install taglib
|
||||
run: sudo apt-get install libtag1-dev
|
||||
|
||||
- name: Set up Go 1.17
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: 1.17
|
||||
id: go
|
||||
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v2
|
||||
with:
|
||||
version: v1.38
|
||||
version: v1.40
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
args: --timeout 2m
|
||||
|
||||
- name: Install goimports
|
||||
run: go install golang.org/x/tools/cmd/goimports
|
||||
|
||||
- run: goimports -w `find . -name '*.go' | grep -v '_gen.go$'`
|
||||
- run: go mod tidy
|
||||
- name: Verify no changes from goimports and go mod tidy
|
||||
run: |
|
||||
git status --porcelain
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
go:
|
||||
name: Test Server with Go ${{ matrix.go_version }}
|
||||
name: Test with Go ${{ matrix.go_version }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
go_version: [1.16.x]
|
||||
go_version: [1.16.x, 1.17.x]
|
||||
steps:
|
||||
- name: Install taglib
|
||||
run: sudo apt-get install libtag1-dev
|
||||
@@ -61,6 +79,7 @@ jobs:
|
||||
- name: Test
|
||||
continue-on-error: ${{contains(matrix.go_version, 'beta') || contains(matrix.go_version, 'rc')}}
|
||||
run: go test -cover ./... -v
|
||||
|
||||
js:
|
||||
name: Build JS bundle
|
||||
runs-on: ubuntu-latest
|
||||
@@ -68,27 +87,21 @@ jobs:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v1
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 14
|
||||
|
||||
- uses: actions/cache@v2
|
||||
id: cache-npm
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('ui/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
node-version: 16
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
- name: npm install dependencies
|
||||
run: |
|
||||
cd ui
|
||||
npm ci
|
||||
|
||||
- name: npm check-formatting
|
||||
- name: npm lint
|
||||
run: |
|
||||
cd ui
|
||||
npm run check-formatting
|
||||
npm run check-formatting && npm run lint
|
||||
|
||||
- name: npm test
|
||||
run: |
|
||||
@@ -104,10 +117,11 @@ jobs:
|
||||
with:
|
||||
name: js-bundle
|
||||
path: ui/build
|
||||
retention-days: 7
|
||||
|
||||
binaries:
|
||||
name: Binaries
|
||||
needs: [js, go, golangci-lint]
|
||||
name: Build binaries
|
||||
needs: [js, go, go-lint]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
@@ -128,7 +142,7 @@ jobs:
|
||||
|
||||
- name: Run GoReleaser - SNAPSHOT
|
||||
if: startsWith(github.ref, 'refs/tags/') != true
|
||||
uses: docker://deluan/ci-goreleaser:1.16.2-1
|
||||
uses: docker://deluan/ci-goreleaser:1.17.1-1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
@@ -136,7 +150,7 @@ jobs:
|
||||
|
||||
- name: Run GoReleaser - RELEASE
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: docker://deluan/ci-goreleaser:1.16.2-1
|
||||
uses: docker://deluan/ci-goreleaser:1.17.1-1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
@@ -149,9 +163,10 @@ jobs:
|
||||
dist
|
||||
!dist/*.tar.gz
|
||||
!dist/*.zip
|
||||
retention-days: 7
|
||||
|
||||
docker:
|
||||
name: Docker images
|
||||
name: Build Docker images
|
||||
needs: [binaries]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
@@ -180,7 +195,7 @@ jobs:
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
env:
|
||||
DOCKER_IMAGE: ${{secrets.DOCKER_IMAGE}}
|
||||
DOCKER_PLATFORM: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
|
||||
DOCKER_PLATFORM: linux/amd64,linux/386,linux/arm/v6,linux/arm/v7,linux/arm64
|
||||
run: |
|
||||
echo ${{secrets.DOCKER_PASSWORD}} | docker login -u ${{secrets.DOCKER_USERNAME}} --password-stdin
|
||||
docker buildx build --platform ${DOCKER_PLATFORM} `.github/workflows/docker-tags.sh` -f .github/workflows/pipeline.dockerfile --push .
|
||||
|
||||
18
.github/workflows/remove-old-artifacts.yml
vendored
18
.github/workflows/remove-old-artifacts.yml
vendored
@@ -1,18 +0,0 @@
|
||||
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
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -22,4 +22,4 @@ docker-compose.yml
|
||||
navidrome.db-shm
|
||||
navidrome.db-wal
|
||||
tags
|
||||
|
||||
.gitinfo
|
||||
|
||||
@@ -2,10 +2,10 @@ linters:
|
||||
enable:
|
||||
- bodyclose
|
||||
- deadcode
|
||||
- depguard
|
||||
- dogsled
|
||||
- errcheck
|
||||
- gocyclo
|
||||
- goimports
|
||||
- goprintffuncname
|
||||
- gosec
|
||||
- gosimple
|
||||
|
||||
@@ -62,7 +62,7 @@ builds:
|
||||
- "-extldflags '-static'"
|
||||
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
- id: navidrome_windows_i686
|
||||
- id: navidrome_windows_386
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=i686-w64-mingw32-gcc
|
||||
@@ -78,7 +78,7 @@ builds:
|
||||
- "-extldflags '-static'"
|
||||
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
- id: navidrome_windows_x64
|
||||
- id: navidrome_windows_amd64
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=x86_64-w64-mingw32-gcc
|
||||
@@ -94,7 +94,7 @@ builds:
|
||||
- "-extldflags '-static'"
|
||||
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
|
||||
|
||||
- id: navidrome_darwin
|
||||
- id: navidrome_darwin_amd64
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=o64-clang
|
||||
|
||||
92
CONTRIBUTING.md
Normal file
92
CONTRIBUTING.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Navidrome Contribution Guide
|
||||
|
||||
Navidrome is a streaming service which allows you to enjoy your music collection from anywhere. We'd welcome you to contribute to our open source project and make Navidrome even better. There are some basic guidelines which you need to follow if you like to contribute to Navidrome.
|
||||
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [Issues](#issues)
|
||||
- [Questions](#questions)
|
||||
- [Pull Requests](#pull-requests)
|
||||
|
||||
|
||||
## Code of Conduct
|
||||
Please read the following [Code of Conduct](https://github.com/navidrome/navidrome/blob/master/CODE_OF_CONDUCT.md).
|
||||
|
||||
## Issues
|
||||
Found any issue or bug in our codebase? Have a great idea you want to propose or discuss with
|
||||
the developers? You can help by submitting an [issue](https://github.com/navidrome/navidrome/issues/new/choose)
|
||||
to the Github repository.
|
||||
|
||||
**Before opening a new issue, please check if the issue has not been already made by searching
|
||||
the [issues](https://github.com/navidrome/navidrome/issues)**
|
||||
|
||||
## Questions
|
||||
We would like to have discussions and general queries related to Navidrome on our [Discord channel](https://discord.gg/2qMuMyHfSV).
|
||||
|
||||
## Pull requests
|
||||
Before submitting a pull request, ensure that you go through the following:
|
||||
- Open a corresponding issue for the Pull Request, if not existing. The issue can be opened following [these guidelines](#issues)
|
||||
- Ensure that there is no open or closed Pull Request corresponding to your submission to avoid duplication of effort.
|
||||
- Setup the [development environment](https://www.navidrome.org/docs/developers/dev-environment/)
|
||||
- Create a new branch on your forked repo and make the changes in it. Naming conventions for branch are: `<Issue Title>/<Issue Number>`. Example:
|
||||
```
|
||||
git checkout -b adding-docs/834 master
|
||||
```
|
||||
- The commits should follow a [specific convention](#commit-conventions)
|
||||
- Ensure that a DCO sign-off for commits is provided via `--signoff` option of git commit
|
||||
- Provide a link to the issue that will be closed via your Pull request.
|
||||
|
||||
### Commit Conventions
|
||||
Each commit message must adhere to the following format:
|
||||
```
|
||||
<type>(scope): <description> - <issue number>
|
||||
|
||||
[optional body]
|
||||
```
|
||||
This improves the readability of the messages
|
||||
|
||||
#### Type
|
||||
It can be one of the following:
|
||||
1. **feat**: Addition of a new feature
|
||||
2. **fix**: Bug fix
|
||||
3. **docs**: Documentation Changes
|
||||
4. **style**: Changes to styling
|
||||
5. **refactor**: Refactoring of code
|
||||
6. **perf**: Code that affects performance
|
||||
7. **test**: Updating or improving the current tests
|
||||
8. **build**: Changes to Build process
|
||||
9. **revert**: Reverting to a previous commit
|
||||
10. **chore** : updating grunt tasks etc
|
||||
|
||||
If there is a breaking change in your Pull Request, please add `BREAKING CHANGE` in the optional body section
|
||||
|
||||
#### Scope
|
||||
The file or folder where the changes are made. If there are more than one, you can mention any
|
||||
|
||||
#### Description
|
||||
A short description of the issue
|
||||
|
||||
#### Issue number
|
||||
The issue fixed by this Pull Request.
|
||||
|
||||
The body is optional. It may contain short description of changes made.
|
||||
|
||||
Following all the guidelines an ideal commit will look like:
|
||||
```
|
||||
git commit --signoff -m "feat(themes): New-theme - #834"
|
||||
```
|
||||
|
||||
After committing, push your commits to your forked branch and create a Pull Request from there.
|
||||
The Pull Request Title can be the same as `<type>(scope): <description> - <issue number>`
|
||||
A demo layout of how the Pull request body can look:
|
||||
```
|
||||
Closes <Issue number along with link>
|
||||
|
||||
Description (What does the pull request do)
|
||||
|
||||
Changes (What changes were made )
|
||||
|
||||
Screenshots or Videos
|
||||
|
||||
Related Issues and Pull Requests(if any)
|
||||
|
||||
```
|
||||
186
Makefile
186
Makefile
@@ -1,118 +1,104 @@
|
||||
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
|
||||
NODE_VERSION=$(shell cat .nvmrc)
|
||||
|
||||
ifneq ("$(wildcard .git)","")
|
||||
GIT_SHA=$(shell git rev-parse --short HEAD)
|
||||
GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`)
|
||||
else
|
||||
GIT_SHA=source_archive
|
||||
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))
|
||||
endif
|
||||
|
||||
## Default target just build the Go project.
|
||||
default:
|
||||
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=master"
|
||||
.PHONY: default
|
||||
CI_RELEASER_VERSION=1.17.1-1 ## https://github.com/navidrome/ci-goreleaser
|
||||
|
||||
dev: check_dev_env
|
||||
setup: check_env download-deps setup-git ##@1_Run_First Install dependencies and prepare development environment
|
||||
@echo Downloading Node dependencies...
|
||||
@(cd ./ui && npm ci)
|
||||
.PHONY: setup
|
||||
|
||||
dev: check_env ##@Development Start Navidrome in development mode, with hot-reload for both frontend and backend
|
||||
npx foreman -j Procfile.dev -p 4533 start
|
||||
.PHONY: dev
|
||||
|
||||
server: check_go_dev_env
|
||||
server: check_go_env ##@Development Start the backend in development mode
|
||||
@go run github.com/cespare/reflex -d none -c reflex.conf
|
||||
.PHONY: server
|
||||
|
||||
wire: check_go_env
|
||||
go run github.com/google/wire/cmd/wire ./...
|
||||
.PHONY: wire
|
||||
|
||||
watch: check_go_env
|
||||
watch: ##@Development Start Go tests in watch mode (re-run when code changes)
|
||||
go run github.com/onsi/ginkgo/ginkgo watch -notify ./...
|
||||
.PHONY: watch
|
||||
|
||||
test: check_go_env
|
||||
go test ./... -v
|
||||
test: ##@Development Run Go tests
|
||||
go test ./...
|
||||
.PHONY: test
|
||||
|
||||
testall: check_go_env test
|
||||
testall: test ##@Development Run Go and JS tests
|
||||
@(cd ./ui && npm test -- --watchAll=false)
|
||||
.PHONY: testall
|
||||
|
||||
lint:
|
||||
go run github.com/golangci/golangci-lint/cmd/golangci-lint run -v
|
||||
lint: ##@Development Lint Go code
|
||||
go run github.com/golangci/golangci-lint/cmd/golangci-lint run -v --timeout 5m
|
||||
.PHONY: lint
|
||||
|
||||
update-snapshots: check_go_env
|
||||
UPDATE_SNAPSHOTS=true go run github.com/onsi/ginkgo/ginkgo ./server/subsonic/...
|
||||
.PHONY: update-snapshots
|
||||
lintall: lint ##@Development Lint Go and JS code
|
||||
@(cd ./ui && npm run check-formatting && npm run lint)
|
||||
.PHONY: lintall
|
||||
|
||||
migration:
|
||||
wire: check_go_env ##@Development Update Dependency Injection
|
||||
go run github.com/google/wire/cmd/wire ./...
|
||||
.PHONY: wire
|
||||
|
||||
snapshots: ##@Development Update (GoLang) Snapshot tests
|
||||
UPDATE_SNAPSHOTS=true go run github.com/onsi/ginkgo/ginkgo ./server/subsonic/...
|
||||
.PHONY: snapshots
|
||||
|
||||
migration: ##@Development Create an empty migration file
|
||||
@if [ -z "${name}" ]; then echo "Usage: make migration name=name_of_migration_file"; exit 1; fi
|
||||
go run github.com/pressly/goose/cmd/goose -dir db/migration create ${name}
|
||||
.PHONY: migration
|
||||
|
||||
setup: download-deps
|
||||
@echo Installing tools from tools.go
|
||||
@cat tools.go | grep _ | awk -F'"' '{print $$2}' | xargs -tI % go install %
|
||||
.PHONY: setup
|
||||
|
||||
download-deps:
|
||||
@echo Downloading Go dependencies...
|
||||
@go mod download -x
|
||||
@go mod tidy # To revert any changes made by the `go mod download` command
|
||||
@echo Downloading Node dependencies...
|
||||
@(cd ./ui && npm ci)
|
||||
.PHONY: download-deps
|
||||
|
||||
setup-dev: setup setup-git
|
||||
setup-dev: setup
|
||||
.PHONY: setup-dev
|
||||
|
||||
setup-git:
|
||||
setup-git: ##@Development Setup Git hooks (pre-commit and pre-push)
|
||||
@echo Setting up git hooks
|
||||
@mkdir -p .git/hooks
|
||||
@(cd .git/hooks && ln -sf ../../git/* .)
|
||||
.PHONY: setup-git
|
||||
|
||||
check_dev_env: check_go_dev_env check_node_dev_env
|
||||
.PHONY: check_dev_env
|
||||
|
||||
check_go_dev_env:
|
||||
@(hash go) || (echo "\nERROR: GO environment not setup properly!\n"; exit 1)
|
||||
@current_go_version=`go version | cut -d ' ' -f 3 | cut -c3-` && \
|
||||
echo "$(GO_VERSION) $$current_go_version" | \
|
||||
tr ' ' '\n' | sort -V | tail -1 | \
|
||||
grep -q "^$${current_go_version}$$" || \
|
||||
(echo "\nERROR: Please upgrade your GO version\nThis project requires at least the version $(GO_VERSION)"; exit 1)
|
||||
.PHONY: check_go_dev_env
|
||||
|
||||
check_node_dev_env:
|
||||
@(hash node) || (echo "\nERROR: Node environment not setup properly!\n"; exit 1)
|
||||
@current_node_version=`node --version` && \
|
||||
echo "$(NODE_VERSION) $$current_node_version" | \
|
||||
tr ' ' '\n' | sort -V | tail -1 | \
|
||||
grep -q "^$${current_node_version}$$" || \
|
||||
(echo "\nERROR: Please check your Node version. Should be at least $(NODE_VERSION)\n"; exit 1)
|
||||
.PHONY: check_node_dev_env
|
||||
|
||||
check_env: check_go_env check_node_env
|
||||
.PHONY: check_env
|
||||
|
||||
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\nThis project requires version $(GO_VERSION)"; exit 1)
|
||||
.PHONY: check_go_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
|
||||
|
||||
build: check_go_env
|
||||
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT"
|
||||
.PHONY: build
|
||||
|
||||
buildall: check_env
|
||||
@(cd ./ui && npm run build)
|
||||
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT" -tags=netgo
|
||||
buildall: buildjs build ##@Build Build the project, both frontend and backend
|
||||
.PHONY: buildall
|
||||
|
||||
pre-push: lint test
|
||||
.PHONY: pre-push
|
||||
build: check_go_env ##@Build Build only backend
|
||||
@echo "WARNING: This command does not build the frontend, it uses the latest one built by 'make buildjs'"
|
||||
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT" -tags=netgo
|
||||
.PHONY: build
|
||||
|
||||
buildjs: check_node_env ##@Build Build only frontend
|
||||
@(cd ./ui && npm run build)
|
||||
.PHONY: buildjs
|
||||
|
||||
all: ##@Cross_Compilation Build binaries for all supported platforms. It does not build the frontend
|
||||
@echo "WARNING: This command does not builds the frontend, it uses the latest one built by 'make buildjs'"
|
||||
docker run -t -v $(PWD):/workspace -w /workspace deluan/ci-goreleaser:$(CI_RELEASER_VERSION) \
|
||||
goreleaser release --rm-dist --skip-publish --snapshot
|
||||
.PHONY: all
|
||||
|
||||
single: ##@Cross_Compilation Build binaries for a single supported platforms. It does not build the frontend
|
||||
@echo "WARNING: This command does not build the frontend, it uses the latest one built by 'make buildjs'"
|
||||
@if [ -z "${GOOS}" -o -z "${GOARCH}" ]; then \
|
||||
echo "Usage: GOOS=<os> GOARCH=<arch> make single"; \
|
||||
echo "Options:"; \
|
||||
grep -- "- id: navidrome_" .goreleaser.yml | sed 's/- id: navidrome_//g'; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "Building binaries for ${GOOS}/${GOARCH}"
|
||||
docker run -t -v $(PWD):/workspace -e GOOS -e GOARCH -w /workspace deluan/ci-goreleaser:$(CI_RELEASER_VERSION) \
|
||||
goreleaser build --rm-dist --snapshot --single-target --id navidrome_${GOOS}_${GOARCH}
|
||||
.PHONY: single
|
||||
|
||||
##########################################
|
||||
#### Miscellaneous
|
||||
|
||||
release:
|
||||
@if [[ ! "${V}" =~ ^[0-9]+\.[0-9]+\.[0-9]+.*$$ ]]; then echo "Usage: make release V=X.X.X"; exit 1; fi
|
||||
@@ -123,6 +109,44 @@ release:
|
||||
git push origin v${V} --no-verify
|
||||
.PHONY: release
|
||||
|
||||
snapshot:
|
||||
docker run -t -v $(PWD):/workspace -w /workspace deluan/ci-goreleaser:1.16.2-1 goreleaser release --rm-dist --skip-publish --snapshot
|
||||
.PHONY: snapshot
|
||||
download-deps:
|
||||
@echo Downloading Go dependencies...
|
||||
@go mod download -x
|
||||
@go mod tidy # To revert any changes made by the `go mod download` command
|
||||
.PHONY: download-deps
|
||||
|
||||
check_env: check_go_env check_node_env
|
||||
.PHONY: check_env
|
||||
|
||||
check_go_env:
|
||||
@(hash go) || (echo "\nERROR: GO environment not setup properly!\n"; exit 1)
|
||||
@current_go_version=`go version | cut -d ' ' -f 3 | cut -c3-` && \
|
||||
echo "$(GO_VERSION) $$current_go_version" | \
|
||||
tr ' ' '\n' | sort -V | tail -1 | \
|
||||
grep -q "^$${current_go_version}$$" || \
|
||||
(echo "\nERROR: Please upgrade your GO version\nThis project requires at least the version $(GO_VERSION)"; exit 1)
|
||||
.PHONY: check_go_env
|
||||
|
||||
check_node_env:
|
||||
@(hash node) || (echo "\nERROR: Node environment not setup properly!\n"; exit 1)
|
||||
@current_node_version=`node --version` && \
|
||||
echo "$(NODE_VERSION) $$current_node_version" | \
|
||||
tr ' ' '\n' | sort -V | tail -1 | \
|
||||
grep -q "^$${current_node_version}$$" || \
|
||||
(echo "\nERROR: Please check your Node version. Should be at least $(NODE_VERSION)\n"; exit 1)
|
||||
.PHONY: check_node_env
|
||||
|
||||
pre-push: lintall testall
|
||||
.PHONY: pre-push
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
HELP_FUN = \
|
||||
%help; while(<>){push@{$$help{$$2//'options'}},[$$1,$$3] \
|
||||
if/^([\w-_]+)\s*:.*\#\#(?:@(\w+))?\s(.*)$$/}; \
|
||||
print"$$_:\n", map" $$_->[0]".(" "x(20-length($$_->[0])))."$$_->[1]\n",\
|
||||
@{$$help{$$_}},"\n" for sort keys %help; \
|
||||
|
||||
help: ##@Miscellaneous Show this help
|
||||
@echo "Usage: make [target] ...\n"
|
||||
@perl -e '$(HELP_FUN)' $(MAKEFILE_LIST)
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
JS: sh -c "cd ./ui && npm start"
|
||||
GO: go run github.com/cespare/reflex -c reflex.conf
|
||||
GO: go run github.com/cespare/reflex -d none -c reflex.conf
|
||||
|
||||
16
README.md
16
README.md
@@ -1,12 +1,14 @@
|
||||
<img src="resources/logo-192x192.png" alt="Navidrome logo" title="navidrome" align="right" height="60px" />
|
||||
|
||||
# Navidrome Music Server
|
||||
|
||||
[](https://github.com/navidrome/navidrome/releases)
|
||||
[](https://github.com/navidrome/navidrome/actions)
|
||||
[](https://github.com/navidrome/navidrome/releases/latest)
|
||||
[](https://hub.docker.com/r/deluan/navidrome)
|
||||
[](https://discord.gg/xh7j7yF)
|
||||
[](https://www.reddit.com/r/navidrome/)
|
||||
[](code_of_conduct.md)
|
||||
[](https://github.com/navidrome/navidrome/releases)
|
||||
[](https://nightly.link/navidrome/navidrome/workflows/pipeline/master)
|
||||
[](https://github.com/navidrome/navidrome/releases/latest)
|
||||
[](https://hub.docker.com/r/deluan/navidrome)
|
||||
[](https://discord.gg/xh7j7yF)
|
||||
[](https://www.reddit.com/r/navidrome/)
|
||||
[](CODE_OF_CONDUCT.md)
|
||||
|
||||
## [Check out our Live Demo!](https://www.navidrome.org/demo/)
|
||||
|
||||
|
||||
85
cmd/root.go
85
cmd/root.go
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/scheduler"
|
||||
"github.com/oklog/run"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
@@ -53,8 +54,17 @@ func runNavidrome() {
|
||||
db.EnsureLatestVersion()
|
||||
|
||||
var g run.Group
|
||||
|
||||
g.Add(startServer())
|
||||
g.Add(startScanner())
|
||||
g.Add(startSignaler())
|
||||
g.Add(startScheduler())
|
||||
|
||||
schedule := conf.Server.ScanSchedule
|
||||
if schedule != "" {
|
||||
go schedulePeriodicScan(schedule)
|
||||
} else {
|
||||
log.Warn("Periodic scan is DISABLED")
|
||||
}
|
||||
|
||||
if err := g.Run(); err != nil {
|
||||
log.Error("Fatal error in Navidrome. Aborting", err)
|
||||
@@ -64,8 +74,11 @@ func runNavidrome() {
|
||||
func startServer() (func() error, func(err error)) {
|
||||
return func() error {
|
||||
a := CreateServer(conf.Server.MusicFolder)
|
||||
a.MountRouter(consts.URLPathSubsonicAPI, CreateSubsonicAPIRouter())
|
||||
a.MountRouter(consts.URLPathUI, CreateAppRouter())
|
||||
a.MountRouter("Native API", consts.URLPathNativeAPI, CreateNativeAPIRouter())
|
||||
a.MountRouter("Subsonic API", consts.URLPathSubsonicAPI, CreateSubsonicAPIRouter())
|
||||
if conf.Server.LastFM.Enabled {
|
||||
a.MountRouter("LastFM Auth", consts.URLPathNativeAPI+"/lastfm", CreateLastFMRouter())
|
||||
}
|
||||
return a.Run(fmt.Sprintf("%s:%d", conf.Server.Address, conf.Server.Port))
|
||||
}, func(err error) {
|
||||
if err != nil {
|
||||
@@ -76,28 +89,72 @@ func startServer() (func() error, func(err error)) {
|
||||
}
|
||||
}
|
||||
|
||||
func startScanner() (func() error, func(err error)) {
|
||||
interval := conf.Server.ScanInterval
|
||||
log.Info("Starting scanner", "interval", interval.String())
|
||||
var sigChan = make(chan os.Signal, 1)
|
||||
|
||||
func startSignaler() (func() error, func(err error)) {
|
||||
scanner := GetScanner()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
return func() error {
|
||||
if interval != 0 {
|
||||
time.Sleep(2 * time.Second) // Wait 2 seconds before the first scan
|
||||
scanner.Run(ctx, interval)
|
||||
} else {
|
||||
log.Warn("Periodic scan is DISABLED", "interval", interval)
|
||||
<-ctx.Done()
|
||||
for {
|
||||
select {
|
||||
case sig := <-sigChan:
|
||||
log.Info(ctx, "Received signal, triggering a new scan", "signal", sig)
|
||||
start := time.Now()
|
||||
err := scanner.RescanAll(ctx, false)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error scanning", err)
|
||||
}
|
||||
log.Info(ctx, "Triggered scan complete", "elapsed", time.Since(start).Round(100*time.Millisecond))
|
||||
case <-ctx.Done():
|
||||
break
|
||||
}
|
||||
}
|
||||
}, func(err error) {
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Error("Shutting down Signaler due to error", err)
|
||||
} else {
|
||||
log.Info("Shutting down Signaler")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func schedulePeriodicScan(schedule string) {
|
||||
scanner := GetScanner()
|
||||
schedulerInstance := scheduler.GetInstance()
|
||||
|
||||
log.Info("Scheduling periodic scan", "schedule", schedule)
|
||||
err := schedulerInstance.Add(schedule, func() {
|
||||
_ = scanner.RescanAll(context.Background(), false)
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("Error scheduling periodic scan", err)
|
||||
}
|
||||
|
||||
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
|
||||
log.Debug("Executing initial scan")
|
||||
if err := scanner.RescanAll(context.Background(), false); err != nil {
|
||||
log.Error("Error executing initial scan", err)
|
||||
}
|
||||
log.Debug("Finished initial scan")
|
||||
}
|
||||
|
||||
func startScheduler() (func() error, func(err error)) {
|
||||
log.Info("Starting scheduler")
|
||||
schedulerInstance := scheduler.GetInstance()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
return func() error {
|
||||
schedulerInstance.Run(ctx)
|
||||
|
||||
return nil
|
||||
}, func(err error) {
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Error("Shutting down Scanner due to error", err)
|
||||
log.Error("Shutting down Scheduler due to error", err)
|
||||
} else {
|
||||
log.Info("Shutting down Scanner")
|
||||
log.Info("Shutting down Scheduler")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
17
cmd/signaler_unix.go
Normal file
17
cmd/signaler_unix.go
Normal file
@@ -0,0 +1,17 @@
|
||||
//go:build !windows && !plan9
|
||||
// +build !windows,!plan9
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func init() {
|
||||
signals := []os.Signal{
|
||||
syscall.SIGUSR1,
|
||||
}
|
||||
signal.Notify(sigChan, signals...)
|
||||
}
|
||||
@@ -8,12 +8,16 @@ package cmd
|
||||
import (
|
||||
"github.com/google/wire"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/agents/lastfm"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/core/transcoder"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/app"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/server/nativeapi"
|
||||
"github.com/navidrome/navidrome/server/subsonic"
|
||||
"sync"
|
||||
)
|
||||
@@ -21,20 +25,24 @@ import (
|
||||
// Injectors from wire_injectors.go:
|
||||
|
||||
func CreateServer(musicFolder string) *server.Server {
|
||||
dataStore := persistence.New()
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
serverServer := server.New(dataStore)
|
||||
return serverServer
|
||||
}
|
||||
|
||||
func CreateAppRouter() *app.Router {
|
||||
dataStore := persistence.New()
|
||||
broker := GetBroker()
|
||||
router := app.New(dataStore, broker)
|
||||
func CreateNativeAPIRouter() *nativeapi.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
broker := events.GetBroker()
|
||||
share := core.NewShare(dataStore)
|
||||
router := nativeapi.New(dataStore, broker, share)
|
||||
return router
|
||||
}
|
||||
|
||||
func CreateSubsonicAPIRouter() *subsonic.Router {
|
||||
dataStore := persistence.New()
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
artworkCache := core.GetImageCache()
|
||||
artwork := core.NewArtwork(dataStore, artworkCache)
|
||||
transcoderTranscoder := transcoder.New()
|
||||
@@ -42,30 +50,36 @@ func CreateSubsonicAPIRouter() *subsonic.Router {
|
||||
mediaStreamer := core.NewMediaStreamer(dataStore, transcoderTranscoder, transcodingCache)
|
||||
archiver := core.NewArchiver(dataStore)
|
||||
players := core.NewPlayers(dataStore)
|
||||
externalMetadata := core.NewExternalMetadata(dataStore)
|
||||
agentsAgents := agents.New(dataStore)
|
||||
externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents)
|
||||
scanner := GetScanner()
|
||||
router := subsonic.New(dataStore, artwork, mediaStreamer, archiver, players, externalMetadata, scanner)
|
||||
broker := events.GetBroker()
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker)
|
||||
router := subsonic.New(dataStore, artwork, mediaStreamer, archiver, players, externalMetadata, scanner, broker, playTracker)
|
||||
return router
|
||||
}
|
||||
|
||||
func CreateLastFMRouter() *lastfm.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
router := lastfm.NewRouter(dataStore)
|
||||
return router
|
||||
}
|
||||
|
||||
func createScanner() scanner.Scanner {
|
||||
dataStore := persistence.New()
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
artworkCache := core.GetImageCache()
|
||||
artwork := core.NewArtwork(dataStore, artworkCache)
|
||||
cacheWarmer := core.NewCacheWarmer(artwork, artworkCache)
|
||||
broker := GetBroker()
|
||||
broker := events.GetBroker()
|
||||
scannerScanner := scanner.New(dataStore, cacheWarmer, broker)
|
||||
return scannerScanner
|
||||
}
|
||||
|
||||
func createBroker() events.Broker {
|
||||
broker := events.NewBroker()
|
||||
return broker
|
||||
}
|
||||
|
||||
// wire_injectors.go:
|
||||
|
||||
var allProviders = wire.NewSet(core.Set, subsonic.New, app.New, persistence.New)
|
||||
var allProviders = wire.NewSet(core.Set, subsonic.New, nativeapi.New, persistence.New, lastfm.NewRouter, events.GetBroker, db.Db)
|
||||
|
||||
// Scanner must be a Singleton
|
||||
var (
|
||||
@@ -79,16 +93,3 @@ func GetScanner() scanner.Scanner {
|
||||
})
|
||||
return scannerInstance
|
||||
}
|
||||
|
||||
// Broker must be a Singleton
|
||||
var (
|
||||
onceBroker sync.Once
|
||||
brokerInstance events.Broker
|
||||
)
|
||||
|
||||
func GetBroker() events.Broker {
|
||||
onceBroker.Do(func() {
|
||||
brokerInstance = createBroker()
|
||||
})
|
||||
return brokerInstance
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//+build wireinject
|
||||
//go:build wireinject
|
||||
// +build wireinject
|
||||
|
||||
package cmd
|
||||
|
||||
@@ -7,19 +8,24 @@ import (
|
||||
|
||||
"github.com/google/wire"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/agents/lastfm"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/app"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/server/nativeapi"
|
||||
"github.com/navidrome/navidrome/server/subsonic"
|
||||
)
|
||||
|
||||
var allProviders = wire.NewSet(
|
||||
core.Set,
|
||||
subsonic.New,
|
||||
app.New,
|
||||
nativeapi.New,
|
||||
persistence.New,
|
||||
lastfm.NewRouter,
|
||||
events.GetBroker,
|
||||
db.Db,
|
||||
)
|
||||
|
||||
func CreateServer(musicFolder string) *server.Server {
|
||||
@@ -29,10 +35,9 @@ func CreateServer(musicFolder string) *server.Server {
|
||||
))
|
||||
}
|
||||
|
||||
func CreateAppRouter() *app.Router {
|
||||
func CreateNativeAPIRouter() *nativeapi.Router {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
GetBroker,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -43,6 +48,12 @@ func CreateSubsonicAPIRouter() *subsonic.Router {
|
||||
))
|
||||
}
|
||||
|
||||
func CreateLastFMRouter() *lastfm.Router {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
))
|
||||
}
|
||||
|
||||
// Scanner must be a Singleton
|
||||
var (
|
||||
onceScanner sync.Once
|
||||
@@ -59,26 +70,6 @@ func GetScanner() scanner.Scanner {
|
||||
func createScanner() scanner.Scanner {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
GetBroker,
|
||||
scanner.New,
|
||||
))
|
||||
}
|
||||
|
||||
// Broker must be a Singleton
|
||||
var (
|
||||
onceBroker sync.Once
|
||||
brokerInstance events.Broker
|
||||
)
|
||||
|
||||
func GetBroker() events.Broker {
|
||||
onceBroker.Do(func() {
|
||||
brokerInstance = createBroker()
|
||||
})
|
||||
return brokerInstance
|
||||
}
|
||||
|
||||
func createBroker() events.Broker {
|
||||
panic(wire.Build(
|
||||
events.NewBroker,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/kr/pretty"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
@@ -22,6 +23,7 @@ type configOptions struct {
|
||||
DbPath string
|
||||
LogLevel string
|
||||
ScanInterval time.Duration
|
||||
ScanSchedule string
|
||||
SessionTimeout time.Duration
|
||||
BaseURL string
|
||||
UILoginBackgroundURL string
|
||||
@@ -30,6 +32,7 @@ type configOptions struct {
|
||||
TranscodingCacheSize string
|
||||
ImageCacheSize string
|
||||
AutoImportPlaylists bool
|
||||
PlaylistsPath string
|
||||
|
||||
SearchFullString bool
|
||||
RecentlyAddedByModTime bool
|
||||
@@ -40,9 +43,18 @@ type configOptions struct {
|
||||
CoverJpegQuality int
|
||||
UIWelcomeMessage string
|
||||
EnableGravatar bool
|
||||
EnableFavourites bool
|
||||
EnableStarRating bool
|
||||
EnableUserEditing bool
|
||||
DefaultTheme string
|
||||
EnableCoverAnimation bool
|
||||
GATrackingID string
|
||||
EnableLogRedacting bool
|
||||
AuthRequestLimit int
|
||||
AuthWindowLength time.Duration
|
||||
PasswordEncryptionKey string
|
||||
ReverseProxyUserHeader string
|
||||
ReverseProxyWhitelist string
|
||||
|
||||
Scanner scannerOptions
|
||||
|
||||
@@ -52,18 +64,25 @@ type configOptions struct {
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
DevLogSourceLine bool
|
||||
DevLogLevels map[string]string
|
||||
DevAutoCreateAdminPassword string
|
||||
DevAutoLoginUsername string
|
||||
DevPreCacheAlbumArtwork bool
|
||||
DevFastAccessCoverArt bool
|
||||
DevOldCacheLayout bool
|
||||
DevActivityPanel bool
|
||||
DevEnableShare bool
|
||||
DevSidebarPlaylists bool
|
||||
DevEnableBufferedScrobble bool
|
||||
DevShowArtistPage bool
|
||||
}
|
||||
|
||||
type scannerOptions struct {
|
||||
Extractor string
|
||||
Extractor string
|
||||
GenreSeparators string
|
||||
}
|
||||
|
||||
type lastfmOptions struct {
|
||||
Enabled bool
|
||||
ApiKey string
|
||||
Secret string
|
||||
Language string
|
||||
@@ -87,12 +106,12 @@ func LoadFromFile(confFile string) {
|
||||
func Load() {
|
||||
err := viper.Unmarshal(&Server)
|
||||
if err != nil {
|
||||
fmt.Println("Error parsing config:", err)
|
||||
fmt.Println("FATAL: 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)
|
||||
fmt.Println("FATAL: Error creating data path:", "path", Server.DataFolder, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
Server.ConfigFile = viper.GetViper().ConfigFileUsed()
|
||||
@@ -101,9 +120,21 @@ func Load() {
|
||||
}
|
||||
|
||||
log.SetLevelString(Server.LogLevel)
|
||||
log.SetLogLevels(Server.DevLogLevels)
|
||||
log.SetLogSourceLine(Server.DevLogSourceLine)
|
||||
log.SetRedacting(Server.EnableLogRedacting)
|
||||
|
||||
if err := validateScanSchedule(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Print current configuration if log level is Debug
|
||||
if log.CurrentLevel() >= log.LevelDebug {
|
||||
pretty.Printf("Loaded configuration from '%s': %# v\n", Server.ConfigFile, Server)
|
||||
prettyConf := pretty.Sprintf("Loaded configuration from '%s': %# v", Server.ConfigFile, Server)
|
||||
if Server.EnableLogRedacting {
|
||||
prettyConf = log.Redact(prettyConf)
|
||||
}
|
||||
fmt.Println(prettyConf)
|
||||
}
|
||||
|
||||
// Call init hooks
|
||||
@@ -112,6 +143,35 @@ func Load() {
|
||||
}
|
||||
}
|
||||
|
||||
func validateScanSchedule() error {
|
||||
if Server.ScanInterval != -1 {
|
||||
log.Warn("ScanInterval is DEPRECATED. Please use ScanSchedule. See docs at https://navidrome.org/docs/usage/configuration-options/")
|
||||
if Server.ScanSchedule != "@every 1m" {
|
||||
log.Error("You cannot specify both ScanInterval and ScanSchedule, ignoring ScanInterval")
|
||||
} else {
|
||||
if Server.ScanInterval == 0 {
|
||||
Server.ScanSchedule = ""
|
||||
} else {
|
||||
Server.ScanSchedule = fmt.Sprintf("@every %s", Server.ScanInterval)
|
||||
}
|
||||
log.Warn("Setting ScanSchedule", "schedule", Server.ScanSchedule)
|
||||
}
|
||||
}
|
||||
if Server.ScanSchedule == "0" || Server.ScanSchedule == "" {
|
||||
Server.ScanSchedule = ""
|
||||
return nil
|
||||
}
|
||||
if _, err := time.ParseDuration(Server.ScanSchedule); err == nil {
|
||||
Server.ScanSchedule = "@every " + Server.ScanSchedule
|
||||
}
|
||||
c := cron.New()
|
||||
_, err := c.AddFunc(Server.ScanSchedule, func() {})
|
||||
if err != nil {
|
||||
log.Error("Invalid ScanSchedule. Please read format spec at https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format", "schedule", Server.ScanSchedule, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// AddHook is used to register initialization code that should run as soon as the config is loaded
|
||||
func AddHook(hook func()) {
|
||||
hooks = append(hooks, hook)
|
||||
@@ -124,13 +184,15 @@ func init() {
|
||||
viper.SetDefault("address", "0.0.0.0")
|
||||
viper.SetDefault("port", 4533)
|
||||
viper.SetDefault("sessiontimeout", consts.DefaultSessionTimeout)
|
||||
viper.SetDefault("scaninterval", time.Minute)
|
||||
viper.SetDefault("scaninterval", -1)
|
||||
viper.SetDefault("scanschedule", "@every 1m")
|
||||
viper.SetDefault("baseurl", "")
|
||||
viper.SetDefault("uiloginbackgroundurl", "https://source.unsplash.com/random/1600x900?music")
|
||||
viper.SetDefault("uiloginbackgroundurl", consts.DefaultUILoginBackgroundURL)
|
||||
viper.SetDefault("enabletranscodingconfig", false)
|
||||
viper.SetDefault("transcodingcachesize", "100MB")
|
||||
viper.SetDefault("imagecachesize", "100MB")
|
||||
viper.SetDefault("autoimportplaylists", true)
|
||||
viper.SetDefault("playlistspath", consts.DefaultPlaylistsPath)
|
||||
viper.SetDefault("enabledownloads", true)
|
||||
|
||||
// Config options only valid for file/env configuration
|
||||
@@ -143,25 +205,42 @@ func init() {
|
||||
viper.SetDefault("coverjpegquality", 75)
|
||||
viper.SetDefault("uiwelcomemessage", "")
|
||||
viper.SetDefault("enablegravatar", false)
|
||||
viper.SetDefault("enablefavourites", true)
|
||||
viper.SetDefault("enablestarrating", true)
|
||||
viper.SetDefault("enableuserediting", true)
|
||||
viper.SetDefault("defaulttheme", "Dark")
|
||||
viper.SetDefault("enablecoveranimation", true)
|
||||
viper.SetDefault("gatrackingid", "")
|
||||
viper.SetDefault("enablelogredacting", true)
|
||||
viper.SetDefault("authrequestlimit", 5)
|
||||
viper.SetDefault("authwindowlength", 20*time.Second)
|
||||
viper.SetDefault("passwordencryptionkey", "")
|
||||
|
||||
viper.SetDefault("reverseproxyuserheader", "Remote-User")
|
||||
viper.SetDefault("reverseproxywhitelist", "")
|
||||
|
||||
viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor)
|
||||
viper.SetDefault("scanner.genreseparators", ";/,")
|
||||
|
||||
viper.SetDefault("scanner.extractor", "taglib")
|
||||
viper.SetDefault("agents", "lastfm,spotify")
|
||||
viper.SetDefault("lastfm.enabled", true)
|
||||
viper.SetDefault("lastfm.language", "en")
|
||||
viper.SetDefault("lastfm.apikey", "")
|
||||
viper.SetDefault("lastfm.secret", "")
|
||||
viper.SetDefault("lastfm.apikey", consts.LastFMAPIKey)
|
||||
viper.SetDefault("lastfm.secret", consts.LastFMAPISecret)
|
||||
viper.SetDefault("spotify.id", "")
|
||||
viper.SetDefault("spotify.secret", "")
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
viper.SetDefault("devlogsourceline", false)
|
||||
viper.SetDefault("devautocreateadminpassword", "")
|
||||
viper.SetDefault("devautologinusername", "")
|
||||
viper.SetDefault("devprecachealbumartwork", false)
|
||||
viper.SetDefault("devoldcachelayout", false)
|
||||
viper.SetDefault("devFastAccessCoverArt", false)
|
||||
viper.SetDefault("devfastaccesscoverart", false)
|
||||
viper.SetDefault("devactivitypanel", true)
|
||||
viper.SetDefault("devenableshare", false)
|
||||
viper.SetDefault("devenablebufferedscrobble", true)
|
||||
viper.SetDefault("devsidebarplaylists", true)
|
||||
viper.SetDefault("devshowartistpage", false)
|
||||
}
|
||||
|
||||
func InitConfig(cfgFile string) {
|
||||
@@ -182,8 +261,8 @@ func InitConfig(cfgFile string) {
|
||||
viper.AutomaticEnv()
|
||||
|
||||
err := viper.ReadInConfig()
|
||||
if cfgFile != "" && err != nil {
|
||||
fmt.Println("Navidrome could not open config file: ", err)
|
||||
if viper.ConfigFileUsed() != "" && err != nil {
|
||||
fmt.Println("FATAL: Navidrome could not open config file: ", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,12 @@ import (
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
)
|
||||
|
||||
func getBanner() string {
|
||||
func loadBanner() string {
|
||||
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\n%s%s\n", getBanner(), padding, version)
|
||||
return fmt.Sprintf("%s\n%52s\n", loadBanner(), version)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package consts
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -13,21 +14,32 @@ const (
|
||||
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on"
|
||||
InitialSetupFlagKey = "InitialSetup"
|
||||
|
||||
UIAuthorizationHeader = "X-ND-Authorization"
|
||||
JWTSecretKey = "JWTSecret"
|
||||
JWTIssuer = "ND"
|
||||
DefaultSessionTimeout = 24 * time.Hour
|
||||
UIAuthorizationHeader = "X-ND-Authorization"
|
||||
UIClientUniqueIDHeader = "X-ND-Client-Unique-Id"
|
||||
JWTSecretKey = "JWTSecret"
|
||||
JWTIssuer = "ND"
|
||||
DefaultSessionTimeout = 24 * time.Hour
|
||||
CookieExpiry = 365 * 24 * 3600 // One year
|
||||
|
||||
// DefaultEncryptionKey This is the encryption key used if none is specified in the `PasswordEncryptionKey` option
|
||||
// Never ever change this! Or it will break all Navidrome installations that don't set the config option
|
||||
DefaultEncryptionKey = "just for obfuscation"
|
||||
PasswordsEncryptedKey = "PasswordsEncryptedKey"
|
||||
|
||||
DevInitialUserName = "admin"
|
||||
DevInitialName = "Dev Admin"
|
||||
|
||||
URLPathUI = "/app"
|
||||
URLPathNativeAPI = "/api"
|
||||
URLPathSubsonicAPI = "/rest"
|
||||
|
||||
// Login backgrounds from https://unsplash.com/collections/20072696/navidrome
|
||||
DefaultUILoginBackgroundURL = "https://source.unsplash.com/collection/20072696/1600x900"
|
||||
|
||||
RequestThrottleBacklogLimit = 100
|
||||
RequestThrottleBacklogTimeout = time.Minute
|
||||
|
||||
ArtistInfoTimeToLive = 1 * time.Hour
|
||||
ArtistInfoTimeToLive = 3 * 24 * time.Hour
|
||||
|
||||
I18nFolder = "i18n"
|
||||
SkipScanFile = ".ndignore"
|
||||
@@ -35,7 +47,9 @@ const (
|
||||
PlaceholderAlbumArt = "navidrome-600x600.png"
|
||||
PlaceholderAvatar = "logo-192x192.png"
|
||||
|
||||
DefaultCachedHttpClientTTL = 10 * time.Second
|
||||
DefaultHttpClientTimeOut = 10 * time.Second
|
||||
|
||||
DefaultScannerExtractor = "taglib"
|
||||
)
|
||||
|
||||
// Cache options
|
||||
@@ -50,6 +64,12 @@ const (
|
||||
DefaultCacheCleanUpInterval = 10 * time.Minute
|
||||
)
|
||||
|
||||
// Shared secrets (only add here "secrets" that can be public)
|
||||
const (
|
||||
LastFMAPIKey = "9b94a5515ea66b2da3ec03c12300327e"
|
||||
LastFMAPISecret = "74cb6557cec7171d921af5d7d887c587" // nolint:gosec
|
||||
)
|
||||
|
||||
var (
|
||||
DefaultTranscodings = []map[string]interface{}{
|
||||
{
|
||||
@@ -65,12 +85,15 @@ var (
|
||||
"command": "ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -c:a libopus -f opus -",
|
||||
},
|
||||
}
|
||||
|
||||
DefaultPlaylistsPath = strings.Join([]string{".", "**/**"}, string(filepath.ListSeparator))
|
||||
)
|
||||
|
||||
var (
|
||||
VariousArtists = "Various Artists"
|
||||
VariousArtistsID = fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(VariousArtists))))
|
||||
UnknownArtist = "[Unknown Artist]"
|
||||
VariousArtists = "Various Artists"
|
||||
VariousArtistsID = fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(VariousArtists))))
|
||||
UnknownArtist = "[Unknown Artist]"
|
||||
VariousArtistsMbzId = "89ad4ac3-39f7-470e-963a-56509c546377"
|
||||
|
||||
ServerStart = time.Now()
|
||||
)
|
||||
|
||||
@@ -1,38 +1,59 @@
|
||||
package consts
|
||||
|
||||
import "mime"
|
||||
import (
|
||||
"mime"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type format struct {
|
||||
typ string
|
||||
lossless bool
|
||||
}
|
||||
|
||||
var audioFormats = map[string]format{
|
||||
".mp3": {typ: "audio/mpeg"},
|
||||
".ogg": {typ: "audio/ogg"},
|
||||
".oga": {typ: "audio/ogg"},
|
||||
".opus": {typ: "audio/ogg"},
|
||||
".aac": {typ: "audio/mp4"},
|
||||
".alac": {typ: "audio/mp4", lossless: true},
|
||||
".m4a": {typ: "audio/mp4"},
|
||||
".m4b": {typ: "audio/mp4"},
|
||||
".flac": {typ: "audio/flac", lossless: true},
|
||||
".wav": {typ: "audio/x-wav", lossless: true},
|
||||
".wma": {typ: "audio/x-ms-wma"},
|
||||
".ape": {typ: "audio/x-monkeys-audio", lossless: true},
|
||||
".mpc": {typ: "audio/x-musepack"},
|
||||
".shn": {typ: "audio/x-shn", lossless: true},
|
||||
".aif": {typ: "audio/x-aiff"},
|
||||
".aiff": {typ: "audio/x-aiff"},
|
||||
".m3u": {typ: "audio/x-mpegurl"},
|
||||
".pls": {typ: "audio/x-scpls"},
|
||||
".dsf": {typ: "audio/dsd", lossless: true},
|
||||
".wv": {typ: "audio/x-wavpack", lossless: true},
|
||||
".wvp": {typ: "audio/x-wavpack", lossless: true},
|
||||
}
|
||||
var imageFormats = map[string]string{
|
||||
".gif": "image/gif",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".png": "image/png",
|
||||
".bmp": "image/bmp",
|
||||
}
|
||||
|
||||
var LosslessFormats []string
|
||||
|
||||
func init() {
|
||||
mt := map[string]string{
|
||||
".mp3": "audio/mpeg",
|
||||
".ogg": "audio/ogg",
|
||||
".oga": "audio/ogg",
|
||||
".opus": "audio/ogg",
|
||||
".aac": "audio/mp4",
|
||||
".m4a": "audio/mp4",
|
||||
".m4b": "audio/mp4",
|
||||
".flac": "audio/flac",
|
||||
".wav": "audio/x-wav",
|
||||
".wma": "audio/x-ms-wma",
|
||||
".ape": "audio/x-monkeys-audio",
|
||||
".mpc": "audio/x-musepack",
|
||||
".shn": "audio/x-shn",
|
||||
".aif": "audio/x-aiff",
|
||||
".aiff": "audio/x-aiff",
|
||||
".m3u": "audio/x-mpegurl",
|
||||
".pls": "audio/x-scpls",
|
||||
".dsf": "audio/dsd",
|
||||
".wv": "audio/x-wavpack",
|
||||
".wvp": "audio/x-wavpack",
|
||||
".gif": "image/gif",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".png": "image/png",
|
||||
".bmp": "image/bmp",
|
||||
for ext, fmt := range audioFormats {
|
||||
_ = mime.AddExtensionType(ext, fmt.typ)
|
||||
if fmt.lossless {
|
||||
LosslessFormats = append(LosslessFormats, strings.TrimPrefix(ext, "."))
|
||||
}
|
||||
}
|
||||
|
||||
for ext, typ := range mt {
|
||||
sort.Strings(LosslessFormats)
|
||||
for ext, typ := range imageFormats {
|
||||
_ = mime.AddExtensionType(ext, typ)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
[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
|
||||
@@ -13,6 +12,7 @@ User=navidrome
|
||||
Group=navidrome
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/navidrome
|
||||
StateDirectory=navidrome
|
||||
WorkingDirectory=/var/lib/navidrome
|
||||
TimeoutStopSec=20
|
||||
KillMode=process
|
||||
@@ -21,18 +21,25 @@ Restart=on-failure
|
||||
EnvironmentFile=-/etc/sysconfig/navidrome
|
||||
|
||||
# See https://www.freedesktop.org/software/systemd/man/systemd.exec.html
|
||||
CapabilityBoundingSet=
|
||||
DevicePolicy=closed
|
||||
NoNewPrivileges=yes
|
||||
LockPersonality=yes
|
||||
PrivateTmp=yes
|
||||
PrivateUsers=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectClock=yes
|
||||
ProtectHostname=yes
|
||||
ProtectKernelLogs=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
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallFilter=~@privileged @resources
|
||||
SystemCallArchitectures=native
|
||||
UMask=0066
|
||||
|
||||
# You can uncomment the following line if you're not using the jukebox This
|
||||
# will prevent navidrome from accessing any real (physical) devices
|
||||
|
||||
161
core/agents/agents.go
Normal file
161
core/agents/agents.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
)
|
||||
|
||||
type Agents struct {
|
||||
ds model.DataStore
|
||||
agents []Interface
|
||||
}
|
||||
|
||||
func New(ds model.DataStore) *Agents {
|
||||
order := strings.Split(conf.Server.Agents, ",")
|
||||
order = append(order, PlaceholderAgentName)
|
||||
var res []Interface
|
||||
for _, name := range order {
|
||||
init, ok := Map[name]
|
||||
if !ok {
|
||||
log.Error("Agent not available. Check configuration", "name", name)
|
||||
continue
|
||||
}
|
||||
|
||||
res = append(res, init(ds))
|
||||
}
|
||||
|
||||
return &Agents{ds: ds, agents: res}
|
||||
}
|
||||
|
||||
func (a *Agents) AgentName() string {
|
||||
return "agents"
|
||||
}
|
||||
|
||||
func (a *Agents) GetMBID(ctx context.Context, id string, name string) (string, error) {
|
||||
start := time.Now()
|
||||
for _, ag := range a.agents {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
break
|
||||
}
|
||||
agent, ok := ag.(ArtistMBIDRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
mbid, err := agent.GetMBID(ctx, id, name)
|
||||
if mbid != "" && err == nil {
|
||||
log.Debug(ctx, "Got MBID", "agent", ag.AgentName(), "artist", name, "mbid", mbid, "elapsed", time.Since(start))
|
||||
return mbid, err
|
||||
}
|
||||
}
|
||||
return "", ErrNotFound
|
||||
}
|
||||
|
||||
func (a *Agents) GetURL(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
start := time.Now()
|
||||
for _, ag := range a.agents {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
break
|
||||
}
|
||||
agent, ok := ag.(ArtistURLRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
url, err := agent.GetURL(ctx, id, name, mbid)
|
||||
if url != "" && err == nil {
|
||||
log.Debug(ctx, "Got External Url", "agent", ag.AgentName(), "artist", name, "url", url, "elapsed", time.Since(start))
|
||||
return url, err
|
||||
}
|
||||
}
|
||||
return "", ErrNotFound
|
||||
}
|
||||
|
||||
func (a *Agents) GetBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
start := time.Now()
|
||||
for _, ag := range a.agents {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
break
|
||||
}
|
||||
agent, ok := ag.(ArtistBiographyRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
bio, err := agent.GetBiography(ctx, id, name, mbid)
|
||||
if bio != "" && err == nil {
|
||||
log.Debug(ctx, "Got Biography", "agent", ag.AgentName(), "artist", name, "len", len(bio), "elapsed", time.Since(start))
|
||||
return bio, err
|
||||
}
|
||||
}
|
||||
return "", ErrNotFound
|
||||
}
|
||||
|
||||
func (a *Agents) GetSimilar(ctx context.Context, id, name, mbid string, limit int) ([]Artist, error) {
|
||||
start := time.Now()
|
||||
for _, ag := range a.agents {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
break
|
||||
}
|
||||
agent, ok := ag.(ArtistSimilarRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
similar, err := agent.GetSimilar(ctx, id, name, mbid, limit)
|
||||
if len(similar) > 0 && err == nil {
|
||||
log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start))
|
||||
return similar, err
|
||||
}
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
func (a *Agents) GetImages(ctx context.Context, id, name, mbid string) ([]ArtistImage, error) {
|
||||
start := time.Now()
|
||||
for _, ag := range a.agents {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
break
|
||||
}
|
||||
agent, ok := ag.(ArtistImageRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
images, err := agent.GetImages(ctx, id, name, mbid)
|
||||
if len(images) > 0 && err == nil {
|
||||
log.Debug(ctx, "Got Images", "agent", ag.AgentName(), "artist", name, "images", images, "elapsed", time.Since(start))
|
||||
return images, err
|
||||
}
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
func (a *Agents) GetTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) {
|
||||
start := time.Now()
|
||||
for _, ag := range a.agents {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
break
|
||||
}
|
||||
agent, ok := ag.(ArtistTopSongsRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
songs, err := agent.GetTopSongs(ctx, id, artistName, mbid, count)
|
||||
if len(songs) > 0 && err == nil {
|
||||
log.Debug(ctx, "Got Top Songs", "agent", ag.AgentName(), "artist", artistName, "songs", songs, "elapsed", time.Since(start))
|
||||
return songs, err
|
||||
}
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
var _ Interface = (*Agents)(nil)
|
||||
var _ ArtistMBIDRetriever = (*Agents)(nil)
|
||||
var _ ArtistURLRetriever = (*Agents)(nil)
|
||||
var _ ArtistBiographyRetriever = (*Agents)(nil)
|
||||
var _ ArtistSimilarRetriever = (*Agents)(nil)
|
||||
var _ ArtistImageRetriever = (*Agents)(nil)
|
||||
var _ ArtistTopSongsRetriever = (*Agents)(nil)
|
||||
249
core/agents/agents_test.go
Normal file
249
core/agents/agents_test.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Agents", func() {
|
||||
var ctx context.Context
|
||||
var cancel context.CancelFunc
|
||||
var ds model.DataStore
|
||||
BeforeEach(func() {
|
||||
ctx, cancel = context.WithCancel(context.Background())
|
||||
ds = &tests.MockDataStore{}
|
||||
})
|
||||
|
||||
Describe("Placeholder", func() {
|
||||
var ag *Agents
|
||||
BeforeEach(func() {
|
||||
conf.Server.Agents = ""
|
||||
ag = New(ds)
|
||||
})
|
||||
|
||||
It("calls the placeholder GetBiography", func() {
|
||||
Expect(ag.GetBiography(ctx, "123", "John Doe", "mb123")).To(Equal(placeholderBiography))
|
||||
})
|
||||
It("calls the placeholder GetImages", func() {
|
||||
images, err := ag.GetImages(ctx, "123", "John Doe", "mb123")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(images).To(HaveLen(3))
|
||||
for _, i := range images {
|
||||
Expect(i.URL).To(BeElementOf(placeholderArtistImageSmallUrl, placeholderArtistImageMediumUrl, placeholderArtistImageLargeUrl))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Agents", func() {
|
||||
var ag *Agents
|
||||
var mock *mockAgent
|
||||
BeforeEach(func() {
|
||||
mock = &mockAgent{}
|
||||
Register("fake", func(ds model.DataStore) Interface {
|
||||
return mock
|
||||
})
|
||||
Register("empty", func(ds model.DataStore) Interface {
|
||||
return struct {
|
||||
Interface
|
||||
}{}
|
||||
})
|
||||
conf.Server.Agents = "empty,fake"
|
||||
ag = New(ds)
|
||||
Expect(ag.AgentName()).To(Equal("agents"))
|
||||
})
|
||||
|
||||
Describe("GetMBID", func() {
|
||||
It("returns on first match", func() {
|
||||
Expect(ag.GetMBID(ctx, "123", "test")).To(Equal("mbid"))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test"))
|
||||
})
|
||||
It("skips the agent if it returns an error", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetMBID(ctx, "123", "test")
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test"))
|
||||
})
|
||||
It("interrupts if the context is canceled", func() {
|
||||
cancel()
|
||||
_, err := ag.GetMBID(ctx, "123", "test")
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetURL", func() {
|
||||
It("returns on first match", func() {
|
||||
Expect(ag.GetURL(ctx, "123", "test", "mb123")).To(Equal("url"))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
|
||||
})
|
||||
It("skips the agent if it returns an error", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetURL(ctx, "123", "test", "mb123")
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
|
||||
})
|
||||
It("interrupts if the context is canceled", func() {
|
||||
cancel()
|
||||
_, err := ag.GetURL(ctx, "123", "test", "mb123")
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetBiography", func() {
|
||||
It("returns on first match", func() {
|
||||
Expect(ag.GetBiography(ctx, "123", "test", "mb123")).To(Equal("bio"))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
|
||||
})
|
||||
It("skips the agent if it returns an error", func() {
|
||||
mock.Err = errors.New("error")
|
||||
Expect(ag.GetBiography(ctx, "123", "test", "mb123")).To(Equal(placeholderBiography))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
|
||||
})
|
||||
It("interrupts if the context is canceled", func() {
|
||||
cancel()
|
||||
_, err := ag.GetBiography(ctx, "123", "test", "mb123")
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetImages", func() {
|
||||
It("returns on first match", func() {
|
||||
Expect(ag.GetImages(ctx, "123", "test", "mb123")).To(Equal([]ArtistImage{{
|
||||
URL: "imageUrl",
|
||||
Size: 100,
|
||||
}}))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
|
||||
})
|
||||
It("skips the agent if it returns an error", func() {
|
||||
mock.Err = errors.New("error")
|
||||
Expect(ag.GetImages(ctx, "123", "test", "mb123")).To(HaveLen(3))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
|
||||
})
|
||||
It("interrupts if the context is canceled", func() {
|
||||
cancel()
|
||||
_, err := ag.GetImages(ctx, "123", "test", "mb123")
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetSimilar", func() {
|
||||
It("returns on first match", func() {
|
||||
Expect(ag.GetSimilar(ctx, "123", "test", "mb123", 1)).To(Equal([]Artist{{
|
||||
Name: "Joe Dohn",
|
||||
MBID: "mbid321",
|
||||
}}))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123", 1))
|
||||
})
|
||||
It("skips the agent if it returns an error", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetSimilar(ctx, "123", "test", "mb123", 1)
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123", 1))
|
||||
})
|
||||
It("interrupts if the context is canceled", func() {
|
||||
cancel()
|
||||
_, err := ag.GetSimilar(ctx, "123", "test", "mb123", 1)
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetTopSongs", func() {
|
||||
It("returns on first match", func() {
|
||||
Expect(ag.GetTopSongs(ctx, "123", "test", "mb123", 2)).To(Equal([]Song{{
|
||||
Name: "A Song",
|
||||
MBID: "mbid444",
|
||||
}}))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123", 2))
|
||||
})
|
||||
It("skips the agent if it returns an error", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetTopSongs(ctx, "123", "test", "mb123", 2)
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123", 2))
|
||||
})
|
||||
It("interrupts if the context is canceled", func() {
|
||||
cancel()
|
||||
_, err := ag.GetTopSongs(ctx, "123", "test", "mb123", 2)
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockAgent struct {
|
||||
Args []interface{}
|
||||
Err error
|
||||
}
|
||||
|
||||
func (a *mockAgent) AgentName() string {
|
||||
return "fake"
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetMBID(ctx context.Context, id string, name string) (string, error) {
|
||||
a.Args = []interface{}{id, name}
|
||||
if a.Err != nil {
|
||||
return "", a.Err
|
||||
}
|
||||
return "mbid", nil
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetURL(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
a.Args = []interface{}{id, name, mbid}
|
||||
if a.Err != nil {
|
||||
return "", a.Err
|
||||
}
|
||||
return "url", nil
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
a.Args = []interface{}{id, name, mbid}
|
||||
if a.Err != nil {
|
||||
return "", a.Err
|
||||
}
|
||||
return "bio", nil
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetImages(ctx context.Context, id, name, mbid string) ([]ArtistImage, error) {
|
||||
a.Args = []interface{}{id, name, mbid}
|
||||
if a.Err != nil {
|
||||
return nil, a.Err
|
||||
}
|
||||
return []ArtistImage{{
|
||||
URL: "imageUrl",
|
||||
Size: 100,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetSimilar(ctx context.Context, id, name, mbid string, limit int) ([]Artist, error) {
|
||||
a.Args = []interface{}{id, name, mbid, limit}
|
||||
if a.Err != nil {
|
||||
return nil, a.Err
|
||||
}
|
||||
return []Artist{{
|
||||
Name: "Joe Dohn",
|
||||
MBID: "mbid321",
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) {
|
||||
a.Args = []interface{}{id, artistName, mbid, count}
|
||||
if a.Err != nil {
|
||||
return nil, a.Err
|
||||
}
|
||||
return []Song{{
|
||||
Name: "A Song",
|
||||
MBID: "mbid444",
|
||||
}}, nil
|
||||
}
|
||||
@@ -3,9 +3,11 @@ package agents
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type Constructor func(ctx context.Context) Interface
|
||||
type Constructor func(ds model.DataStore) Interface
|
||||
|
||||
type Interface interface {
|
||||
AgentName() string
|
||||
@@ -31,27 +33,27 @@ var (
|
||||
)
|
||||
|
||||
type ArtistMBIDRetriever interface {
|
||||
GetMBID(id string, name string) (string, error)
|
||||
GetMBID(ctx context.Context, id string, name string) (string, error)
|
||||
}
|
||||
|
||||
type ArtistURLRetriever interface {
|
||||
GetURL(id, name, mbid string) (string, error)
|
||||
GetURL(ctx context.Context, id, name, mbid string) (string, error)
|
||||
}
|
||||
|
||||
type ArtistBiographyRetriever interface {
|
||||
GetBiography(id, name, mbid string) (string, error)
|
||||
GetBiography(ctx context.Context, id, name, mbid string) (string, error)
|
||||
}
|
||||
|
||||
type ArtistSimilarRetriever interface {
|
||||
GetSimilar(id, name, mbid string, limit int) ([]Artist, error)
|
||||
GetSimilar(ctx context.Context, id, name, mbid string, limit int) ([]Artist, error)
|
||||
}
|
||||
|
||||
type ArtistImageRetriever interface {
|
||||
GetImages(id, name, mbid string) ([]ArtistImage, error)
|
||||
GetImages(ctx context.Context, id, name, mbid string) ([]ArtistImage, error)
|
||||
}
|
||||
|
||||
type ArtistTopSongsRetriever interface {
|
||||
GetTopSongs(id, artistName, mbid string, count int) ([]Song, error)
|
||||
GetTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error)
|
||||
}
|
||||
|
||||
var Map map[string]Constructor
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils/lastfm"
|
||||
)
|
||||
|
||||
const lastFMAgentName = "lastfm"
|
||||
|
||||
type lastfmAgent struct {
|
||||
ctx context.Context
|
||||
apiKey string
|
||||
lang string
|
||||
client *lastfm.Client
|
||||
}
|
||||
|
||||
func lastFMConstructor(ctx context.Context) Interface {
|
||||
l := &lastfmAgent{
|
||||
ctx: ctx,
|
||||
apiKey: conf.Server.LastFM.ApiKey,
|
||||
lang: conf.Server.LastFM.Language,
|
||||
}
|
||||
hc := NewCachedHTTPClient(http.DefaultClient, consts.DefaultCachedHttpClientTTL)
|
||||
l.client = lastfm.NewClient(l.apiKey, l.lang, hc)
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) AgentName() string {
|
||||
return lastFMAgentName
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) GetMBID(id string, name string) (string, error) {
|
||||
a, err := l.callArtistGetInfo(name, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if a.MBID == "" {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return a.MBID, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) GetURL(id, name, mbid string) (string, error) {
|
||||
a, err := l.callArtistGetInfo(name, mbid)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if a.URL == "" {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return a.URL, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) GetBiography(id, name, mbid string) (string, error) {
|
||||
a, err := l.callArtistGetInfo(name, mbid)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if a.Bio.Summary == "" {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return a.Bio.Summary, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) GetSimilar(id, name, mbid string, limit int) ([]Artist, error) {
|
||||
resp, err := l.callArtistGetSimilar(name, mbid, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp) == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
var res []Artist
|
||||
for _, a := range resp {
|
||||
res = append(res, Artist{
|
||||
Name: a.Name,
|
||||
MBID: a.MBID,
|
||||
})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) GetTopSongs(id, artistName, mbid string, count int) ([]Song, error) {
|
||||
resp, err := l.callArtistGetTopTracks(artistName, mbid, count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp) == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
var res []Song
|
||||
for _, t := range resp {
|
||||
res = append(res, Song{
|
||||
Name: t.Name,
|
||||
MBID: t.MBID,
|
||||
})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) callArtistGetInfo(name string, mbid string) (*lastfm.Artist, error) {
|
||||
a, err := l.client.ArtistGetInfo(l.ctx, name, mbid)
|
||||
if err != nil {
|
||||
log.Error(l.ctx, "Error calling LastFM/artist.getInfo", "artist", name, "mbid", mbid, err)
|
||||
return nil, err
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) callArtistGetSimilar(name string, mbid string, limit int) ([]lastfm.Artist, error) {
|
||||
s, err := l.client.ArtistGetSimilar(l.ctx, name, mbid, limit)
|
||||
if err != nil {
|
||||
log.Error(l.ctx, "Error calling LastFM/artist.getSimilar", "artist", name, "mbid", mbid, err)
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) callArtistGetTopTracks(artistName, mbid string, count int) ([]lastfm.Track, error) {
|
||||
t, err := l.client.ArtistGetTopTracks(l.ctx, artistName, mbid, count)
|
||||
if err != nil {
|
||||
log.Error(l.ctx, "Error calling LastFM/artist.getTopTracks", "artist", artistName, "mbid", mbid, err)
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
if conf.Server.LastFM.ApiKey != "" {
|
||||
log.Info("Last.FM integration is ENABLED")
|
||||
Register(lastFMAgentName, lastFMConstructor)
|
||||
}
|
||||
})
|
||||
}
|
||||
233
core/agents/lastfm/agent.go
Normal file
233
core/agents/lastfm/agent.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package lastfm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
lastFMAgentName = "lastfm"
|
||||
)
|
||||
|
||||
type lastfmAgent struct {
|
||||
ds model.DataStore
|
||||
sessionKeys *sessionKeys
|
||||
apiKey string
|
||||
secret string
|
||||
lang string
|
||||
client *Client
|
||||
}
|
||||
|
||||
func lastFMConstructor(ds model.DataStore) *lastfmAgent {
|
||||
l := &lastfmAgent{
|
||||
ds: ds,
|
||||
lang: conf.Server.LastFM.Language,
|
||||
apiKey: conf.Server.LastFM.ApiKey,
|
||||
secret: conf.Server.LastFM.Secret,
|
||||
sessionKeys: &sessionKeys{ds: ds},
|
||||
}
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
chc := utils.NewCachedHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
l.client = NewClient(l.apiKey, l.secret, l.lang, chc)
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) AgentName() string {
|
||||
return lastFMAgentName
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) GetMBID(ctx context.Context, id string, name string) (string, error) {
|
||||
a, err := l.callArtistGetInfo(ctx, name, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if a.MBID == "" {
|
||||
return "", agents.ErrNotFound
|
||||
}
|
||||
return a.MBID, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) GetURL(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
a, err := l.callArtistGetInfo(ctx, name, mbid)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if a.URL == "" {
|
||||
return "", agents.ErrNotFound
|
||||
}
|
||||
return a.URL, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) GetBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
a, err := l.callArtistGetInfo(ctx, name, mbid)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if a.Bio.Summary == "" {
|
||||
return "", agents.ErrNotFound
|
||||
}
|
||||
return a.Bio.Summary, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) GetSimilar(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
|
||||
resp, err := l.callArtistGetSimilar(ctx, name, mbid, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp) == 0 {
|
||||
return nil, agents.ErrNotFound
|
||||
}
|
||||
var res []agents.Artist
|
||||
for _, a := range resp {
|
||||
res = append(res, agents.Artist{
|
||||
Name: a.Name,
|
||||
MBID: a.MBID,
|
||||
})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) GetTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
|
||||
resp, err := l.callArtistGetTopTracks(ctx, artistName, mbid, count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp) == 0 {
|
||||
return nil, agents.ErrNotFound
|
||||
}
|
||||
var res []agents.Song
|
||||
for _, t := range resp {
|
||||
res = append(res, agents.Song{
|
||||
Name: t.Name,
|
||||
MBID: t.MBID,
|
||||
})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string, mbid string) (*Artist, error) {
|
||||
a, err := l.client.ArtistGetInfo(ctx, name, mbid)
|
||||
lfErr, isLastFMError := err.(*lastFMError)
|
||||
if mbid != "" && ((err == nil && a.Name == "[unknown]") || (isLastFMError && lfErr.Code == 6)) {
|
||||
log.Warn(ctx, "LastFM/artist.getInfo could not find artist by mbid, trying again", "artist", name, "mbid", mbid)
|
||||
return l.callArtistGetInfo(ctx, name, "")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error calling LastFM/artist.getInfo", "artist", name, "mbid", mbid, err)
|
||||
return nil, err
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) callArtistGetSimilar(ctx context.Context, name string, mbid string, limit int) ([]Artist, error) {
|
||||
s, err := l.client.ArtistGetSimilar(ctx, name, mbid, limit)
|
||||
lfErr, isLastFMError := err.(*lastFMError)
|
||||
if mbid != "" && ((err == nil && s.Attr.Artist == "[unknown]") || (isLastFMError && lfErr.Code == 6)) {
|
||||
log.Warn(ctx, "LastFM/artist.getSimilar could not find artist by mbid, trying again", "artist", name, "mbid", mbid)
|
||||
return l.callArtistGetSimilar(ctx, name, "", limit)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error calling LastFM/artist.getSimilar", "artist", name, "mbid", mbid, err)
|
||||
return nil, err
|
||||
}
|
||||
return s.Artists, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName, mbid string, count int) ([]Track, error) {
|
||||
t, err := l.client.ArtistGetTopTracks(ctx, artistName, mbid, count)
|
||||
lfErr, isLastFMError := err.(*lastFMError)
|
||||
if mbid != "" && ((err == nil && t.Attr.Artist == "[unknown]") || (isLastFMError && lfErr.Code == 6)) {
|
||||
log.Warn(ctx, "LastFM/artist.getTopTracks could not find artist by mbid, trying again", "artist", artistName, "mbid", mbid)
|
||||
return l.callArtistGetTopTracks(ctx, artistName, "", count)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error calling LastFM/artist.getTopTracks", "artist", artistName, "mbid", mbid, err)
|
||||
return nil, err
|
||||
}
|
||||
return t.Track, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error {
|
||||
sk, err := l.sessionKeys.get(ctx, userId)
|
||||
if err != nil || sk == "" {
|
||||
return scrobbler.ErrNotAuthorized
|
||||
}
|
||||
|
||||
err = l.client.UpdateNowPlaying(ctx, sk, ScrobbleInfo{
|
||||
artist: track.Artist,
|
||||
track: track.Title,
|
||||
album: track.Album,
|
||||
trackNumber: track.TrackNumber,
|
||||
mbid: track.MbzTrackID,
|
||||
duration: int(track.Duration),
|
||||
albumArtist: track.AlbumArtist,
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Last.fm client.updateNowPlaying returned error", "track", track.Title, err)
|
||||
return scrobbler.ErrUnrecoverable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error {
|
||||
sk, err := l.sessionKeys.get(ctx, userId)
|
||||
if err != nil || sk == "" {
|
||||
return scrobbler.ErrNotAuthorized
|
||||
}
|
||||
|
||||
if s.Duration <= 30 {
|
||||
log.Debug(ctx, "Skipping Last.fm scrobble for short song", "track", s.Title, "duration", s.Duration)
|
||||
return nil
|
||||
}
|
||||
err = l.client.Scrobble(ctx, sk, ScrobbleInfo{
|
||||
artist: s.Artist,
|
||||
track: s.Title,
|
||||
album: s.Album,
|
||||
trackNumber: s.TrackNumber,
|
||||
mbid: s.MbzTrackID,
|
||||
duration: int(s.Duration),
|
||||
albumArtist: s.AlbumArtist,
|
||||
timestamp: s.TimeStamp,
|
||||
})
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
lfErr, isLastFMError := err.(*lastFMError)
|
||||
if !isLastFMError {
|
||||
log.Warn(ctx, "Last.fm client.scrobble returned error", "track", s.Title, err)
|
||||
return scrobbler.ErrRetryLater
|
||||
}
|
||||
if lfErr.Code == 11 || lfErr.Code == 16 {
|
||||
return scrobbler.ErrRetryLater
|
||||
}
|
||||
return scrobbler.ErrUnrecoverable
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
sk, err := l.sessionKeys.get(ctx, userId)
|
||||
return err == nil && sk != ""
|
||||
}
|
||||
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
if conf.Server.LastFM.Enabled {
|
||||
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
|
||||
return lastFMConstructor(ds)
|
||||
})
|
||||
scrobbler.Register(lastFMAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
|
||||
return lastFMConstructor(ds)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
353
core/agents/lastfm/agent_test.go
Normal file
353
core/agents/lastfm/agent_test.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package lastfm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
const (
|
||||
lastfmError3 = `{"error":3,"message":"Invalid Method - No method with that name in this package","links":[]}`
|
||||
lastfmError6 = `{"error":6,"message":"The artist you supplied could not be found","links":[]}`
|
||||
)
|
||||
|
||||
var _ = Describe("lastfmAgent", func() {
|
||||
var ds model.DataStore
|
||||
var ctx context.Context
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
ctx = context.Background()
|
||||
})
|
||||
Describe("lastFMConstructor", func() {
|
||||
It("uses configured api key and language", func() {
|
||||
conf.Server.LastFM.ApiKey = "123"
|
||||
conf.Server.LastFM.Secret = "secret"
|
||||
conf.Server.LastFM.Language = "pt"
|
||||
agent := lastFMConstructor(ds)
|
||||
Expect(agent.apiKey).To(Equal("123"))
|
||||
Expect(agent.secret).To(Equal("secret"))
|
||||
Expect(agent.lang).To(Equal("pt"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetBiography", func() {
|
||||
var agent *lastfmAgent
|
||||
var httpClient *tests.FakeHttpClient
|
||||
BeforeEach(func() {
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
client := NewClient("API_KEY", "SECRET", "pt", httpClient)
|
||||
agent = lastFMConstructor(ds)
|
||||
agent.client = client
|
||||
})
|
||||
|
||||
It("returns the biography", func() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
Expect(agent.GetBiography(ctx, "123", "U2", "mbid-1234")).To(Equal("U2 é uma das mais importantes bandas de rock de todos os tempos. Formada em 1976 em Dublin, composta por Bono (vocalista e guitarrista), The Edge (guitarrista, pianista e backing vocal), Adam Clayton (baixista), Larry Mullen, Jr. (baterista e percussionista).\n\nDesde a década de 80, U2 é uma das bandas mais populares no mundo. Seus shows são únicos e um verdadeiro festival de efeitos especiais, além de serem um dos que mais arrecadam anualmente. <a href=\"https://www.last.fm/music/U2\">Read more on Last.fm</a>"))
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
|
||||
})
|
||||
|
||||
It("returns an error if Last.FM call fails", func() {
|
||||
httpClient.Err = errors.New("error")
|
||||
_, err := agent.GetBiography(ctx, "123", "U2", "mbid-1234")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
|
||||
})
|
||||
|
||||
It("returns an error if Last.FM call returns an error", func() {
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
|
||||
_, err := agent.GetBiography(ctx, "123", "U2", "mbid-1234")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
|
||||
})
|
||||
|
||||
It("returns an error if Last.FM call returns an error 6 and mbid is empty", func() {
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
|
||||
_, err := agent.GetBiography(ctx, "123", "U2", "")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
})
|
||||
|
||||
Context("MBID non existent in Last.FM", func() {
|
||||
It("calls again when the response is artist == [unknown]", func() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.unknown.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
_, _ = agent.GetBiography(ctx, "123", "U2", "mbid-1234")
|
||||
Expect(httpClient.RequestCount).To(Equal(2))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
||||
})
|
||||
It("calls again when last.fm returns an error 6", func() {
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
|
||||
_, _ = agent.GetBiography(ctx, "123", "U2", "mbid-1234")
|
||||
Expect(httpClient.RequestCount).To(Equal(2))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetSimilar", func() {
|
||||
var agent *lastfmAgent
|
||||
var httpClient *tests.FakeHttpClient
|
||||
BeforeEach(func() {
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
client := NewClient("API_KEY", "SECRET", "pt", httpClient)
|
||||
agent = lastFMConstructor(ds)
|
||||
agent.client = client
|
||||
})
|
||||
|
||||
It("returns similar artists", func() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
Expect(agent.GetSimilar(ctx, "123", "U2", "mbid-1234", 2)).To(Equal([]agents.Artist{
|
||||
{Name: "Passengers", MBID: "e110c11f-1c94-4471-a350-c38f46b29389"},
|
||||
{Name: "INXS", MBID: "481bf5f9-2e7c-4c44-b08a-05b32bc7c00d"},
|
||||
}))
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
|
||||
})
|
||||
|
||||
It("returns an error if Last.FM call fails", func() {
|
||||
httpClient.Err = errors.New("error")
|
||||
_, err := agent.GetSimilar(ctx, "123", "U2", "mbid-1234", 2)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
|
||||
})
|
||||
|
||||
It("returns an error if Last.FM call returns an error", func() {
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
|
||||
_, err := agent.GetSimilar(ctx, "123", "U2", "mbid-1234", 2)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
|
||||
})
|
||||
|
||||
It("returns an error if Last.FM call returns an error 6 and mbid is empty", func() {
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
|
||||
_, err := agent.GetSimilar(ctx, "123", "U2", "", 2)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
})
|
||||
|
||||
Context("MBID non existent in Last.FM", func() {
|
||||
It("calls again when the response is artist == [unknown]", func() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.unknown.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
_, _ = agent.GetSimilar(ctx, "123", "U2", "mbid-1234", 2)
|
||||
Expect(httpClient.RequestCount).To(Equal(2))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
||||
})
|
||||
It("calls again when last.fm returns an error 6", func() {
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
|
||||
_, _ = agent.GetSimilar(ctx, "123", "U2", "mbid-1234", 2)
|
||||
Expect(httpClient.RequestCount).To(Equal(2))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetTopSongs", func() {
|
||||
var agent *lastfmAgent
|
||||
var httpClient *tests.FakeHttpClient
|
||||
BeforeEach(func() {
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
client := NewClient("API_KEY", "SECRET", "pt", httpClient)
|
||||
agent = lastFMConstructor(ds)
|
||||
agent.client = client
|
||||
})
|
||||
|
||||
It("returns top songs", func() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
Expect(agent.GetTopSongs(ctx, "123", "U2", "mbid-1234", 2)).To(Equal([]agents.Song{
|
||||
{Name: "Beautiful Day", MBID: "f7f264d0-a89b-4682-9cd7-a4e7c37637af"},
|
||||
{Name: "With or Without You", MBID: "6b9a509f-6907-4a6e-9345-2f12da09ba4b"},
|
||||
}))
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
|
||||
})
|
||||
|
||||
It("returns an error if Last.FM call fails", func() {
|
||||
httpClient.Err = errors.New("error")
|
||||
_, err := agent.GetTopSongs(ctx, "123", "U2", "mbid-1234", 2)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
|
||||
})
|
||||
|
||||
It("returns an error if Last.FM call returns an error", func() {
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
|
||||
_, err := agent.GetTopSongs(ctx, "123", "U2", "mbid-1234", 2)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
|
||||
})
|
||||
|
||||
It("returns an error if Last.FM call returns an error 6 and mbid is empty", func() {
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
|
||||
_, err := agent.GetTopSongs(ctx, "123", "U2", "", 2)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
})
|
||||
|
||||
Context("MBID non existent in Last.FM", func() {
|
||||
It("calls again when the response is artist == [unknown]", func() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.unknown.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
_, _ = agent.GetTopSongs(ctx, "123", "U2", "mbid-1234", 2)
|
||||
Expect(httpClient.RequestCount).To(Equal(2))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
||||
})
|
||||
It("calls again when last.fm returns an error 6", func() {
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
|
||||
_, _ = agent.GetTopSongs(ctx, "123", "U2", "mbid-1234", 2)
|
||||
Expect(httpClient.RequestCount).To(Equal(2))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Scrobbling", func() {
|
||||
var agent *lastfmAgent
|
||||
var httpClient *tests.FakeHttpClient
|
||||
var track *model.MediaFile
|
||||
BeforeEach(func() {
|
||||
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
client := NewClient("API_KEY", "SECRET", "en", httpClient)
|
||||
agent = lastFMConstructor(ds)
|
||||
agent.client = client
|
||||
track = &model.MediaFile{
|
||||
ID: "123",
|
||||
Title: "Track Title",
|
||||
Album: "Track Album",
|
||||
Artist: "Track Artist",
|
||||
AlbumArtist: "Track AlbumArtist",
|
||||
TrackNumber: 1,
|
||||
Duration: 180,
|
||||
MbzTrackID: "mbz-123",
|
||||
}
|
||||
})
|
||||
|
||||
Describe("NowPlaying", func() {
|
||||
It("calls Last.fm with correct params", func() {
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
|
||||
|
||||
err := agent.NowPlaying(ctx, "user-1", track)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
|
||||
sentParams := httpClient.SavedRequest.URL.Query()
|
||||
Expect(sentParams.Get("method")).To(Equal("track.updateNowPlaying"))
|
||||
Expect(sentParams.Get("sk")).To(Equal("SK-1"))
|
||||
Expect(sentParams.Get("track")).To(Equal(track.Title))
|
||||
Expect(sentParams.Get("album")).To(Equal(track.Album))
|
||||
Expect(sentParams.Get("artist")).To(Equal(track.Artist))
|
||||
Expect(sentParams.Get("albumArtist")).To(Equal(track.AlbumArtist))
|
||||
Expect(sentParams.Get("trackNumber")).To(Equal(strconv.Itoa(track.TrackNumber)))
|
||||
Expect(sentParams.Get("duration")).To(Equal(strconv.FormatFloat(float64(track.Duration), 'G', -1, 32)))
|
||||
Expect(sentParams.Get("mbid")).To(Equal(track.MbzTrackID))
|
||||
})
|
||||
|
||||
It("returns ErrNotAuthorized if user is not linked", func() {
|
||||
err := agent.NowPlaying(ctx, "user-2", track)
|
||||
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Scrobble", func() {
|
||||
It("calls Last.fm with correct params", func() {
|
||||
ts := time.Now()
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
|
||||
|
||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: ts})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
|
||||
sentParams := httpClient.SavedRequest.URL.Query()
|
||||
Expect(sentParams.Get("method")).To(Equal("track.scrobble"))
|
||||
Expect(sentParams.Get("sk")).To(Equal("SK-1"))
|
||||
Expect(sentParams.Get("track")).To(Equal(track.Title))
|
||||
Expect(sentParams.Get("album")).To(Equal(track.Album))
|
||||
Expect(sentParams.Get("artist")).To(Equal(track.Artist))
|
||||
Expect(sentParams.Get("albumArtist")).To(Equal(track.AlbumArtist))
|
||||
Expect(sentParams.Get("trackNumber")).To(Equal(strconv.Itoa(track.TrackNumber)))
|
||||
Expect(sentParams.Get("duration")).To(Equal(strconv.FormatFloat(float64(track.Duration), 'G', -1, 32)))
|
||||
Expect(sentParams.Get("mbid")).To(Equal(track.MbzTrackID))
|
||||
Expect(sentParams.Get("timestamp")).To(Equal(strconv.FormatInt(ts.Unix(), 10)))
|
||||
})
|
||||
|
||||
It("skips songs with less than 31 seconds", func() {
|
||||
track.Duration = 29
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
|
||||
|
||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(httpClient.SavedRequest).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns ErrNotAuthorized if user is not linked", func() {
|
||||
err := agent.Scrobble(ctx, "user-2", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
||||
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("returns ErrRetryLater on error 11", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":11,"message":"Service Offline - This service is temporarily offline. Try again later."}`)),
|
||||
StatusCode: 400,
|
||||
}
|
||||
|
||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
||||
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
|
||||
})
|
||||
|
||||
It("returns ErrRetryLater on error 16", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":16,"message":"There was a temporary error processing your request. Please try again"}`)),
|
||||
StatusCode: 400,
|
||||
}
|
||||
|
||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
||||
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
|
||||
})
|
||||
|
||||
It("returns ErrRetryLater on http errors", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`internal server error`)),
|
||||
StatusCode: 500,
|
||||
}
|
||||
|
||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
||||
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
|
||||
})
|
||||
|
||||
It("returns ErrUnrecoverable on other errors", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":8,"message":"Operation failed - Something else went wrong"}`)),
|
||||
StatusCode: 400,
|
||||
}
|
||||
|
||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
||||
Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
127
core/agents/lastfm/auth_router.go
Normal file
127
core/agents/lastfm/auth_router.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package lastfm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
)
|
||||
|
||||
//go:embed token_received.html
|
||||
var tokenReceivedPage []byte
|
||||
|
||||
type Router struct {
|
||||
http.Handler
|
||||
ds model.DataStore
|
||||
sessionKeys *sessionKeys
|
||||
client *Client
|
||||
apiKey string
|
||||
secret string
|
||||
}
|
||||
|
||||
func NewRouter(ds model.DataStore) *Router {
|
||||
r := &Router{
|
||||
ds: ds,
|
||||
apiKey: conf.Server.LastFM.ApiKey,
|
||||
secret: conf.Server.LastFM.Secret,
|
||||
sessionKeys: &sessionKeys{ds: ds},
|
||||
}
|
||||
r.Handler = r.routes()
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
r.client = NewClient(r.apiKey, r.secret, "en", hc)
|
||||
return r
|
||||
}
|
||||
|
||||
func (s *Router) routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.Authenticator(s.ds))
|
||||
r.Use(server.JWTRefresher)
|
||||
|
||||
r.Get("/link", s.getLinkStatus)
|
||||
r.Delete("/link", s.unlink)
|
||||
})
|
||||
|
||||
r.Get("/link/callback", s.callback)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
|
||||
resp := map[string]interface{}{"status": true}
|
||||
u, _ := request.UserFrom(r.Context())
|
||||
key, err := s.sessionKeys.get(r.Context(), u.ID)
|
||||
if err != nil && err != model.ErrNotFound {
|
||||
resp["error"] = err
|
||||
resp["status"] = false
|
||||
_ = rest.RespondWithJSON(w, http.StatusInternalServerError, resp)
|
||||
return
|
||||
}
|
||||
resp["status"] = key != ""
|
||||
_ = rest.RespondWithJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (s *Router) unlink(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := request.UserFrom(r.Context())
|
||||
err := s.sessionKeys.delete(r.Context(), u.ID)
|
||||
if err != nil {
|
||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
||||
} else {
|
||||
_ = rest.RespondWithJSON(w, http.StatusOK, map[string]string{})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
|
||||
token := utils.ParamString(r, "token")
|
||||
if token == "" {
|
||||
_ = rest.RespondWithError(w, http.StatusBadRequest, "token not received")
|
||||
return
|
||||
}
|
||||
uid := utils.ParamString(r, "uid")
|
||||
if uid == "" {
|
||||
_ = rest.RespondWithError(w, http.StatusBadRequest, "uid not received")
|
||||
return
|
||||
}
|
||||
|
||||
// Need to add user to context, as this is a non-authenticated endpoint, so it does not
|
||||
// automatically contain any user info
|
||||
ctx := request.WithUser(r.Context(), model.User{ID: uid})
|
||||
err := s.fetchSessionKey(ctx, uid, token)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("An error occurred while authorizing with Last.fm. \n\nRequest ID: " + middleware.GetReqID(ctx)))
|
||||
return
|
||||
}
|
||||
|
||||
http.ServeContent(w, r, "response", time.Now(), bytes.NewReader(tokenReceivedPage))
|
||||
}
|
||||
|
||||
func (s *Router) fetchSessionKey(ctx context.Context, uid, token string) error {
|
||||
sessionKey, err := s.client.GetSession(ctx, token)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Could not fetch LastFM session key", "userId", uid, "token", token,
|
||||
"requestId", middleware.GetReqID(ctx), err)
|
||||
return err
|
||||
}
|
||||
err = s.sessionKeys.put(ctx, uid, sessionKey)
|
||||
if err != nil {
|
||||
log.Error("Could not save LastFM session key", "userId", uid, "requestId", middleware.GetReqID(ctx), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
221
core/agents/lastfm/client.go
Normal file
221
core/agents/lastfm/client.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package lastfm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
apiBaseUrl = "https://ws.audioscrobbler.com/2.0/"
|
||||
)
|
||||
|
||||
type lastFMError struct {
|
||||
Code int
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *lastFMError) Error() string {
|
||||
return fmt.Sprintf("last.fm error(%d): %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
type httpDoer interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
func NewClient(apiKey string, secret string, lang string, hc httpDoer) *Client {
|
||||
return &Client{apiKey, secret, lang, hc}
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
apiKey string
|
||||
secret string
|
||||
lang string
|
||||
hc httpDoer
|
||||
}
|
||||
|
||||
func (c *Client) ArtistGetInfo(ctx context.Context, name string, mbid string) (*Artist, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "artist.getInfo")
|
||||
params.Add("artist", name)
|
||||
params.Add("mbid", mbid)
|
||||
params.Add("lang", c.lang)
|
||||
response, err := c.makeRequest(http.MethodGet, params, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &response.Artist, nil
|
||||
}
|
||||
|
||||
func (c *Client) ArtistGetSimilar(ctx context.Context, name string, mbid string, limit int) (*SimilarArtists, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "artist.getSimilar")
|
||||
params.Add("artist", name)
|
||||
params.Add("mbid", mbid)
|
||||
params.Add("limit", strconv.Itoa(limit))
|
||||
response, err := c.makeRequest(http.MethodGet, params, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &response.SimilarArtists, nil
|
||||
}
|
||||
|
||||
func (c *Client) ArtistGetTopTracks(ctx context.Context, name string, mbid string, limit int) (*TopTracks, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "artist.getTopTracks")
|
||||
params.Add("artist", name)
|
||||
params.Add("mbid", mbid)
|
||||
params.Add("limit", strconv.Itoa(limit))
|
||||
response, err := c.makeRequest(http.MethodGet, params, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &response.TopTracks, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetToken(ctx context.Context) (string, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "auth.getToken")
|
||||
c.sign(params)
|
||||
response, err := c.makeRequest(http.MethodGet, params, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return response.Token, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetSession(ctx context.Context, token string) (string, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "auth.getSession")
|
||||
params.Add("token", token)
|
||||
response, err := c.makeRequest(http.MethodGet, params, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return response.Session.Key, nil
|
||||
}
|
||||
|
||||
type ScrobbleInfo struct {
|
||||
artist string
|
||||
track string
|
||||
album string
|
||||
trackNumber int
|
||||
mbid string
|
||||
duration int
|
||||
albumArtist string
|
||||
timestamp time.Time
|
||||
}
|
||||
|
||||
func (c *Client) UpdateNowPlaying(ctx context.Context, sessionKey string, info ScrobbleInfo) error {
|
||||
params := url.Values{}
|
||||
params.Add("method", "track.updateNowPlaying")
|
||||
params.Add("artist", info.artist)
|
||||
params.Add("track", info.track)
|
||||
params.Add("album", info.album)
|
||||
params.Add("trackNumber", strconv.Itoa(info.trackNumber))
|
||||
params.Add("mbid", info.mbid)
|
||||
params.Add("duration", strconv.Itoa(info.duration))
|
||||
params.Add("albumArtist", info.albumArtist)
|
||||
params.Add("sk", sessionKey)
|
||||
resp, err := c.makeRequest(http.MethodPost, params, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.NowPlaying.IgnoredMessage.Code != "0" {
|
||||
log.Warn(ctx, "LastFM: NowPlaying was ignored", "code", resp.NowPlaying.IgnoredMessage.Code,
|
||||
"text", resp.NowPlaying.IgnoredMessage.Text)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Scrobble(ctx context.Context, sessionKey string, info ScrobbleInfo) error {
|
||||
params := url.Values{}
|
||||
params.Add("method", "track.scrobble")
|
||||
params.Add("timestamp", strconv.FormatInt(info.timestamp.Unix(), 10))
|
||||
params.Add("artist", info.artist)
|
||||
params.Add("track", info.track)
|
||||
params.Add("album", info.album)
|
||||
params.Add("trackNumber", strconv.Itoa(info.trackNumber))
|
||||
params.Add("mbid", info.mbid)
|
||||
params.Add("duration", strconv.Itoa(info.duration))
|
||||
params.Add("albumArtist", info.albumArtist)
|
||||
params.Add("sk", sessionKey)
|
||||
resp, err := c.makeRequest(http.MethodPost, params, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Scrobbles.Scrobble.IgnoredMessage.Code != "0" {
|
||||
log.Warn(ctx, "LastFM: Scrobble was ignored", "code", resp.Scrobbles.Scrobble.IgnoredMessage.Code,
|
||||
"text", resp.Scrobbles.Scrobble.IgnoredMessage.Text)
|
||||
}
|
||||
if resp.Scrobbles.Attr.Accepted != 1 {
|
||||
log.Warn(ctx, "LastFM: Scrobble was not accepted", "code", resp.Scrobbles.Scrobble.IgnoredMessage.Code,
|
||||
"text", resp.Scrobbles.Scrobble.IgnoredMessage.Text)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) makeRequest(method string, params url.Values, signed bool) (*Response, error) {
|
||||
params.Add("format", "json")
|
||||
params.Add("api_key", c.apiKey)
|
||||
|
||||
if signed {
|
||||
c.sign(params)
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest(method, apiBaseUrl, nil)
|
||||
req.URL.RawQuery = params.Encode()
|
||||
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
decoder := json.NewDecoder(resp.Body)
|
||||
|
||||
var response Response
|
||||
jsonErr := decoder.Decode(&response)
|
||||
if resp.StatusCode != 200 && jsonErr != nil {
|
||||
return nil, fmt.Errorf("last.fm http status: (%d)", resp.StatusCode)
|
||||
}
|
||||
if jsonErr != nil {
|
||||
return nil, jsonErr
|
||||
}
|
||||
if response.Error != 0 {
|
||||
return &response, &lastFMError{Code: response.Error, Message: response.Message}
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
func (c *Client) sign(params url.Values) {
|
||||
// the parameters must be in order before hashing
|
||||
keys := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
if utils.StringInSlice(k, []string{"format", "callback"}) {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
msg := strings.Builder{}
|
||||
for _, k := range keys {
|
||||
msg.WriteString(k)
|
||||
msg.WriteString(params[k][0])
|
||||
}
|
||||
msg.WriteString(c.secret)
|
||||
hash := md5.Sum([]byte(msg.String()))
|
||||
params.Add("api_sig", hex.EncodeToString(hash[:]))
|
||||
}
|
||||
161
core/agents/lastfm/client_test.go
Normal file
161
core/agents/lastfm/client_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package lastfm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Client", func() {
|
||||
var httpClient *tests.FakeHttpClient
|
||||
var client *Client
|
||||
|
||||
BeforeEach(func() {
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
client = NewClient("API_KEY", "SECRET", "pt", httpClient)
|
||||
})
|
||||
|
||||
Describe("ArtistGetInfo", func() {
|
||||
It("returns an artist for a successful response", func() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
|
||||
artist, err := client.ArtistGetInfo(context.Background(), "U2", "123")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(artist.Name).To(Equal("U2"))
|
||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&mbid=123&method=artist.getInfo"))
|
||||
})
|
||||
|
||||
It("fails if Last.FM returns an http status != 200", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`Internal Server Error`)),
|
||||
StatusCode: 500,
|
||||
}
|
||||
|
||||
_, err := client.ArtistGetInfo(context.Background(), "U2", "123")
|
||||
Expect(err).To(MatchError("last.fm http status: (500)"))
|
||||
})
|
||||
|
||||
It("fails if Last.FM returns an http status != 200", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":3,"message":"Invalid Method - No method with that name in this package"}`)),
|
||||
StatusCode: 400,
|
||||
}
|
||||
|
||||
_, err := client.ArtistGetInfo(context.Background(), "U2", "123")
|
||||
Expect(err).To(MatchError(&lastFMError{Code: 3, Message: "Invalid Method - No method with that name in this package"}))
|
||||
})
|
||||
|
||||
It("fails if Last.FM returns an error", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":6,"message":"The artist you supplied could not be found"}`)),
|
||||
StatusCode: 200,
|
||||
}
|
||||
|
||||
_, err := client.ArtistGetInfo(context.Background(), "U2", "123")
|
||||
Expect(err).To(MatchError(&lastFMError{Code: 6, Message: "The artist you supplied could not be found"}))
|
||||
})
|
||||
|
||||
It("fails if HttpClient.Do() returns error", func() {
|
||||
httpClient.Err = errors.New("generic error")
|
||||
|
||||
_, err := client.ArtistGetInfo(context.Background(), "U2", "123")
|
||||
Expect(err).To(MatchError("generic error"))
|
||||
})
|
||||
|
||||
It("fails if returned body is not a valid JSON", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`<xml>NOT_VALID_JSON</xml>`)),
|
||||
StatusCode: 200,
|
||||
}
|
||||
|
||||
_, err := client.ArtistGetInfo(context.Background(), "U2", "123")
|
||||
Expect(err).To(MatchError("invalid character '<' looking for beginning of value"))
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
Describe("ArtistGetSimilar", func() {
|
||||
It("returns an artist for a successful response", func() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
|
||||
similar, err := client.ArtistGetSimilar(context.Background(), "U2", "123", 2)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(len(similar.Artists)).To(Equal(2))
|
||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&mbid=123&method=artist.getSimilar"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ArtistGetTopTracks", func() {
|
||||
It("returns top tracks for a successful response", func() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
|
||||
top, err := client.ArtistGetTopTracks(context.Background(), "U2", "123", 2)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(len(top.Track)).To(Equal(2))
|
||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&mbid=123&method=artist.getTopTracks"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetToken", func() {
|
||||
It("returns a token when the request is successful", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"token":"TOKEN"}`)),
|
||||
StatusCode: 200,
|
||||
}
|
||||
|
||||
Expect(client.GetToken(context.Background())).To(Equal("TOKEN"))
|
||||
queryParams := httpClient.SavedRequest.URL.Query()
|
||||
Expect(queryParams.Get("method")).To(Equal("auth.getToken"))
|
||||
Expect(queryParams.Get("format")).To(Equal("json"))
|
||||
Expect(queryParams.Get("api_key")).To(Equal("API_KEY"))
|
||||
Expect(queryParams.Get("api_sig")).ToNot(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetSession", func() {
|
||||
It("returns a session key when the request is successful", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"session":{"name":"Navidrome","key":"SESSION_KEY","subscriber":0}}`)),
|
||||
StatusCode: 200,
|
||||
}
|
||||
|
||||
Expect(client.GetSession(context.Background(), "TOKEN")).To(Equal("SESSION_KEY"))
|
||||
queryParams := httpClient.SavedRequest.URL.Query()
|
||||
Expect(queryParams.Get("method")).To(Equal("auth.getSession"))
|
||||
Expect(queryParams.Get("format")).To(Equal("json"))
|
||||
Expect(queryParams.Get("token")).To(Equal("TOKEN"))
|
||||
Expect(queryParams.Get("api_key")).To(Equal("API_KEY"))
|
||||
Expect(queryParams.Get("api_sig")).ToNot(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("sign", func() {
|
||||
It("adds an api_sig param with the signature", func() {
|
||||
params := url.Values{}
|
||||
params.Add("d", "444")
|
||||
params.Add("callback", "https://myserver.com")
|
||||
params.Add("a", "111")
|
||||
params.Add("format", "json")
|
||||
params.Add("c", "333")
|
||||
params.Add("b", "222")
|
||||
client.sign(params)
|
||||
Expect(params).To(HaveKey("api_sig"))
|
||||
sig := params.Get("api_sig")
|
||||
expected := fmt.Sprintf("%x", md5.Sum([]byte("a111b222c333d444SECRET")))
|
||||
Expect(sig).To(Equal(expected))
|
||||
})
|
||||
})
|
||||
})
|
||||
110
core/agents/lastfm/responses.go
Normal file
110
core/agents/lastfm/responses.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package lastfm
|
||||
|
||||
type Response struct {
|
||||
Artist Artist `json:"artist"`
|
||||
SimilarArtists SimilarArtists `json:"similarartists"`
|
||||
TopTracks TopTracks `json:"toptracks"`
|
||||
Error int `json:"error"`
|
||||
Message string `json:"message"`
|
||||
Token string `json:"token"`
|
||||
Session Session `json:"session"`
|
||||
NowPlaying NowPlaying `json:"nowplaying"`
|
||||
Scrobbles Scrobbles `json:"scrobbles"`
|
||||
}
|
||||
|
||||
type Artist struct {
|
||||
Name string `json:"name"`
|
||||
MBID string `json:"mbid"`
|
||||
URL string `json:"url"`
|
||||
Image []ArtistImage `json:"image"`
|
||||
Bio ArtistBio `json:"bio"`
|
||||
}
|
||||
|
||||
type SimilarArtists struct {
|
||||
Artists []Artist `json:"artist"`
|
||||
Attr Attr `json:"@attr"`
|
||||
}
|
||||
|
||||
type Attr struct {
|
||||
Artist string `json:"artist"`
|
||||
}
|
||||
|
||||
type ArtistImage struct {
|
||||
URL string `json:"#text"`
|
||||
Size string `json:"size"`
|
||||
}
|
||||
|
||||
type ArtistBio struct {
|
||||
Published string `json:"published"`
|
||||
Summary string `json:"summary"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
Name string `json:"name"`
|
||||
MBID string `json:"mbid"`
|
||||
}
|
||||
|
||||
type TopTracks struct {
|
||||
Track []Track `json:"track"`
|
||||
Attr Attr `json:"@attr"`
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
Name string `json:"name"`
|
||||
Key string `json:"key"`
|
||||
Subscriber int `json:"subscriber"`
|
||||
}
|
||||
|
||||
type NowPlaying struct {
|
||||
Artist struct {
|
||||
Corrected string `json:"corrected"`
|
||||
Text string `json:"#text"`
|
||||
} `json:"artist"`
|
||||
IgnoredMessage struct {
|
||||
Code string `json:"code"`
|
||||
Text string `json:"#text"`
|
||||
} `json:"ignoredMessage"`
|
||||
Album struct {
|
||||
Corrected string `json:"corrected"`
|
||||
Text string `json:"#text"`
|
||||
} `json:"album"`
|
||||
AlbumArtist struct {
|
||||
Corrected string `json:"corrected"`
|
||||
Text string `json:"#text"`
|
||||
} `json:"albumArtist"`
|
||||
Track struct {
|
||||
Corrected string `json:"corrected"`
|
||||
Text string `json:"#text"`
|
||||
} `json:"track"`
|
||||
}
|
||||
|
||||
type Scrobbles struct {
|
||||
Attr struct {
|
||||
Accepted int `json:"accepted"`
|
||||
Ignored int `json:"ignored"`
|
||||
} `json:"@attr"`
|
||||
Scrobble struct {
|
||||
Artist struct {
|
||||
Corrected string `json:"corrected"`
|
||||
Text string `json:"#text"`
|
||||
} `json:"artist"`
|
||||
IgnoredMessage struct {
|
||||
Code string `json:"code"`
|
||||
Text string `json:"#text"`
|
||||
} `json:"ignoredMessage"`
|
||||
AlbumArtist struct {
|
||||
Corrected string `json:"corrected"`
|
||||
Text string `json:"#text"`
|
||||
} `json:"albumArtist"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Album struct {
|
||||
Corrected string `json:"corrected"`
|
||||
Text string `json:"#text"`
|
||||
} `json:"album"`
|
||||
Track struct {
|
||||
Corrected string `json:"corrected"`
|
||||
Text string `json:"#text"`
|
||||
} `json:"track"`
|
||||
} `json:"scrobble"`
|
||||
}
|
||||
@@ -2,7 +2,7 @@ package lastfm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
@@ -12,7 +12,7 @@ var _ = Describe("LastFM responses", func() {
|
||||
Describe("Artist", func() {
|
||||
It("parses the response correctly", func() {
|
||||
var resp Response
|
||||
body, _ := ioutil.ReadFile("tests/fixtures/lastfm.artist.getinfo.json")
|
||||
body, _ := os.ReadFile("tests/fixtures/lastfm.artist.getinfo.json")
|
||||
err := json.Unmarshal(body, &resp)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
@@ -20,18 +20,13 @@ var _ = Describe("LastFM responses", func() {
|
||||
Expect(resp.Artist.MBID).To(Equal("a3cb23fc-acd3-4ce0-8f36-1e5aa6a18432"))
|
||||
Expect(resp.Artist.URL).To(Equal("https://www.last.fm/music/U2"))
|
||||
Expect(resp.Artist.Bio.Summary).To(ContainSubstring("U2 é uma das mais importantes bandas de rock de todos os tempos"))
|
||||
|
||||
similarArtists := []string{"Passengers", "INXS", "R.E.M.", "Simple Minds", "Bono"}
|
||||
for i, similar := range similarArtists {
|
||||
Expect(resp.Artist.Similar.Artists[i].Name).To(Equal(similar))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Describe("SimilarArtists", func() {
|
||||
It("parses the response correctly", func() {
|
||||
var resp Response
|
||||
body, _ := ioutil.ReadFile("tests/fixtures/lastfm.artist.getsimilar.json")
|
||||
body, _ := os.ReadFile("tests/fixtures/lastfm.artist.getsimilar.json")
|
||||
err := json.Unmarshal(body, &resp)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
@@ -44,7 +39,7 @@ var _ = Describe("LastFM responses", func() {
|
||||
Describe("TopTracks", func() {
|
||||
It("parses the response correctly", func() {
|
||||
var resp Response
|
||||
body, _ := ioutil.ReadFile("tests/fixtures/lastfm.artist.gettoptracks.json")
|
||||
body, _ := os.ReadFile("tests/fixtures/lastfm.artist.gettoptracks.json")
|
||||
err := json.Unmarshal(body, &resp)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
@@ -58,12 +53,12 @@ var _ = Describe("LastFM responses", func() {
|
||||
|
||||
Describe("Error", func() {
|
||||
It("parses the error response correctly", func() {
|
||||
var error Error
|
||||
var error Response
|
||||
body := []byte(`{"error":3,"message":"Invalid Method - No method with that name in this package"}`)
|
||||
err := json.Unmarshal(body, &error)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(error.Code).To(Equal(3))
|
||||
Expect(error.Error).To(Equal(3))
|
||||
Expect(error.Message).To(Equal("Invalid Method - No method with that name in this package"))
|
||||
})
|
||||
})
|
||||
28
core/agents/lastfm/session_keys.go
Normal file
28
core/agents/lastfm/session_keys.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package lastfm
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionKeyProperty = "LastFMSessionKey"
|
||||
)
|
||||
|
||||
// sessionKeys is a simple wrapper around the UserPropsRepository
|
||||
type sessionKeys struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func (sk *sessionKeys) put(ctx context.Context, userId, sessionKey string) error {
|
||||
return sk.ds.UserProps(ctx).Put(userId, sessionKeyProperty, sessionKey)
|
||||
}
|
||||
|
||||
func (sk *sessionKeys) get(ctx context.Context, userId string) (string, error) {
|
||||
return sk.ds.UserProps(ctx).Get(userId, sessionKeyProperty)
|
||||
}
|
||||
|
||||
func (sk *sessionKeys) delete(ctx context.Context, userId string) error {
|
||||
return sk.ds.UserProps(ctx).Delete(userId, sessionKeyProperty)
|
||||
}
|
||||
16
core/agents/lastfm/token_received.html
Normal file
16
core/agents/lastfm/token_received.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Account Linking Success</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2 id="msg"></h2>
|
||||
<script>
|
||||
setTimeout("document.getElementById('msg').innerHTML = 'Success! Your account is linked to Last.fm. You can close this tab now.';",2000)
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
window.close();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,6 +2,8 @@ package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
const PlaceholderAgentName = "placeholder"
|
||||
@@ -15,7 +17,7 @@ const (
|
||||
|
||||
type placeholderAgent struct{}
|
||||
|
||||
func placeholdersConstructor(ctx context.Context) Interface {
|
||||
func placeholdersConstructor(ds model.DataStore) Interface {
|
||||
return &placeholderAgent{}
|
||||
}
|
||||
|
||||
@@ -23,11 +25,11 @@ func (p *placeholderAgent) AgentName() string {
|
||||
return PlaceholderAgentName
|
||||
}
|
||||
|
||||
func (p *placeholderAgent) GetBiography(id, name, mbid string) (string, error) {
|
||||
func (p *placeholderAgent) GetBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
return placeholderBiography, nil
|
||||
}
|
||||
|
||||
func (p *placeholderAgent) GetImages(id, name, mbid string) ([]ArtistImage, error) {
|
||||
func (p *placeholderAgent) GetImages(ctx context.Context, id, name, mbid string) ([]ArtistImage, error) {
|
||||
return []ArtistImage{
|
||||
{placeholderArtistImageLargeUrl, 300},
|
||||
{placeholderArtistImageMediumUrl, 174},
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -93,7 +93,7 @@ func (c *Client) makeRequest(req *http.Request, response interface{}) error {
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
data, err := ioutil.ReadAll(resp.Body)
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package spotify
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
@@ -26,7 +26,7 @@ var _ = Describe("Client", func() {
|
||||
httpClient.mock("https://api.spotify.com/v1/search", http.Response{Body: f, StatusCode: 200})
|
||||
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: ioutil.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)),
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)),
|
||||
})
|
||||
|
||||
artists, err := client.SearchArtists(context.TODO(), "U2", 10)
|
||||
@@ -44,7 +44,7 @@ var _ = Describe("Client", func() {
|
||||
It("fails if artist was not found", func() {
|
||||
httpClient.mock("https://api.spotify.com/v1/search", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: ioutil.NopCloser(bytes.NewBufferString(`{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{
|
||||
"artists" : {
|
||||
"href" : "https://api.spotify.com/v1/search?query=dasdasdas%2Cdna&type=artist&offset=0&limit=20",
|
||||
"items" : [ ], "limit" : 20, "next" : null, "offset" : 0, "previous" : null, "total" : 0
|
||||
@@ -52,7 +52,7 @@ var _ = Describe("Client", func() {
|
||||
})
|
||||
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: ioutil.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)),
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)),
|
||||
})
|
||||
|
||||
_, err := client.SearchArtists(context.TODO(), "U2", 10)
|
||||
@@ -64,7 +64,7 @@ var _ = Describe("Client", func() {
|
||||
httpClient.mock("https://api.spotify.com/v1/search", http.Response{Body: f, StatusCode: 200})
|
||||
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
|
||||
StatusCode: 400,
|
||||
Body: ioutil.NopCloser(bytes.NewBufferString(`{"error":"invalid_client","error_description":"Invalid client"}`)),
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":"invalid_client","error_description":"Invalid client"}`)),
|
||||
})
|
||||
|
||||
_, err := client.SearchArtists(context.TODO(), "U2", 10)
|
||||
@@ -76,7 +76,7 @@ var _ = Describe("Client", func() {
|
||||
It("returns an access_token on successful authorization", func() {
|
||||
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: ioutil.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)),
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)),
|
||||
})
|
||||
|
||||
token, err := client.authorize(context.TODO())
|
||||
@@ -89,7 +89,7 @@ var _ = Describe("Client", func() {
|
||||
It("fails on unsuccessful authorization", func() {
|
||||
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
|
||||
StatusCode: 400,
|
||||
Body: ioutil.NopCloser(bytes.NewBufferString(`{"error":"invalid_client","error_description":"Invalid client"}`)),
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":"invalid_client","error_description":"Invalid client"}`)),
|
||||
})
|
||||
|
||||
_, err := client.authorize(context.TODO())
|
||||
@@ -99,7 +99,7 @@ var _ = Describe("Client", func() {
|
||||
It("fails on invalid JSON response", func() {
|
||||
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: ioutil.NopCloser(bytes.NewBufferString(`{NOT_VALID}`)),
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{NOT_VALID}`)),
|
||||
})
|
||||
|
||||
_, err := client.authorize(context.TODO())
|
||||
@@ -2,7 +2,7 @@ package spotify
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
@@ -12,7 +12,7 @@ var _ = Describe("Responses", func() {
|
||||
Describe("Search type=artist", func() {
|
||||
It("parses the artist search result correctly ", func() {
|
||||
var resp SearchResults
|
||||
body, _ := ioutil.ReadFile("tests/fixtures/spotify.search.artist.json")
|
||||
body, _ := os.ReadFile("tests/fixtures/spotify.search.artist.json")
|
||||
err := json.Unmarshal(body, &resp)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package agents
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -9,29 +9,33 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/spotify"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/xrash/smetrics"
|
||||
)
|
||||
|
||||
const spotifyAgentName = "spotify"
|
||||
|
||||
type spotifyAgent struct {
|
||||
ctx context.Context
|
||||
ds model.DataStore
|
||||
id string
|
||||
secret string
|
||||
client *spotify.Client
|
||||
client *Client
|
||||
}
|
||||
|
||||
func spotifyConstructor(ctx context.Context) Interface {
|
||||
func spotifyConstructor(ds model.DataStore) agents.Interface {
|
||||
l := &spotifyAgent{
|
||||
ctx: ctx,
|
||||
ds: ds,
|
||||
id: conf.Server.Spotify.ID,
|
||||
secret: conf.Server.Spotify.Secret,
|
||||
}
|
||||
hc := NewCachedHTTPClient(http.DefaultClient, consts.DefaultCachedHttpClientTTL)
|
||||
l.client = spotify.NewClient(l.id, l.secret, hc)
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
chc := utils.NewCachedHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
l.client = NewClient(l.id, l.secret, chc)
|
||||
return l
|
||||
}
|
||||
|
||||
@@ -39,20 +43,20 @@ func (s *spotifyAgent) AgentName() string {
|
||||
return spotifyAgentName
|
||||
}
|
||||
|
||||
func (s *spotifyAgent) GetImages(id, name, mbid string) ([]ArtistImage, error) {
|
||||
a, err := s.searchArtist(name)
|
||||
func (s *spotifyAgent) GetImages(ctx context.Context, id, name, mbid string) ([]agents.ArtistImage, error) {
|
||||
a, err := s.searchArtist(ctx, name)
|
||||
if err != nil {
|
||||
if err == model.ErrNotFound {
|
||||
log.Warn(s.ctx, "Artist not found in Spotify", "artist", name)
|
||||
log.Warn(ctx, "Artist not found in Spotify", "artist", name)
|
||||
} else {
|
||||
log.Error(s.ctx, "Error calling Spotify", "artist", name, err)
|
||||
log.Error(ctx, "Error calling Spotify", "artist", name, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var res []ArtistImage
|
||||
var res []agents.ArtistImage
|
||||
for _, img := range a.Images {
|
||||
res = append(res, ArtistImage{
|
||||
res = append(res, agents.ArtistImage{
|
||||
URL: img.URL,
|
||||
Size: img.Width,
|
||||
})
|
||||
@@ -60,8 +64,8 @@ func (s *spotifyAgent) GetImages(id, name, mbid string) ([]ArtistImage, error) {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *spotifyAgent) searchArtist(name string) (*spotify.Artist, error) {
|
||||
artists, err := s.client.SearchArtists(s.ctx, name, 40)
|
||||
func (s *spotifyAgent) searchArtist(ctx context.Context, name string) (*Artist, error) {
|
||||
artists, err := s.client.SearchArtists(ctx, name, 40)
|
||||
if err != nil || len(artists) == 0 {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
@@ -84,8 +88,7 @@ func (s *spotifyAgent) searchArtist(name string) (*spotify.Artist, error) {
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
if conf.Server.Spotify.ID != "" && conf.Server.Spotify.Secret != "" {
|
||||
log.Info("Spotify integration is ENABLED")
|
||||
Register(spotifyAgentName, spotifyConstructor)
|
||||
agents.Register(spotifyAgentName, spotifyConstructor)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -30,7 +30,10 @@ type archiver struct {
|
||||
type createHeader func(idx int, mf model.MediaFile) *zip.FileHeader
|
||||
|
||||
func (a *archiver) ZipAlbum(ctx context.Context, id string, out io.Writer) error {
|
||||
mfs, err := a.ds.MediaFile(ctx).FindByAlbum(id)
|
||||
mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"album_id": id},
|
||||
Sort: "album",
|
||||
})
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading mediafiles from album", "id", id, err)
|
||||
return err
|
||||
@@ -83,7 +86,7 @@ func (a *archiver) createHeader(idx int, mf model.MediaFile) *zip.FileHeader {
|
||||
func (a *archiver) createPlaylistHeader(idx int, mf model.MediaFile) *zip.FileHeader {
|
||||
_, file := filepath.Split(mf.Path)
|
||||
return &zip.FileHeader{
|
||||
Name: fmt.Sprintf("%d - %s-%s", idx, mf.AlbumArtist, file),
|
||||
Name: fmt.Sprintf("%d - %s - %s", idx+1, mf.AlbumArtist, file),
|
||||
Modified: mf.UpdatedAt,
|
||||
Method: zip.Store,
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"image"
|
||||
_ "image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -127,7 +127,13 @@ func (a *artwork) getArtwork(ctx context.Context, id string, path string, size i
|
||||
defer func() {
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error extracting image", "path", path, "size", size, err)
|
||||
reader, err = resources.Assets().Open(consts.PlaceholderAlbumArt)
|
||||
reader, err = resources.FS.Open(consts.PlaceholderAlbumArt)
|
||||
|
||||
if size != 0 && err == nil {
|
||||
var r io.ReadCloser
|
||||
r, err = resources.FS.Open(consts.PlaceholderAlbumArt)
|
||||
reader, err = resizeImage(r, size, true)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -150,13 +156,13 @@ func (a *artwork) getArtwork(ctx context.Context, id string, path string, size i
|
||||
return
|
||||
}
|
||||
defer r.Close()
|
||||
reader, err = resizeImage(r, size)
|
||||
reader, err = resizeImage(r, size, false)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func resizeImage(reader io.Reader, size int) (io.ReadCloser, error) {
|
||||
func resizeImage(reader io.Reader, size int, usePng bool) (io.ReadCloser, error) {
|
||||
img, _, err := image.Decode(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -172,8 +178,12 @@ func resizeImage(reader io.Reader, size int) (io.ReadCloser, error) {
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
err = jpeg.Encode(buf, m, &jpeg.Options{Quality: conf.Server.CoverJpegQuality})
|
||||
return ioutil.NopCloser(buf), err
|
||||
if usePng {
|
||||
err = png.Encode(buf, m)
|
||||
} else {
|
||||
err = jpeg.Encode(buf, m, &jpeg.Options{Quality: conf.Server.CoverJpegQuality})
|
||||
}
|
||||
return io.NopCloser(buf), err
|
||||
}
|
||||
|
||||
func readFromTag(path string) (io.ReadCloser, error) {
|
||||
@@ -192,7 +202,7 @@ func readFromTag(path string) (io.ReadCloser, error) {
|
||||
if picture == nil {
|
||||
return nil, errors.New("file does not contain embedded art")
|
||||
}
|
||||
return ioutil.NopCloser(bytes.NewReader(picture.Data)), nil
|
||||
return io.NopCloser(bytes.NewReader(picture.Data)), nil
|
||||
}
|
||||
|
||||
func readFromFile(path string) (io.ReadCloser, error) {
|
||||
|
||||
@@ -3,7 +3,7 @@ package core
|
||||
import (
|
||||
"context"
|
||||
"image"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@@ -19,13 +19,13 @@ var _ = Describe("Artwork", func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepository{}}
|
||||
ds.Album(ctx).(*tests.MockAlbum).SetData(model.Albums{
|
||||
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "222", CoverArtId: "123", CoverArtPath: "tests/fixtures/test.mp3"},
|
||||
{ID: "333", CoverArtId: ""},
|
||||
{ID: "444", CoverArtId: "444", CoverArtPath: "tests/fixtures/cover.jpg"},
|
||||
})
|
||||
ds.MediaFile(ctx).(*tests.MockMediaFile).SetData(model.MediaFiles{
|
||||
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||
{ID: "123", AlbumID: "222", Path: "tests/fixtures/test.mp3", HasCoverArt: true},
|
||||
{ID: "456", AlbumID: "222", Path: "tests/fixtures/test.ogg", HasCoverArt: false},
|
||||
})
|
||||
@@ -33,7 +33,7 @@ var _ = Describe("Artwork", func() {
|
||||
|
||||
Context("Cache is configured", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DataFolder, _ = ioutil.TempDir("", "file_caches")
|
||||
conf.Server.DataFolder, _ = os.MkdirTemp("", "file_caches")
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
cache := GetImageCache()
|
||||
Eventually(func() bool { return cache.Ready(context.TODO()) }).Should(BeTrue())
|
||||
@@ -126,13 +126,13 @@ var _ = Describe("Artwork", func() {
|
||||
|
||||
Context("Errors", func() {
|
||||
It("returns err if gets error from album table", func() {
|
||||
ds.Album(ctx).(*tests.MockAlbum).SetError(true)
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetError(true)
|
||||
_, err := artwork.Get(ctx, "al-222", 0)
|
||||
Expect(err).To(MatchError("Error!"))
|
||||
})
|
||||
|
||||
It("returns err if gets error from media_file table", func() {
|
||||
ds.MediaFile(ctx).(*tests.MockMediaFile).SetError(true)
|
||||
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetError(true)
|
||||
_, err := artwork.Get(ctx, "123", 0)
|
||||
Expect(err).To(MatchError("Error!"))
|
||||
})
|
||||
|
||||
@@ -2,12 +2,11 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/go-chi/jwtauth"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"github.com/lestrrat-go/jwx/jwt"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@@ -15,62 +14,54 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
JwtSecret []byte
|
||||
TokenAuth *jwtauth.JWTAuth
|
||||
sessionTimeOut time.Duration
|
||||
once sync.Once
|
||||
Secret []byte
|
||||
TokenAuth *jwtauth.JWTAuth
|
||||
)
|
||||
|
||||
func InitTokenAuth(ds model.DataStore) {
|
||||
func Init(ds model.DataStore) {
|
||||
once.Do(func() {
|
||||
log.Info("Setting Session Timeout", "value", conf.Server.SessionTimeout)
|
||||
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)
|
||||
}
|
||||
JwtSecret = []byte(secret)
|
||||
TokenAuth = jwtauth.New("HS256", JwtSecret, nil)
|
||||
Secret = []byte(secret)
|
||||
TokenAuth = jwtauth.New("HS256", Secret, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func CreateToken(u *model.User) (string, error) {
|
||||
token := jwt.New(jwt.SigningMethodHS256)
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
claims["iss"] = consts.JWTIssuer
|
||||
claims["sub"] = u.UserName
|
||||
claims := map[string]interface{}{}
|
||||
claims[jwt.IssuerKey] = consts.JWTIssuer
|
||||
claims[jwt.IssuedAtKey] = time.Now().UTC().Unix()
|
||||
claims[jwt.SubjectKey] = u.UserName
|
||||
claims["uid"] = u.ID
|
||||
claims["adm"] = u.IsAdmin
|
||||
token, _, err := TokenAuth.Encode(claims)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return TouchToken(token)
|
||||
}
|
||||
|
||||
func getSessionTimeOut() time.Duration {
|
||||
if sessionTimeOut == 0 {
|
||||
sessionTimeOut = conf.Server.SessionTimeout
|
||||
log.Info("Setting Session Timeout", "value", sessionTimeOut)
|
||||
func TouchToken(token jwt.Token) (string, error) {
|
||||
claims, err := token.AsMap(context.Background())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return sessionTimeOut
|
||||
|
||||
claims[jwt.ExpirationKey] = time.Now().UTC().Add(conf.Server.SessionTimeout).Unix()
|
||||
_, newToken, err := TokenAuth.Encode(claims)
|
||||
|
||||
return newToken, err
|
||||
}
|
||||
|
||||
func TouchToken(token *jwt.Token) (string, error) {
|
||||
timeout := getSessionTimeOut()
|
||||
expireIn := time.Now().Add(timeout).Unix()
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
claims["exp"] = expireIn
|
||||
|
||||
return token.SignedString(JwtSecret)
|
||||
}
|
||||
|
||||
func Validate(tokenStr string) (jwt.MapClaims, error) {
|
||||
token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) {
|
||||
// Don't forget to validate the alg is what you expect:
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
|
||||
// hmacSampleSecret is a []byte containing your secret, e.g. []byte("my_secret_key")
|
||||
return JwtSecret, nil
|
||||
})
|
||||
func Validate(tokenStr string) (map[string]interface{}, error) {
|
||||
token, err := jwtauth.VerifyToken(TokenAuth, tokenStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return token.Claims.(jwt.MapClaims), err
|
||||
return token.AsMap(context.Background())
|
||||
}
|
||||
|
||||
@@ -4,9 +4,12 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@@ -17,39 +20,89 @@ func TestAuth(t *testing.T) {
|
||||
RunSpecs(t, "Auth Test Suite")
|
||||
}
|
||||
|
||||
const testJWTSecret = "not so secret"
|
||||
const (
|
||||
testJWTSecret = "not so secret"
|
||||
oneDay = 24 * time.Hour
|
||||
)
|
||||
|
||||
var _ = Describe("Auth", func() {
|
||||
BeforeEach(func() {
|
||||
auth.JwtSecret = []byte(testJWTSecret)
|
||||
BeforeSuite(func() {
|
||||
conf.Server.SessionTimeout = 2 * oneDay
|
||||
})
|
||||
Context("Validate", func() {
|
||||
|
||||
BeforeEach(func() {
|
||||
auth.Secret = []byte(testJWTSecret)
|
||||
auth.TokenAuth = jwtauth.New("HS256", auth.Secret, nil)
|
||||
})
|
||||
|
||||
Describe("Validate", func() {
|
||||
It("returns error with an invalid JWT token", func() {
|
||||
_, err := auth.Validate("invalid.token")
|
||||
Expect(err).To(Not(BeNil()))
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns the claims from a valid JWT token", func() {
|
||||
token := jwt.New(jwt.SigningMethodHS256)
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
claims := map[string]interface{}{}
|
||||
claims["iss"] = "issuer"
|
||||
claims["iat"] = time.Now().Unix()
|
||||
claims["exp"] = time.Now().Add(1 * time.Minute).Unix()
|
||||
tokenStr, _ := token.SignedString(auth.JwtSecret)
|
||||
_, tokenStr, err := auth.TokenAuth.Encode(claims)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
decodedClaims, err := auth.Validate(tokenStr)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(decodedClaims["iss"]).To(Equal("issuer"))
|
||||
})
|
||||
|
||||
It("returns ErrExpired if the `exp` field is in the past", func() {
|
||||
token := jwt.New(jwt.SigningMethodHS256)
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
claims := map[string]interface{}{}
|
||||
claims["iss"] = "issuer"
|
||||
claims["exp"] = time.Now().Add(-1 * time.Minute).Unix()
|
||||
tokenStr, _ := token.SignedString(auth.JwtSecret)
|
||||
_, tokenStr, err := auth.TokenAuth.Encode(claims)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err := auth.Validate(tokenStr)
|
||||
Expect(err).To(MatchError("Token is expired"))
|
||||
_, err = auth.Validate(tokenStr)
|
||||
Expect(err).To(MatchError("token is expired"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("CreateToken", func() {
|
||||
It("creates a valid token", func() {
|
||||
u := &model.User{
|
||||
ID: "123",
|
||||
UserName: "johndoe",
|
||||
IsAdmin: true,
|
||||
}
|
||||
tokenStr, err := auth.CreateToken(u)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
claims, err := auth.Validate(tokenStr)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
Expect(claims["iss"]).To(Equal(consts.JWTIssuer))
|
||||
Expect(claims["sub"]).To(Equal("johndoe"))
|
||||
Expect(claims["uid"]).To(Equal("123"))
|
||||
Expect(claims["adm"]).To(Equal(true))
|
||||
Expect(claims["exp"]).To(BeTemporally(">", time.Now()))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("TouchToken", func() {
|
||||
It("updates the expiration time", func() {
|
||||
yesterday := time.Now().Add(-oneDay)
|
||||
claims := map[string]interface{}{}
|
||||
claims["iss"] = "issuer"
|
||||
claims["exp"] = yesterday.Unix()
|
||||
token, _, err := auth.TokenAuth.Encode(claims)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
touched, err := auth.TouchToken(token)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
decodedClaims, err := auth.Validate(touched)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
exp := decodedClaims["exp"].(time.Time)
|
||||
Expect(exp.Sub(yesterday)).To(BeNumerically(">=", oneDay))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ package core
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
@@ -47,13 +46,8 @@ func (w *warmer) AddAlbum(ctx context.Context, albumID string) {
|
||||
}
|
||||
|
||||
func (w *warmer) waitForCacheReady(ctx context.Context) {
|
||||
tick := time.NewTicker(time.Second)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
<-tick.C
|
||||
if w.artworkCache.Ready(ctx) {
|
||||
return
|
||||
}
|
||||
for !w.artworkCache.Ready(ctx) {
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +79,7 @@ func (w *warmer) execute(workload interface{}) {
|
||||
return
|
||||
}
|
||||
defer r.Close()
|
||||
_, _ = io.Copy(ioutil.Discard, r)
|
||||
_, _ = io.Copy(io.Discard, r)
|
||||
}
|
||||
|
||||
type artworkItem struct {
|
||||
|
||||
@@ -9,14 +9,19 @@ import (
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
_ "github.com/navidrome/navidrome/core/agents/lastfm"
|
||||
_ "github.com/navidrome/navidrome/core/agents/spotify"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
)
|
||||
|
||||
const unavailableArtistID = "-1"
|
||||
const (
|
||||
unavailableArtistID = "-1"
|
||||
maxSimilarArtists = 100
|
||||
)
|
||||
|
||||
type ExternalMetadata interface {
|
||||
UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error)
|
||||
@@ -26,6 +31,7 @@ type ExternalMetadata interface {
|
||||
|
||||
type externalMetadata struct {
|
||||
ds model.DataStore
|
||||
ag *agents.Agents
|
||||
}
|
||||
|
||||
type auxArtist struct {
|
||||
@@ -33,25 +39,8 @@ type auxArtist struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func NewExternalMetadata(ds model.DataStore) ExternalMetadata {
|
||||
return &externalMetadata{ds: ds}
|
||||
}
|
||||
|
||||
func (e *externalMetadata) initAgents(ctx context.Context) []agents.Interface {
|
||||
order := strings.Split(conf.Server.Agents, ",")
|
||||
order = append(order, agents.PlaceholderAgentName)
|
||||
var res []agents.Interface
|
||||
for _, name := range order {
|
||||
init, ok := agents.Map[name]
|
||||
if !ok {
|
||||
log.Error(ctx, "Agent not available. Check configuration", "name", name)
|
||||
continue
|
||||
}
|
||||
|
||||
res = append(res, init(ctx))
|
||||
}
|
||||
|
||||
return res
|
||||
func NewExternalMetadata(ds model.DataStore, agents *agents.Agents) ExternalMetadata {
|
||||
return &externalMetadata{ds: ds, ag: agents}
|
||||
}
|
||||
|
||||
func (e *externalMetadata) getArtist(ctx context.Context, id string) (*auxArtist, error) {
|
||||
@@ -88,102 +77,140 @@ func clearName(name string) string {
|
||||
}
|
||||
|
||||
func (e *externalMetadata) UpdateArtistInfo(ctx context.Context, id string, similarCount int, includeNotPresent bool) (*model.Artist, error) {
|
||||
allAgents := e.initAgents(ctx)
|
||||
artist, err := e.getArtist(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If we have fresh info, just return it
|
||||
// If we have fresh info, just return it and trigger a refresh in the background
|
||||
if time.Since(artist.ExternalInfoUpdatedAt) < consts.ArtistInfoTimeToLive {
|
||||
log.Debug("Found cached ArtistInfo", "updatedAt", artist.ExternalInfoUpdatedAt, "name", artist.Name)
|
||||
err := e.loadSimilar(ctx, artist, includeNotPresent)
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
err := e.refreshArtistInfo(ctx, artist)
|
||||
if err != nil {
|
||||
log.Error("Error refreshing ArtistInfo", "id", id, "name", artist.Name, err)
|
||||
}
|
||||
}()
|
||||
log.Debug("Found cached ArtistInfo, refreshing in the background", "updatedAt", artist.ExternalInfoUpdatedAt, "name", artist.Name)
|
||||
err := e.loadSimilar(ctx, artist, similarCount, includeNotPresent)
|
||||
return &artist.Artist, err
|
||||
}
|
||||
log.Debug(ctx, "ArtistInfo not cached or expired", "updatedAt", artist.ExternalInfoUpdatedAt, "id", id, "name", artist.Name)
|
||||
|
||||
log.Debug(ctx, "ArtistInfo not cached or expired", "updatedAt", artist.ExternalInfoUpdatedAt, "id", id, "name", artist.Name)
|
||||
err = e.refreshArtistInfo(ctx, artist)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = e.loadSimilar(ctx, artist, similarCount, includeNotPresent)
|
||||
return &artist.Artist, err
|
||||
}
|
||||
|
||||
func (e *externalMetadata) refreshArtistInfo(ctx context.Context, artist *auxArtist) error {
|
||||
// Get MBID first, if it is not yet available
|
||||
if artist.MbzArtistID == "" {
|
||||
e.callGetMBID(ctx, allAgents, artist)
|
||||
}
|
||||
|
||||
// Call all registered agents and collect information
|
||||
wg := &sync.WaitGroup{}
|
||||
e.callGetBiography(ctx, allAgents, artist, wg)
|
||||
e.callGetURL(ctx, allAgents, artist, wg)
|
||||
e.callGetImage(ctx, allAgents, artist, wg)
|
||||
e.callGetSimilar(ctx, allAgents, artist, similarCount, wg)
|
||||
wg.Wait()
|
||||
|
||||
if isDone(ctx) {
|
||||
log.Warn(ctx, "ArtistInfo update canceled", ctx.Err())
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
artist.ExternalInfoUpdatedAt = time.Now()
|
||||
err = e.ds.Artist(ctx).Put(&artist.Artist)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error trying to update artist external information", "id", id, "name", artist.Name, err)
|
||||
}
|
||||
|
||||
if !includeNotPresent {
|
||||
similar := artist.SimilarArtists
|
||||
artist.SimilarArtists = nil
|
||||
for _, s := range similar {
|
||||
if s.ID == unavailableArtistID {
|
||||
continue
|
||||
}
|
||||
artist.SimilarArtists = append(artist.SimilarArtists, s)
|
||||
mbid, err := e.ag.GetMBID(ctx, artist.ID, artist.Name)
|
||||
if mbid != "" && err == nil {
|
||||
artist.MbzArtistID = mbid
|
||||
}
|
||||
}
|
||||
|
||||
// Call all registered agents and collect information
|
||||
callParallel([]func(){
|
||||
func() { e.callGetBiography(ctx, e.ag, artist) },
|
||||
func() { e.callGetURL(ctx, e.ag, artist) },
|
||||
func() { e.callGetImage(ctx, e.ag, artist) },
|
||||
func() { e.callGetSimilar(ctx, e.ag, artist, maxSimilarArtists, true) },
|
||||
})
|
||||
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "ArtistInfo update canceled", ctx.Err())
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
artist.ExternalInfoUpdatedAt = time.Now()
|
||||
err := e.ds.Artist(ctx).Put(&artist.Artist)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artist.Name, err)
|
||||
}
|
||||
|
||||
log.Trace(ctx, "ArtistInfo collected", "artist", artist)
|
||||
return &artist.Artist, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func callParallel(fs []func()) {
|
||||
wg := &sync.WaitGroup{}
|
||||
wg.Add(len(fs))
|
||||
for _, f := range fs {
|
||||
go func(f func()) {
|
||||
f()
|
||||
wg.Done()
|
||||
}(f)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) {
|
||||
allAgents := e.initAgents(ctx)
|
||||
artist, err := e.getArtist(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
wg := &sync.WaitGroup{}
|
||||
e.callGetSimilar(ctx, allAgents, artist, count, wg)
|
||||
wg.Wait()
|
||||
|
||||
if isDone(ctx) {
|
||||
e.callGetSimilar(ctx, e.ag, artist, 15, false)
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
if len(artist.SimilarArtists) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
artists := model.Artists{artist.Artist}
|
||||
artists = append(artists, artist.SimilarArtists...)
|
||||
|
||||
var ids = []string{artist.ID}
|
||||
for _, a := range artist.SimilarArtists {
|
||||
if a.ID != unavailableArtistID {
|
||||
ids = append(ids, a.ID)
|
||||
weightedSongs := utils.NewWeightedRandomChooser()
|
||||
for _, a := range artists {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
topCount := utils.MaxInt(count, 20)
|
||||
topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Name: a.Name, Artist: a}, topCount)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
weight := topCount * 4
|
||||
for _, mf := range topSongs {
|
||||
weightedSongs.Put(mf, weight)
|
||||
weight -= 4
|
||||
}
|
||||
}
|
||||
|
||||
return e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"artist_id": ids},
|
||||
Max: count,
|
||||
Sort: "random()",
|
||||
})
|
||||
var similarSongs model.MediaFiles
|
||||
for len(similarSongs) < count && weightedSongs.Size() > 0 {
|
||||
s, err := weightedSongs.GetAndRemove()
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error getting weighted song", err)
|
||||
continue
|
||||
}
|
||||
similarSongs = append(similarSongs, s.(model.MediaFile))
|
||||
}
|
||||
|
||||
return similarSongs, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) TopSongs(ctx context.Context, artistName string, count int) (model.MediaFiles, error) {
|
||||
allAgents := e.initAgents(ctx)
|
||||
artist, err := e.findArtistByName(ctx, artistName)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Artist not found", "name", artistName, err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
songs, err := e.callGetTopSongs(ctx, allAgents, artist, count)
|
||||
return e.getMatchingTopSongs(ctx, e.ag, artist, count)
|
||||
}
|
||||
|
||||
func (e *externalMetadata) getMatchingTopSongs(ctx context.Context, agent agents.ArtistTopSongsRetriever, artist *auxArtist, count int) (model.MediaFiles, error) {
|
||||
songs, err := agent.GetTopSongs(ctx, artist.ID, artist.Name, artist.MbzArtistID, count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -195,6 +222,14 @@ func (e *externalMetadata) TopSongs(ctx context.Context, artistName string, coun
|
||||
continue
|
||||
}
|
||||
mfs = append(mfs, *mf)
|
||||
if len(mfs) == count {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(mfs) == 0 {
|
||||
log.Debug(ctx, "No matching top songs found", "name", artist.Name)
|
||||
} else {
|
||||
log.Debug(ctx, "Found matching top songs", "name", artist.Name, "numSongs", len(mfs))
|
||||
}
|
||||
return mfs, nil
|
||||
}
|
||||
@@ -224,162 +259,54 @@ func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, a
|
||||
return &mfs[0], nil
|
||||
}
|
||||
|
||||
func isDone(ctx context.Context) bool {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
func (e *externalMetadata) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) {
|
||||
url, err := agent.GetURL(ctx, artist.ID, artist.Name, artist.MbzArtistID)
|
||||
if url == "" || err != nil {
|
||||
return
|
||||
}
|
||||
artist.ExternalUrl = url
|
||||
}
|
||||
|
||||
func (e *externalMetadata) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) {
|
||||
bio, err := agent.GetBiography(ctx, artist.ID, clearName(artist.Name), artist.MbzArtistID)
|
||||
if bio == "" || err != nil {
|
||||
return
|
||||
}
|
||||
policy := bluemonday.UGCPolicy()
|
||||
bio = policy.Sanitize(bio)
|
||||
bio = strings.ReplaceAll(bio, "\n", " ")
|
||||
artist.Biography = strings.ReplaceAll(bio, "<a ", "<a target='_blank' ")
|
||||
}
|
||||
|
||||
func (e *externalMetadata) callGetImage(ctx context.Context, agent agents.ArtistImageRetriever, artist *auxArtist) {
|
||||
images, err := agent.GetImages(ctx, artist.ID, artist.Name, artist.MbzArtistID)
|
||||
if len(images) == 0 || err != nil {
|
||||
return
|
||||
}
|
||||
sort.Slice(images, func(i, j int) bool { return images[i].Size > images[j].Size })
|
||||
|
||||
if len(images) >= 1 {
|
||||
artist.LargeImageUrl = images[0].URL
|
||||
}
|
||||
if len(images) >= 2 {
|
||||
artist.MediumImageUrl = images[1].URL
|
||||
}
|
||||
if len(images) >= 3 {
|
||||
artist.SmallImageUrl = images[2].URL
|
||||
}
|
||||
}
|
||||
|
||||
func (e *externalMetadata) callGetMBID(ctx context.Context, allAgents []agents.Interface, artist *auxArtist) {
|
||||
start := time.Now()
|
||||
for _, a := range allAgents {
|
||||
if isDone(ctx) {
|
||||
break
|
||||
}
|
||||
agent, ok := a.(agents.ArtistMBIDRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
mbid, err := agent.GetMBID(artist.ID, artist.Name)
|
||||
if mbid != "" && err == nil {
|
||||
artist.MbzArtistID = mbid
|
||||
log.Debug(ctx, "Got MBID", "agent", a.AgentName(), "artist", artist.Name, "mbid", mbid, "elapsed", time.Since(start))
|
||||
break
|
||||
}
|
||||
func (e *externalMetadata) callGetSimilar(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist,
|
||||
limit int, includeNotPresent bool) {
|
||||
similar, err := agent.GetSimilar(ctx, artist.ID, artist.Name, artist.MbzArtistID, limit)
|
||||
if len(similar) == 0 || err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (e *externalMetadata) callGetTopSongs(ctx context.Context, allAgents []agents.Interface, artist *auxArtist,
|
||||
count int) ([]agents.Song, error) {
|
||||
start := time.Now()
|
||||
for _, a := range allAgents {
|
||||
if isDone(ctx) {
|
||||
break
|
||||
}
|
||||
agent, ok := a.(agents.ArtistTopSongsRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
songs, err := agent.GetTopSongs(artist.ID, artist.Name, artist.MbzArtistID, count)
|
||||
if len(songs) > 0 && err == nil {
|
||||
log.Debug(ctx, "Got Top Songs", "agent", a.AgentName(), "artist", artist.Name, "songs", songs, "elapsed", time.Since(start))
|
||||
return songs, err
|
||||
}
|
||||
sa, err := e.mapSimilarArtists(ctx, similar, includeNotPresent)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) callGetURL(ctx context.Context, allAgents []agents.Interface, artist *auxArtist, wg *sync.WaitGroup) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
start := time.Now()
|
||||
for _, a := range allAgents {
|
||||
if isDone(ctx) {
|
||||
break
|
||||
}
|
||||
agent, ok := a.(agents.ArtistURLRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
url, err := agent.GetURL(artist.ID, artist.Name, artist.MbzArtistID)
|
||||
if url != "" && err == nil {
|
||||
artist.ExternalUrl = url
|
||||
log.Debug(ctx, "Got External Url", "agent", a.AgentName(), "artist", artist.Name, "url", url, "elapsed", time.Since(start))
|
||||
break
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (e *externalMetadata) callGetBiography(ctx context.Context, allAgents []agents.Interface, artist *auxArtist, wg *sync.WaitGroup) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
start := time.Now()
|
||||
for _, a := range allAgents {
|
||||
if isDone(ctx) {
|
||||
break
|
||||
}
|
||||
agent, ok := a.(agents.ArtistBiographyRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
bio, err := agent.GetBiography(artist.ID, clearName(artist.Name), artist.MbzArtistID)
|
||||
if bio != "" && err == nil {
|
||||
policy := bluemonday.UGCPolicy()
|
||||
bio = policy.Sanitize(bio)
|
||||
bio = strings.ReplaceAll(bio, "\n", " ")
|
||||
artist.Biography = strings.ReplaceAll(bio, "<a ", "<a target='_blank' ")
|
||||
log.Debug(ctx, "Got Biography", "agent", a.AgentName(), "artist", artist.Name, "len", len(bio), "elapsed", time.Since(start))
|
||||
break
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (e *externalMetadata) callGetImage(ctx context.Context, allAgents []agents.Interface, artist *auxArtist, wg *sync.WaitGroup) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
start := time.Now()
|
||||
for _, a := range allAgents {
|
||||
if isDone(ctx) {
|
||||
break
|
||||
}
|
||||
agent, ok := a.(agents.ArtistImageRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
images, err := agent.GetImages(artist.ID, artist.Name, artist.MbzArtistID)
|
||||
if len(images) == 0 || err != nil {
|
||||
continue
|
||||
}
|
||||
log.Debug(ctx, "Got Images", "agent", a.AgentName(), "artist", artist.Name, "images", images, "elapsed", time.Since(start))
|
||||
sort.Slice(images, func(i, j int) bool { return images[i].Size > images[j].Size })
|
||||
if len(images) >= 1 {
|
||||
artist.LargeImageUrl = images[0].URL
|
||||
}
|
||||
if len(images) >= 2 {
|
||||
artist.MediumImageUrl = images[1].URL
|
||||
}
|
||||
if len(images) >= 3 {
|
||||
artist.SmallImageUrl = images[2].URL
|
||||
}
|
||||
break
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (e *externalMetadata) callGetSimilar(ctx context.Context, allAgents []agents.Interface, artist *auxArtist, limit int, wg *sync.WaitGroup) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
start := time.Now()
|
||||
for _, a := range allAgents {
|
||||
if isDone(ctx) {
|
||||
break
|
||||
}
|
||||
agent, ok := a.(agents.ArtistSimilarRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
similar, err := agent.GetSimilar(artist.ID, artist.Name, artist.MbzArtistID, limit)
|
||||
if len(similar) == 0 || err != nil {
|
||||
continue
|
||||
}
|
||||
sa, err := e.mapSimilarArtists(ctx, similar, true)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
log.Debug(ctx, "Got Similar Artists", "agent", a.AgentName(), "artist", artist.Name, "similar", similar, "elapsed", time.Since(start))
|
||||
artist.SimilarArtists = sa
|
||||
break
|
||||
}
|
||||
}()
|
||||
artist.SimilarArtists = sa
|
||||
}
|
||||
|
||||
func (e *externalMetadata) mapSimilarArtists(ctx context.Context, similar []agents.Artist, includeNotPresent bool) (model.Artists, error) {
|
||||
@@ -409,7 +336,7 @@ func (e *externalMetadata) mapSimilarArtists(ctx context.Context, similar []agen
|
||||
|
||||
func (e *externalMetadata) findArtistByName(ctx context.Context, artistName string) (*auxArtist, error) {
|
||||
artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Like{"name": artistName},
|
||||
Filters: squirrel.Like{"artist.name": artistName},
|
||||
Max: 1,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -425,7 +352,7 @@ func (e *externalMetadata) findArtistByName(ctx context.Context, artistName stri
|
||||
return artist, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) loadSimilar(ctx context.Context, artist *auxArtist, includeNotPresent bool) error {
|
||||
func (e *externalMetadata) loadSimilar(ctx context.Context, artist *auxArtist, count int, includeNotPresent bool) error {
|
||||
var ids []string
|
||||
for _, sa := range artist.SimilarArtists {
|
||||
if sa.ID == unavailableArtistID {
|
||||
@@ -435,9 +362,10 @@ func (e *externalMetadata) loadSimilar(ctx context.Context, artist *auxArtist, i
|
||||
}
|
||||
|
||||
similar, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"id": ids},
|
||||
Filters: squirrel.Eq{"artist.id": ids},
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("Error loading similar artists", "id", artist.ID, "name", artist.Name, err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -449,6 +377,9 @@ func (e *externalMetadata) loadSimilar(ctx context.Context, artist *auxArtist, i
|
||||
|
||||
var loaded model.Artists
|
||||
for _, sa := range artist.SimilarArtists {
|
||||
if len(loaded) >= count {
|
||||
break
|
||||
}
|
||||
la, ok := artistMap[sa.ID]
|
||||
if !ok {
|
||||
if !includeNotPresent {
|
||||
|
||||
@@ -24,14 +24,14 @@ type MediaStreamer interface {
|
||||
|
||||
type TranscodingCache cache.FileCache
|
||||
|
||||
func NewMediaStreamer(ds model.DataStore, ffm transcoder.Transcoder, cache TranscodingCache) MediaStreamer {
|
||||
return &mediaStreamer{ds: ds, ffm: ffm, cache: cache}
|
||||
func NewMediaStreamer(ds model.DataStore, t transcoder.Transcoder, cache TranscodingCache) MediaStreamer {
|
||||
return &mediaStreamer{ds: ds, transcoder: t, cache: cache}
|
||||
}
|
||||
|
||||
type mediaStreamer struct {
|
||||
ds model.DataStore
|
||||
ffm transcoder.Transcoder
|
||||
cache cache.FileCache
|
||||
ds model.DataStore
|
||||
transcoder transcoder.Transcoder
|
||||
cache cache.FileCache
|
||||
}
|
||||
|
||||
type streamJob struct {
|
||||
@@ -182,7 +182,7 @@ func GetTranscodingCache() TranscodingCache {
|
||||
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)
|
||||
out, err := job.ms.transcoder.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
|
||||
|
||||
@@ -3,7 +3,7 @@ package core
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
@@ -22,10 +22,10 @@ var _ = Describe("MediaStreamer", func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
|
||||
BeforeEach(func() {
|
||||
conf.Server.DataFolder, _ = ioutil.TempDir("", "file_caches")
|
||||
conf.Server.DataFolder, _ = os.MkdirTemp("", "file_caches")
|
||||
conf.Server.TranscodingCacheSize = "100MB"
|
||||
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepository{}}
|
||||
ds.MediaFile(ctx).(*tests.MockMediaFile).SetData(model.MediaFiles{
|
||||
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
|
||||
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||
{ID: "123", Path: "tests/fixtures/test.mp3", Suffix: "mp3", BitRate: 128, Duration: 257.0},
|
||||
})
|
||||
testCache := GetTranscodingCache()
|
||||
@@ -58,7 +58,7 @@ var _ = Describe("MediaStreamer", func() {
|
||||
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)
|
||||
_, _ = io.ReadAll(s)
|
||||
_ = s.Close()
|
||||
Eventually(func() bool { return ffmpeg.closed }, "3s").Should(BeTrue())
|
||||
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const NowPlayingExpire = 60 * time.Minute
|
||||
|
||||
type NowPlayingInfo struct {
|
||||
TrackID string
|
||||
Start time.Time
|
||||
Username string
|
||||
PlayerId int
|
||||
PlayerName string
|
||||
}
|
||||
|
||||
// This repo must have the semantics of a FIFO queue, for each playerId
|
||||
type NowPlaying interface {
|
||||
// Insert at the head of the queue
|
||||
Enqueue(*NowPlayingInfo) error
|
||||
|
||||
// Returns all heads from all playerIds
|
||||
GetAll() ([]*NowPlayingInfo, error)
|
||||
}
|
||||
|
||||
var playerMap = sync.Map{}
|
||||
|
||||
type nowPlayingRepository struct{}
|
||||
|
||||
func NewNowPlayingRepository() NowPlaying {
|
||||
r := &nowPlayingRepository{}
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *nowPlayingRepository) Enqueue(info *NowPlayingInfo) error {
|
||||
l := r.getList(info.PlayerId)
|
||||
l.PushFront(info)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *nowPlayingRepository) GetAll() ([]*NowPlayingInfo, error) {
|
||||
var all []*NowPlayingInfo
|
||||
playerMap.Range(func(playerId, l interface{}) bool {
|
||||
ll := l.(*list.List)
|
||||
e := checkExpired(ll, ll.Front)
|
||||
if e != nil {
|
||||
all = append(all, e.Value.(*NowPlayingInfo))
|
||||
}
|
||||
return true
|
||||
})
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func (r *nowPlayingRepository) getList(id int) *list.List {
|
||||
l, _ := playerMap.LoadOrStore(id, list.New())
|
||||
return l.(*list.List)
|
||||
}
|
||||
|
||||
func (r *nowPlayingRepository) dequeue(playerId int) (*NowPlayingInfo, error) {
|
||||
l := r.getList(playerId)
|
||||
e := checkExpired(l, l.Back)
|
||||
if e == nil {
|
||||
return nil, nil
|
||||
}
|
||||
l.Remove(e)
|
||||
return e.Value.(*NowPlayingInfo), nil
|
||||
}
|
||||
|
||||
func (r *nowPlayingRepository) head(playerId int) (*NowPlayingInfo, error) {
|
||||
l := r.getList(playerId)
|
||||
e := checkExpired(l, l.Front)
|
||||
if e == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return e.Value.(*NowPlayingInfo), nil
|
||||
}
|
||||
|
||||
func (r *nowPlayingRepository) tail(playerId int) (*NowPlayingInfo, error) {
|
||||
l := r.getList(playerId)
|
||||
e := checkExpired(l, l.Back)
|
||||
if e == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return e.Value.(*NowPlayingInfo), nil
|
||||
}
|
||||
|
||||
func (r *nowPlayingRepository) count(playerId int) (int64, error) {
|
||||
l := r.getList(playerId)
|
||||
return int64(l.Len()), nil
|
||||
}
|
||||
|
||||
func checkExpired(l *list.List, f func() *list.Element) *list.Element {
|
||||
for {
|
||||
e := f()
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
start := e.Value.(*NowPlayingInfo).Start
|
||||
if time.Since(start) < NowPlayingExpire {
|
||||
return e
|
||||
}
|
||||
l.Remove(e)
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("NowPlaying", func() {
|
||||
var repo *nowPlayingRepository
|
||||
var now = time.Now()
|
||||
var past = time.Time{}
|
||||
|
||||
BeforeEach(func() {
|
||||
playerMap = sync.Map{}
|
||||
repo = NewNowPlayingRepository().(*nowPlayingRepository)
|
||||
})
|
||||
|
||||
It("enqueues and dequeues records", func() {
|
||||
Expect(repo.Enqueue(&NowPlayingInfo{PlayerId: 1, TrackID: "AAA", Start: now})).To(BeNil())
|
||||
Expect(repo.Enqueue(&NowPlayingInfo{PlayerId: 1, TrackID: "BBB", Start: now})).To(BeNil())
|
||||
|
||||
Expect(repo.tail(1)).To(Equal(&NowPlayingInfo{PlayerId: 1, TrackID: "AAA", Start: now}))
|
||||
Expect(repo.head(1)).To(Equal(&NowPlayingInfo{PlayerId: 1, TrackID: "BBB", Start: now}))
|
||||
|
||||
Expect(repo.count(1)).To(Equal(int64(2)))
|
||||
|
||||
Expect(repo.dequeue(1)).To(Equal(&NowPlayingInfo{PlayerId: 1, TrackID: "AAA", Start: now}))
|
||||
Expect(repo.count(1)).To(Equal(int64(1)))
|
||||
})
|
||||
|
||||
It("handles multiple players", func() {
|
||||
Expect(repo.Enqueue(&NowPlayingInfo{PlayerId: 1, TrackID: "AAA", Start: now})).To(BeNil())
|
||||
Expect(repo.Enqueue(&NowPlayingInfo{PlayerId: 1, TrackID: "BBB", Start: now})).To(BeNil())
|
||||
|
||||
Expect(repo.Enqueue(&NowPlayingInfo{PlayerId: 2, TrackID: "CCC", Start: now})).To(BeNil())
|
||||
Expect(repo.Enqueue(&NowPlayingInfo{PlayerId: 2, TrackID: "DDD", Start: now})).To(BeNil())
|
||||
|
||||
Expect(repo.GetAll()).To(ConsistOf([]*NowPlayingInfo{
|
||||
{PlayerId: 1, TrackID: "BBB", Start: now},
|
||||
{PlayerId: 2, TrackID: "DDD", Start: now},
|
||||
}))
|
||||
|
||||
Expect(repo.count(2)).To(Equal(int64(2)))
|
||||
Expect(repo.count(2)).To(Equal(int64(2)))
|
||||
|
||||
Expect(repo.tail(1)).To(Equal(&NowPlayingInfo{PlayerId: 1, TrackID: "AAA", Start: now}))
|
||||
Expect(repo.head(2)).To(Equal(&NowPlayingInfo{PlayerId: 2, TrackID: "DDD", Start: now}))
|
||||
})
|
||||
|
||||
It("handles expired items", func() {
|
||||
Expect(repo.Enqueue(&NowPlayingInfo{PlayerId: 1, TrackID: "AAA", Start: past})).To(BeNil())
|
||||
Expect(repo.Enqueue(&NowPlayingInfo{PlayerId: 2, TrackID: "BBB", Start: now})).To(BeNil())
|
||||
|
||||
Expect(repo.GetAll()).To(ConsistOf([]*NowPlayingInfo{
|
||||
{PlayerId: 2, TrackID: "BBB", Start: now},
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -24,7 +24,7 @@ type players struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func (p *players) Register(ctx context.Context, id, client, typ, ip string) (*model.Player, *model.Transcoding, error) {
|
||||
func (p *players) Register(ctx context.Context, id, client, userAgent, ip string) (*model.Player, *model.Transcoding, error) {
|
||||
var plr *model.Player
|
||||
var trc *model.Transcoding
|
||||
var err error
|
||||
@@ -36,22 +36,23 @@ func (p *players) Register(ctx context.Context, id, client, typ, ip string) (*mo
|
||||
}
|
||||
}
|
||||
if err != nil || id == "" {
|
||||
plr, err = p.ds.Player(ctx).FindByName(client, userName)
|
||||
plr, err = p.ds.Player(ctx).FindMatch(userName, client, userAgent)
|
||||
if err == nil {
|
||||
log.Debug("Found player by name", "id", plr.ID, "client", client, "username", userName)
|
||||
log.Debug("Found matching player", "id", plr.ID, "client", client, "username", userName, "type", userAgent)
|
||||
} else {
|
||||
plr = &model.Player{
|
||||
ID: uuid.NewString(),
|
||||
Name: fmt.Sprintf("%s (%s)", client, userName),
|
||||
UserName: userName,
|
||||
Client: client,
|
||||
ID: uuid.NewString(),
|
||||
UserName: userName,
|
||||
Client: client,
|
||||
ScrobbleEnabled: true,
|
||||
}
|
||||
log.Info("Registering new player", "id", plr.ID, "client", client, "username", userName)
|
||||
log.Info("Registering new player", "id", plr.ID, "client", client, "username", userName, "type", userAgent)
|
||||
}
|
||||
}
|
||||
plr.LastSeen = time.Now()
|
||||
plr.Type = typ
|
||||
plr.Name = fmt.Sprintf("%s [%s]", client, userAgent)
|
||||
plr.UserAgent = userAgent
|
||||
plr.IPAddress = ip
|
||||
plr.LastSeen = time.Now()
|
||||
err = p.ds.Player(ctx).Put(plr)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
|
||||
@@ -22,7 +22,7 @@ var _ = Describe("Players", func() {
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = &mockPlayerRepository{}
|
||||
ds := &tests.MockDataStore{MockedPlayer: repo, MockedTranscoding: &tests.MockTranscodingRepository{}}
|
||||
ds := &tests.MockDataStore{MockedPlayer: repo, MockedTranscoding: &tests.MockTranscodingRepo{}}
|
||||
players = NewPlayers(ds)
|
||||
beforeRegister = time.Now()
|
||||
})
|
||||
@@ -35,7 +35,7 @@ var _ = Describe("Players", func() {
|
||||
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(p.UserAgent).To(Equal("chrome"))
|
||||
Expect(repo.lastSaved).To(Equal(p))
|
||||
Expect(trc).To(BeNil())
|
||||
})
|
||||
@@ -125,7 +125,7 @@ func (m *mockPlayerRepository) Get(id string) (*model.Player, error) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockPlayerRepository) FindByName(client, userName string) (*model.Player, error) {
|
||||
func (m *mockPlayerRepository) FindMatch(userName, client, typ string) (*model.Player, error) {
|
||||
for _, p := range m.data {
|
||||
if p.Client == client && p.UserName == userName {
|
||||
return &p, nil
|
||||
|
||||
115
core/scrobbler/buffered_scrobbler.go
Normal file
115
core/scrobbler/buffered_scrobbler.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package scrobbler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
func newBufferedScrobbler(ds model.DataStore, s Scrobbler, service string) *bufferedScrobbler {
|
||||
b := &bufferedScrobbler{ds: ds, wrapped: s, service: service}
|
||||
b.wakeSignal = make(chan struct{}, 1)
|
||||
go b.run()
|
||||
return b
|
||||
}
|
||||
|
||||
type bufferedScrobbler struct {
|
||||
ds model.DataStore
|
||||
wrapped Scrobbler
|
||||
service string
|
||||
wakeSignal chan struct{}
|
||||
}
|
||||
|
||||
func (b *bufferedScrobbler) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
return b.wrapped.IsAuthorized(ctx, userId)
|
||||
}
|
||||
|
||||
func (b *bufferedScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error {
|
||||
return b.wrapped.NowPlaying(ctx, userId, track)
|
||||
}
|
||||
|
||||
func (b *bufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error {
|
||||
err := b.ds.ScrobbleBuffer(ctx).Enqueue(b.service, userId, s.ID, s.TimeStamp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.sendWakeSignal()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bufferedScrobbler) sendWakeSignal() {
|
||||
// Don't block if the previous signal was not read yet
|
||||
select {
|
||||
case b.wakeSignal <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (b *bufferedScrobbler) run() {
|
||||
ctx := context.Background()
|
||||
for {
|
||||
if !b.processQueue(ctx) {
|
||||
time.AfterFunc(5*time.Second, func() {
|
||||
b.sendWakeSignal()
|
||||
})
|
||||
}
|
||||
<-b.wakeSignal
|
||||
}
|
||||
}
|
||||
|
||||
func (b *bufferedScrobbler) processQueue(ctx context.Context) bool {
|
||||
buffer := b.ds.ScrobbleBuffer(ctx)
|
||||
userIds, err := buffer.UserIDs(b.service)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error retrieving userIds from scrobble buffer", "scrobbler", b.service, err)
|
||||
return false
|
||||
}
|
||||
result := true
|
||||
for _, userId := range userIds {
|
||||
if !b.processUserQueue(ctx, userId) {
|
||||
result = false
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (b *bufferedScrobbler) processUserQueue(ctx context.Context, userId string) bool {
|
||||
buffer := b.ds.ScrobbleBuffer(ctx)
|
||||
for {
|
||||
entry, err := buffer.Next(b.service, userId)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error reading from scrobble buffer", "scrobbler", b.service, err)
|
||||
return false
|
||||
}
|
||||
if entry == nil {
|
||||
return true
|
||||
}
|
||||
log.Debug(ctx, "Sending scrobble", "scrobbler", b.service, "track", entry.Title, "artist", entry.Artist)
|
||||
err = b.wrapped.Scrobble(ctx, entry.UserID, Scrobble{
|
||||
MediaFile: entry.MediaFile,
|
||||
TimeStamp: entry.PlayTime,
|
||||
})
|
||||
if err != nil {
|
||||
switch err {
|
||||
case ErrRetryLater:
|
||||
log.Warn(ctx, "Could not send scrobble. Will be retried", "userId", entry.UserID,
|
||||
"track", entry.Title, "artist", entry.Artist, "scrobbler", b.service, err)
|
||||
return false
|
||||
default:
|
||||
log.Error(ctx, "Error sending scrobble to service. Discarding", "scrobbler", b.service,
|
||||
"userId", entry.UserID, "artist", entry.Artist, "track", entry.Title, err)
|
||||
}
|
||||
}
|
||||
err = buffer.Dequeue(entry)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error removing entry from scrobble buffer", "userId", entry.UserID,
|
||||
"track", entry.Title, "artist", entry.Artist, "scrobbler", b.service, err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var _ Scrobbler = (*bufferedScrobbler)(nil)
|
||||
28
core/scrobbler/interfaces.go
Normal file
28
core/scrobbler/interfaces.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package scrobbler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type Scrobble struct {
|
||||
model.MediaFile
|
||||
TimeStamp time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
ErrNotAuthorized = errors.New("not authorized")
|
||||
ErrRetryLater = errors.New("retry later")
|
||||
ErrUnrecoverable = errors.New("unrecoverable")
|
||||
)
|
||||
|
||||
type Scrobbler interface {
|
||||
IsAuthorized(ctx context.Context, userId string) bool
|
||||
NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error
|
||||
Scrobble(ctx context.Context, userId string, s Scrobble) error
|
||||
}
|
||||
|
||||
type Constructor func(ds model.DataStore) Scrobbler
|
||||
195
core/scrobbler/play_tracker.go
Normal file
195
core/scrobbler/play_tracker.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package scrobbler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
|
||||
"github.com/ReneKroon/ttlcache/v2"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
)
|
||||
|
||||
const nowPlayingExpire = 60 * time.Minute
|
||||
|
||||
type NowPlayingInfo struct {
|
||||
TrackID string
|
||||
Start time.Time
|
||||
Username string
|
||||
PlayerId string
|
||||
PlayerName string
|
||||
}
|
||||
|
||||
type Submission struct {
|
||||
TrackID string
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
type PlayTracker interface {
|
||||
NowPlaying(ctx context.Context, playerId string, playerName string, trackId string) error
|
||||
GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error)
|
||||
Submit(ctx context.Context, submissions []Submission) error
|
||||
}
|
||||
|
||||
type playTracker struct {
|
||||
ds model.DataStore
|
||||
broker events.Broker
|
||||
playMap *ttlcache.Cache
|
||||
scrobblers map[string]Scrobbler
|
||||
}
|
||||
|
||||
func GetPlayTracker(ds model.DataStore, broker events.Broker) PlayTracker {
|
||||
instance := singleton.Get(playTracker{}, func() interface{} {
|
||||
m := ttlcache.NewCache()
|
||||
m.SkipTTLExtensionOnHit(true)
|
||||
_ = m.SetTTL(nowPlayingExpire)
|
||||
p := &playTracker{ds: ds, playMap: m, broker: broker}
|
||||
p.scrobblers = make(map[string]Scrobbler)
|
||||
for name, constructor := range constructors {
|
||||
s := constructor(ds)
|
||||
if conf.Server.DevEnableBufferedScrobble {
|
||||
s = newBufferedScrobbler(ds, s, name)
|
||||
}
|
||||
p.scrobblers[name] = s
|
||||
}
|
||||
return p
|
||||
})
|
||||
return instance.(*playTracker)
|
||||
}
|
||||
|
||||
func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerName string, trackId string) error {
|
||||
user, _ := request.UserFrom(ctx)
|
||||
info := NowPlayingInfo{
|
||||
TrackID: trackId,
|
||||
Start: time.Now(),
|
||||
Username: user.UserName,
|
||||
PlayerId: playerId,
|
||||
PlayerName: playerName,
|
||||
}
|
||||
_ = p.playMap.Set(playerId, info)
|
||||
player, _ := request.PlayerFrom(ctx)
|
||||
if player.ScrobbleEnabled {
|
||||
p.dispatchNowPlaying(ctx, user.ID, trackId)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, trackId string) {
|
||||
t, err := p.ds.MediaFile(ctx).Get(trackId)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error retrieving mediaFile", "id", trackId, err)
|
||||
return
|
||||
}
|
||||
// TODO Parallelize
|
||||
for name, s := range p.scrobblers {
|
||||
if !s.IsAuthorized(ctx, userId) {
|
||||
continue
|
||||
}
|
||||
log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist)
|
||||
err := s.NowPlaying(ctx, userId, t)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error sending NowPlayingInfo", "scrobbler", name, "track", t.Title, "artist", t.Artist, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *playTracker) GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error) {
|
||||
var res []NowPlayingInfo
|
||||
for _, playerId := range p.playMap.GetKeys() {
|
||||
value, err := p.playMap.Get(playerId)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
info := value.(NowPlayingInfo)
|
||||
res = append(res, info)
|
||||
}
|
||||
sort.Slice(res, func(i, j int) bool {
|
||||
return res[i].Start.After(res[j].Start)
|
||||
})
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (p *playTracker) Submit(ctx context.Context, submissions []Submission) error {
|
||||
username, _ := request.UsernameFrom(ctx)
|
||||
player, _ := request.PlayerFrom(ctx)
|
||||
if !player.ScrobbleEnabled {
|
||||
log.Debug(ctx, "External scrobbling disabled for this player", "player", player.Name, "ip", player.IPAddress, "user", username)
|
||||
}
|
||||
event := &events.RefreshResource{}
|
||||
success := 0
|
||||
|
||||
for _, s := range submissions {
|
||||
mf, err := p.ds.MediaFile(ctx).Get(s.TrackID)
|
||||
if err != nil {
|
||||
log.Error("Cannot find track for scrobbling", "id", s.TrackID, "user", username, err)
|
||||
continue
|
||||
}
|
||||
err = p.incPlay(ctx, mf, s.Timestamp)
|
||||
if err != nil {
|
||||
log.Error("Error updating play counts", "id", mf.ID, "track", mf.Title, "user", username, err)
|
||||
} else {
|
||||
success++
|
||||
event.With("song", mf.ID).With("album", mf.AlbumID).With("artist", mf.AlbumArtistID)
|
||||
log.Info("Scrobbled", "title", mf.Title, "artist", mf.Artist, "user", username)
|
||||
if player.ScrobbleEnabled {
|
||||
_ = p.dispatchScrobble(ctx, mf, s.Timestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if success > 0 {
|
||||
p.broker.SendMessage(ctx, event)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *playTracker) incPlay(ctx context.Context, track *model.MediaFile, timestamp time.Time) error {
|
||||
return p.ds.WithTx(func(tx model.DataStore) error {
|
||||
err := p.ds.MediaFile(ctx).IncPlayCount(track.ID, timestamp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = p.ds.Album(ctx).IncPlayCount(track.AlbumID, timestamp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = p.ds.Artist(ctx).IncPlayCount(track.ArtistID, timestamp)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile, playTime time.Time) error {
|
||||
u, _ := request.UserFrom(ctx)
|
||||
scrobble := Scrobble{MediaFile: *t, TimeStamp: playTime}
|
||||
for name, s := range p.scrobblers {
|
||||
if !s.IsAuthorized(ctx, u.ID) {
|
||||
continue
|
||||
}
|
||||
if conf.Server.DevEnableBufferedScrobble {
|
||||
log.Debug(ctx, "Buffering scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist)
|
||||
} else {
|
||||
log.Debug(ctx, "Sending scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist)
|
||||
}
|
||||
err := s.Scrobble(ctx, u.ID, scrobble)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error sending Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var constructors map[string]Constructor
|
||||
|
||||
func Register(name string, init Constructor) {
|
||||
if constructors == nil {
|
||||
constructors = make(map[string]Constructor)
|
||||
}
|
||||
constructors[name] = init
|
||||
}
|
||||
207
core/scrobbler/play_tracker_test.go
Normal file
207
core/scrobbler/play_tracker_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package scrobbler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("PlayTracker", func() {
|
||||
var ctx context.Context
|
||||
var ds model.DataStore
|
||||
var tracker PlayTracker
|
||||
var track model.MediaFile
|
||||
var album model.Album
|
||||
var artist model.Artist
|
||||
var fake fakeScrobbler
|
||||
|
||||
BeforeEach(func() {
|
||||
// Remove buffering to simplify tests
|
||||
conf.Server.DevEnableBufferedScrobble = false
|
||||
|
||||
ctx = context.Background()
|
||||
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
|
||||
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
|
||||
ds = &tests.MockDataStore{}
|
||||
fake = fakeScrobbler{Authorized: true}
|
||||
Register("fake", func(ds model.DataStore) Scrobbler {
|
||||
return &fake
|
||||
})
|
||||
tracker = GetPlayTracker(ds, events.GetBroker())
|
||||
|
||||
track = model.MediaFile{
|
||||
ID: "123",
|
||||
Title: "Track Title",
|
||||
Album: "Track Album",
|
||||
AlbumID: "al-1",
|
||||
Artist: "Track Artist",
|
||||
ArtistID: "ar-1",
|
||||
AlbumArtist: "Track AlbumArtist",
|
||||
TrackNumber: 1,
|
||||
Duration: 180,
|
||||
MbzTrackID: "mbz-123",
|
||||
}
|
||||
_ = ds.MediaFile(ctx).Put(&track)
|
||||
artist = model.Artist{ID: "ar-1"}
|
||||
_ = ds.Artist(ctx).Put(&artist)
|
||||
album = model.Album{ID: "al-1"}
|
||||
_ = ds.Album(ctx).(*tests.MockAlbumRepo).Put(&album)
|
||||
})
|
||||
|
||||
Describe("NowPlaying", func() {
|
||||
It("sends track to agent", func() {
|
||||
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fake.NowPlayingCalled).To(BeTrue())
|
||||
Expect(fake.UserID).To(Equal("u-1"))
|
||||
Expect(fake.Track.ID).To(Equal("123"))
|
||||
})
|
||||
It("does not send track to agent if user has not authorized", func() {
|
||||
fake.Authorized = false
|
||||
|
||||
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fake.NowPlayingCalled).To(BeFalse())
|
||||
})
|
||||
It("does not send track to agent if player is not enabled to send scrobbles", func() {
|
||||
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: false})
|
||||
|
||||
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fake.NowPlayingCalled).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetNowPlaying", func() {
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
})
|
||||
It("returns current playing music", func() {
|
||||
track2 := track
|
||||
track2.ID = "456"
|
||||
_ = ds.MediaFile(ctx).Put(&track)
|
||||
ctx = request.WithUser(ctx, model.User{UserName: "user-1"})
|
||||
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123")
|
||||
ctx = request.WithUser(ctx, model.User{UserName: "user-2"})
|
||||
_ = tracker.NowPlaying(ctx, "player-2", "player-two", "456")
|
||||
|
||||
playing, err := tracker.GetNowPlaying(ctx)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(playing).To(HaveLen(2))
|
||||
Expect(playing[0].PlayerId).To(Equal("player-2"))
|
||||
Expect(playing[0].PlayerName).To(Equal("player-two"))
|
||||
Expect(playing[0].Username).To(Equal("user-2"))
|
||||
Expect(playing[0].TrackID).To(Equal("456"))
|
||||
|
||||
Expect(playing[1].PlayerId).To(Equal("player-1"))
|
||||
Expect(playing[1].PlayerName).To(Equal("player-one"))
|
||||
Expect(playing[1].Username).To(Equal("user-1"))
|
||||
Expect(playing[1].TrackID).To(Equal("123"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Submit", func() {
|
||||
It("sends track to agent", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1"})
|
||||
ts := time.Now()
|
||||
|
||||
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fake.ScrobbleCalled).To(BeTrue())
|
||||
Expect(fake.UserID).To(Equal("u-1"))
|
||||
Expect(fake.LastScrobble.ID).To(Equal("123"))
|
||||
})
|
||||
|
||||
It("increments play counts in the DB", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1"})
|
||||
ts := time.Now()
|
||||
|
||||
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(track.PlayCount).To(Equal(int64(1)))
|
||||
Expect(album.PlayCount).To(Equal(int64(1)))
|
||||
Expect(artist.PlayCount).To(Equal(int64(1)))
|
||||
})
|
||||
|
||||
It("does not send track to agent if user has not authorized", func() {
|
||||
fake.Authorized = false
|
||||
|
||||
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fake.ScrobbleCalled).To(BeFalse())
|
||||
})
|
||||
|
||||
It("does not send track to agent player is not enabled to send scrobbles", func() {
|
||||
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: false})
|
||||
|
||||
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fake.ScrobbleCalled).To(BeFalse())
|
||||
})
|
||||
|
||||
It("increments play counts even if it cannot scrobble", func() {
|
||||
fake.Error = errors.New("error")
|
||||
|
||||
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fake.ScrobbleCalled).To(BeFalse())
|
||||
|
||||
Expect(track.PlayCount).To(Equal(int64(1)))
|
||||
Expect(album.PlayCount).To(Equal(int64(1)))
|
||||
Expect(artist.PlayCount).To(Equal(int64(1)))
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
type fakeScrobbler struct {
|
||||
Authorized bool
|
||||
NowPlayingCalled bool
|
||||
ScrobbleCalled bool
|
||||
UserID string
|
||||
Track *model.MediaFile
|
||||
LastScrobble Scrobble
|
||||
Error error
|
||||
}
|
||||
|
||||
func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
return f.Error == nil && f.Authorized
|
||||
}
|
||||
|
||||
func (f *fakeScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error {
|
||||
f.NowPlayingCalled = true
|
||||
if f.Error != nil {
|
||||
return f.Error
|
||||
}
|
||||
f.UserID = userId
|
||||
f.Track = track
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error {
|
||||
f.ScrobbleCalled = true
|
||||
if f.Error != nil {
|
||||
return f.Error
|
||||
}
|
||||
f.UserID = userId
|
||||
f.LastScrobble = s
|
||||
return nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package app
|
||||
package scrobbler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestApp(t *testing.T) {
|
||||
func TestAgents(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelCritical)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "RESTful API Suite")
|
||||
RunSpecs(t, "Scrobbler Test Suite")
|
||||
}
|
||||
54
core/share.go
Normal file
54
core/share.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
gonanoid "github.com/matoous/go-nanoid"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type Share interface {
|
||||
NewRepository(ctx context.Context) rest.Repository
|
||||
}
|
||||
|
||||
func NewShare(ds model.DataStore) Share {
|
||||
return &shareService{
|
||||
ds: ds,
|
||||
}
|
||||
}
|
||||
|
||||
type shareService struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func (s *shareService) NewRepository(ctx context.Context) rest.Repository {
|
||||
repo := s.ds.Share(ctx)
|
||||
wrapper := &shareRepositoryWrapper{
|
||||
ShareRepository: repo,
|
||||
Repository: repo.(rest.Repository),
|
||||
Persistable: repo.(rest.Persistable),
|
||||
}
|
||||
return wrapper
|
||||
}
|
||||
|
||||
type shareRepositoryWrapper struct {
|
||||
model.ShareRepository
|
||||
rest.Repository
|
||||
rest.Persistable
|
||||
}
|
||||
|
||||
func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
|
||||
s := entity.(*model.Share)
|
||||
id, err := gonanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 9)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.Name = id
|
||||
id, err = r.Persistable.Save(s)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (r *shareRepositoryWrapper) Update(entity interface{}, _ ...string) error {
|
||||
return r.Persistable.Update(entity, "description")
|
||||
}
|
||||
51
core/share_test.go
Normal file
51
core/share_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Share", func() {
|
||||
var ds model.DataStore
|
||||
var share Share
|
||||
var mockedRepo rest.Persistable
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
mockedRepo = ds.Share(context.Background()).(rest.Persistable)
|
||||
share = NewShare(ds)
|
||||
})
|
||||
|
||||
Describe("NewRepository", func() {
|
||||
var repo rest.Persistable
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = share.NewRepository(context.Background()).(rest.Persistable)
|
||||
})
|
||||
|
||||
Describe("Save", func() {
|
||||
It("it adds a random name", func() {
|
||||
entity := &model.Share{Description: "test"}
|
||||
id, err := repo.Save(entity)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(id).ToNot(BeEmpty())
|
||||
Expect(entity.Name).ToNot(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Update", func() {
|
||||
It("filters out read-only fields", func() {
|
||||
entity := "entity"
|
||||
err := repo.Update(entity)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockedRepo.(*tests.MockShareRepo).Entity).To(Equal("entity"))
|
||||
Expect(mockedRepo.(*tests.MockShareRepo).Cols).To(ConsistOf("description"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -16,19 +16,15 @@ type Transcoder interface {
|
||||
}
|
||||
|
||||
func New() Transcoder {
|
||||
_, err := exec.LookPath("ffmpeg")
|
||||
if err != nil {
|
||||
log.Error("Unable to find ffmpeg", err)
|
||||
}
|
||||
return &ffmpeg{}
|
||||
return &externalTranscoder{}
|
||||
}
|
||||
|
||||
type ffmpeg struct{}
|
||||
type externalTranscoder struct{}
|
||||
|
||||
func (ff *ffmpeg) Start(ctx context.Context, command, path string, maxBitRate int) (f io.ReadCloser, err error) {
|
||||
func (e *externalTranscoder) 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)
|
||||
log.Trace(ctx, "Executing transcoding command", "cmd", args)
|
||||
cmd := exec.Command(args[0], args[1:]...) // #nosec
|
||||
cmd.Stderr = os.Stderr
|
||||
if f, err = cmd.StdoutPipe(); err != nil {
|
||||
@@ -47,8 +43,8 @@ func (ff *ffmpeg) Start(ctx context.Context, command, path string, maxBitRate in
|
||||
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)
|
||||
s = strings.ReplaceAll(s, "%s", path)
|
||||
s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate))
|
||||
split[i] = s
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package core
|
||||
|
||||
import (
|
||||
"github.com/google/wire"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/core/transcoder"
|
||||
)
|
||||
|
||||
@@ -11,9 +13,11 @@ var Set = wire.NewSet(
|
||||
GetTranscodingCache,
|
||||
GetImageCache,
|
||||
NewArchiver,
|
||||
NewNowPlayingRepository,
|
||||
NewExternalMetadata,
|
||||
NewCacheWarmer,
|
||||
NewPlayers,
|
||||
agents.New,
|
||||
transcoder.New,
|
||||
scrobbler.GetPlayTracker,
|
||||
NewShare,
|
||||
)
|
||||
|
||||
61
db/db.go
61
db/db.go
@@ -2,13 +2,14 @@ package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
_ "github.com/navidrome/navidrome/db/migration"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
@@ -17,26 +18,21 @@ var (
|
||||
Path string
|
||||
)
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
db *sql.DB
|
||||
)
|
||||
|
||||
func Db() *sql.DB {
|
||||
once.Do(func() {
|
||||
var err error
|
||||
instance := singleton.Get(&sql.DB{}, func() interface{} {
|
||||
Path = conf.Server.DbPath
|
||||
if Path == ":memory:" {
|
||||
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)
|
||||
instance, err := sql.Open(Driver, Path)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return instance
|
||||
})
|
||||
return db
|
||||
return instance.(*sql.DB)
|
||||
}
|
||||
|
||||
func EnsureLatestVersion() {
|
||||
@@ -54,6 +50,9 @@ func EnsureLatestVersion() {
|
||||
log.Error("Error disabling foreign_keys", err)
|
||||
}
|
||||
|
||||
gooseLogger := &logAdapter{silent: isSchemaEmpty(db)}
|
||||
goose.SetLogger(gooseLogger)
|
||||
|
||||
err = goose.SetDialect(Driver)
|
||||
if err != nil {
|
||||
log.Error("Invalid DB driver", "driver", Driver, err)
|
||||
@@ -65,3 +64,45 @@ func EnsureLatestVersion() {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func isSchemaEmpty(db *sql.DB) bool { // nolint:interfacer
|
||||
rows, err := db.Query("SELECT name FROM sqlite_master WHERE type='table' AND name='goose_db_version';") // nolint:rowserrcheck
|
||||
if err != nil {
|
||||
log.Error("Database could not be opened!", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer rows.Close()
|
||||
return !rows.Next()
|
||||
}
|
||||
|
||||
type logAdapter struct {
|
||||
silent bool
|
||||
}
|
||||
|
||||
func (l *logAdapter) Fatal(v ...interface{}) {
|
||||
log.Error(fmt.Sprint(v...))
|
||||
os.Exit(-1)
|
||||
}
|
||||
|
||||
func (l *logAdapter) Fatalf(format string, v ...interface{}) {
|
||||
log.Error(fmt.Sprintf(format, v...))
|
||||
os.Exit(-1)
|
||||
}
|
||||
|
||||
func (l *logAdapter) Print(v ...interface{}) {
|
||||
if !l.silent {
|
||||
log.Info(fmt.Sprint(v...))
|
||||
}
|
||||
}
|
||||
|
||||
func (l *logAdapter) Println(v ...interface{}) {
|
||||
if !l.silent {
|
||||
log.Info(fmt.Sprintln(v...))
|
||||
}
|
||||
}
|
||||
|
||||
func (l *logAdapter) Printf(format string, v ...interface{}) {
|
||||
if !l.silent {
|
||||
log.Info(fmt.Sprintf(format, v...))
|
||||
}
|
||||
}
|
||||
|
||||
36
db/db_test.go
Normal file
36
db/db_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestDB(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelCritical)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "DB Suite")
|
||||
}
|
||||
|
||||
var _ = Describe("isSchemaEmpty", func() {
|
||||
var db *sql.DB
|
||||
BeforeEach(func() {
|
||||
path := "file::memory:"
|
||||
db, _ = sql.Open(Driver, path)
|
||||
})
|
||||
|
||||
It("returns false if the goose metadata table is found", func() {
|
||||
_, err := db.Exec("create table goose_db_version (id primary key);")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(isSchemaEmpty(db)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns true if the schema is brand new", func() {
|
||||
Expect(isSchemaEmpty(db)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
@@ -42,13 +42,14 @@ select a.id, a.name, a.artist_id, a.album_artist_id, group_concat(mf.artist_id,
|
||||
return err
|
||||
}
|
||||
|
||||
var id, name, artistId, albumArtistId, songArtistIds string
|
||||
var id, name, artistId, albumArtistId string
|
||||
var songArtistIds sql.NullString
|
||||
for rows.Next() {
|
||||
err := rows.Scan(&id, &name, &artistId, &albumArtistId, &songArtistIds)
|
||||
err = rows.Scan(&id, &name, &artistId, &albumArtistId, &songArtistIds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
all := utils.SanitizeStrings(artistId, albumArtistId, songArtistIds)
|
||||
all := utils.SanitizeStrings(artistId, albumArtistId, songArtistIds.String)
|
||||
_, err = stmt.Exec(all, id)
|
||||
if err != nil {
|
||||
log.Error("Error setting album's artist_ids", "album", name, "albumId", id, err)
|
||||
|
||||
66
db/migration/20210418232815_fix_album_comments.go
Normal file
66
db/migration/20210418232815_fix_album_comments.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upFixAlbumComments, downFixAlbumComments)
|
||||
}
|
||||
|
||||
func upFixAlbumComments(tx *sql.Tx) error {
|
||||
const zwsp = string('\u200b')
|
||||
rows, err := tx.Query(`
|
||||
SELECT album.id, group_concat(media_file.comment, '` + zwsp + `') FROM album, media_file WHERE media_file.album_id = album.id GROUP BY album.id;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
stmt, err := tx.Prepare("UPDATE album SET comment = ? WHERE id = ?")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var id string
|
||||
var comments sql.NullString
|
||||
|
||||
for rows.Next() {
|
||||
err = rows.Scan(&id, &comments)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !comments.Valid {
|
||||
continue
|
||||
}
|
||||
comment := getComment(comments.String, zwsp)
|
||||
_, err = stmt.Exec(comment, id)
|
||||
|
||||
if err != nil {
|
||||
log.Error("Error setting album's comments", "albumId", id, err)
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func downFixAlbumComments(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func getComment(comments string, separator string) string {
|
||||
cs := strings.Split(comments, separator)
|
||||
if len(cs) == 0 {
|
||||
return ""
|
||||
}
|
||||
first := cs[0]
|
||||
for _, c := range cs[1:] {
|
||||
if first != c {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return first
|
||||
}
|
||||
30
db/migration/20210430212322_add_bpm_metadata.go
Normal file
30
db/migration/20210430212322_add_bpm_metadata.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upAddBpmMetadata, downAddBpmMetadata)
|
||||
}
|
||||
|
||||
func upAddBpmMetadata(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table media_file
|
||||
add bpm integer;
|
||||
|
||||
create index if not exists media_file_bpm
|
||||
on media_file (bpm);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan needs to be performed to import more tags")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func downAddBpmMetadata(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
34
db/migration/20210530121921_create_shares_table.go
Normal file
34
db/migration/20210530121921_create_shares_table.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upCreateSharesTable, downCreateSharesTable)
|
||||
}
|
||||
|
||||
func upCreateSharesTable(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table share
|
||||
(
|
||||
id varchar(255) not null primary key,
|
||||
name varchar(255) not null unique,
|
||||
description varchar(255),
|
||||
expires datetime,
|
||||
created datetime,
|
||||
last_visited datetime,
|
||||
resource_ids varchar not null,
|
||||
resource_type varchar(255) not null,
|
||||
visit_count integer default 0
|
||||
);
|
||||
`)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func downCreateSharesTable(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
25
db/migration/20210601231734_update_share_fieldnames.go
Normal file
25
db/migration/20210601231734_update_share_fieldnames.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upUpdateShareFieldNames, downUpdateShareFieldNames)
|
||||
}
|
||||
|
||||
func upUpdateShareFieldNames(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table share rename column expires to expires_at;
|
||||
alter table share rename column created to created_at;
|
||||
alter table share rename column last_visited to last_visited_at;
|
||||
`)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func downUpdateShareFieldNames(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
56
db/migration/20210616150710_encrypt_all_passwords.go
Normal file
56
db/migration/20210616150710_encrypt_all_passwords.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upEncodeAllPasswords, downEncodeAllPasswords)
|
||||
}
|
||||
|
||||
func upEncodeAllPasswords(tx *sql.Tx) error {
|
||||
rows, err := tx.Query(`SELECT id, user_name, password from user;`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
stmt, err := tx.Prepare("UPDATE user SET password = ? WHERE id = ?")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var id string
|
||||
var username, password string
|
||||
|
||||
data := sha256.Sum256([]byte(consts.DefaultEncryptionKey))
|
||||
encKey := data[0:]
|
||||
|
||||
for rows.Next() {
|
||||
err = rows.Scan(&id, &username, &password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
password, err = utils.Encrypt(context.Background(), encKey, password)
|
||||
if err != nil {
|
||||
log.Error("Error encrypting user's password", "id", id, "username", username, err)
|
||||
}
|
||||
|
||||
_, err = stmt.Exec(password, id)
|
||||
if err != nil {
|
||||
log.Error("Error saving user's encrypted password", "id", id, "username", username, err)
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func downEncodeAllPasswords(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upDropPlayerNameUniqueConstraint, downDropPlayerNameUniqueConstraint)
|
||||
}
|
||||
|
||||
func upDropPlayerNameUniqueConstraint(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table player_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
name varchar not null,
|
||||
user_agent 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,
|
||||
report_real_path bool default FALSE not null
|
||||
);
|
||||
|
||||
insert into player_dg_tmp(id, name, user_agent, user_name, client, ip_address, last_seen, max_bit_rate, transcoding_id, report_real_path) select id, name, type, user_name, client, ip_address, last_seen, max_bit_rate, transcoding_id, report_real_path from player;
|
||||
|
||||
drop table player;
|
||||
|
||||
alter table player_dg_tmp rename to player;
|
||||
create index if not exists player_match
|
||||
on player (client, user_agent, user_name);
|
||||
create index if not exists player_name
|
||||
on player (name);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downDropPlayerNameUniqueConstraint(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upAddUserPrefsPlayerScrobblerEnabled, downAddUserPrefsPlayerScrobblerEnabled)
|
||||
}
|
||||
|
||||
func upAddUserPrefsPlayerScrobblerEnabled(tx *sql.Tx) error {
|
||||
err := upAddUserPrefs(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return upPlayerScrobblerEnabled(tx)
|
||||
}
|
||||
|
||||
func upAddUserPrefs(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table user_props
|
||||
(
|
||||
user_id varchar not null,
|
||||
key varchar not null,
|
||||
value varchar,
|
||||
constraint user_props_pk
|
||||
primary key (user_id, key)
|
||||
);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func upPlayerScrobblerEnabled(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table player add scrobble_enabled bool default true;
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddUserPrefsPlayerScrobblerEnabled(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upAddReferentialIntegrityToUserProps, downAddReferentialIntegrityToUserProps)
|
||||
}
|
||||
|
||||
func upAddReferentialIntegrityToUserProps(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table user_props_dg_tmp
|
||||
(
|
||||
user_id varchar not null
|
||||
constraint user_props_user_id_fk
|
||||
references user
|
||||
on update cascade on delete cascade,
|
||||
key varchar not null,
|
||||
value varchar,
|
||||
constraint user_props_pk
|
||||
primary key (user_id, key)
|
||||
);
|
||||
|
||||
insert into user_props_dg_tmp(user_id, key, value) select user_id, key, value from user_props;
|
||||
|
||||
drop table user_props;
|
||||
|
||||
alter table user_props_dg_tmp rename to user_props;
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddReferentialIntegrityToUserProps(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
38
db/migration/20210626213026_add_scrobble_buffer.go
Normal file
38
db/migration/20210626213026_add_scrobble_buffer.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upAddScrobbleBuffer, downAddScrobbleBuffer)
|
||||
}
|
||||
|
||||
func upAddScrobbleBuffer(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table if not exists scrobble_buffer
|
||||
(
|
||||
user_id varchar not null
|
||||
constraint scrobble_buffer_user_id_fk
|
||||
references user
|
||||
on update cascade on delete cascade,
|
||||
service varchar not null,
|
||||
media_file_id varchar not null
|
||||
constraint scrobble_buffer_media_file_id_fk
|
||||
references media_file
|
||||
on update cascade on delete cascade,
|
||||
play_time datetime not null,
|
||||
enqueue_time datetime not null default current_timestamp,
|
||||
constraint scrobble_buffer_pk
|
||||
unique (user_id, service, media_file_id, play_time, user_id)
|
||||
);
|
||||
`)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddScrobbleBuffer(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
68
db/migration/20210715151153_add_genre_tables.go
Normal file
68
db/migration/20210715151153_add_genre_tables.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upAddGenreTables, downAddGenreTables)
|
||||
}
|
||||
|
||||
func upAddGenreTables(tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed to import multiple genres!")
|
||||
_, err := tx.Exec(`
|
||||
create table if not exists genre
|
||||
(
|
||||
id varchar not null primary key,
|
||||
name varchar not null,
|
||||
constraint genre_name_ux
|
||||
unique (name)
|
||||
);
|
||||
|
||||
create table if not exists album_genres
|
||||
(
|
||||
album_id varchar default null not null
|
||||
references album
|
||||
on delete cascade,
|
||||
genre_id varchar default null not null
|
||||
references genre
|
||||
on delete cascade,
|
||||
constraint album_genre_ux
|
||||
unique (album_id, genre_id)
|
||||
);
|
||||
|
||||
create table if not exists media_file_genres
|
||||
(
|
||||
media_file_id varchar default null not null
|
||||
references media_file
|
||||
on delete cascade,
|
||||
genre_id varchar default null not null
|
||||
references genre
|
||||
on delete cascade,
|
||||
constraint media_file_genre_ux
|
||||
unique (media_file_id, genre_id)
|
||||
);
|
||||
|
||||
create table if not exists artist_genres
|
||||
(
|
||||
artist_id varchar default null not null
|
||||
references artist
|
||||
on delete cascade,
|
||||
genre_id varchar default null not null
|
||||
references genre
|
||||
on delete cascade,
|
||||
constraint artist_genre_ux
|
||||
unique (artist_id, genre_id)
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func downAddGenreTables(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
30
db/migration/20210821212604_add_mediafile_channels.go
Normal file
30
db/migration/20210821212604_add_mediafile_channels.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upAddMediafileChannels, downAddMediafileChannels)
|
||||
}
|
||||
|
||||
func upAddMediafileChannels(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table media_file
|
||||
add channels integer;
|
||||
|
||||
create index if not exists media_file_channels
|
||||
on media_file (channels);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan needs to be performed to import more tags")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func downAddMediafileChannels(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
@@ -10,9 +10,9 @@
|
||||
#
|
||||
# This script does not handle file names that contain spaces.
|
||||
|
||||
gofmtcmd=go run golang.org/x/tools/cmd/goimports
|
||||
gofmtcmd="go run golang.org/x/tools/cmd/goimports"
|
||||
|
||||
gofiles=$(git diff --cached --name-only --diff-filter=ACM | grep '.go$')
|
||||
gofiles=$(git diff --cached --name-only --diff-filter=ACM | grep '.go$' | grep -v '_gen.go$')
|
||||
[ -z "$gofiles" ] && exit 0
|
||||
|
||||
unformatted=$($gofmtcmd -l $gofiles)
|
||||
|
||||
62
go.mod
62
go.mod
@@ -4,55 +4,53 @@ go 1.16
|
||||
|
||||
require (
|
||||
code.cloudfoundry.org/go-diodes v0.0.0-20190809170250-f77fb823c7ee
|
||||
github.com/ClickHouse/clickhouse-go v1.4.3 // indirect
|
||||
github.com/ClickHouse/clickhouse-go v1.4.5 // indirect
|
||||
github.com/Masterminds/squirrel v1.5.0
|
||||
github.com/ReneKroon/ttlcache/v2 v2.3.0
|
||||
github.com/ReneKroon/ttlcache/v2 v2.8.1
|
||||
github.com/astaxie/beego v1.12.3
|
||||
github.com/bradleyjkemp/cupaloy v2.3.0+incompatible
|
||||
github.com/cespare/reflex v0.3.0
|
||||
github.com/deluan/rest v0.0.0-20200327222046-b71e558c45d0
|
||||
github.com/denisenkom/go-mssqldb v0.9.0 // indirect
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible
|
||||
github.com/cespare/reflex v0.3.1
|
||||
github.com/deluan/rest v0.0.0-20210503015435-e7091d44f0ba
|
||||
github.com/denisenkom/go-mssqldb v0.10.0 // indirect
|
||||
github.com/dhowden/tag v0.0.0-20200412032933-5d76b8eaae27
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/djherbis/atime v1.1.0
|
||||
github.com/djherbis/fscache v0.10.2-0.20201024185917-a0daa9e52747
|
||||
github.com/dustin/go-humanize v1.0.0
|
||||
github.com/go-chi/chi v1.5.1
|
||||
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/golangci/golangci-lint v1.38.0
|
||||
github.com/google/uuid v1.2.0
|
||||
github.com/fatih/structs v1.1.0
|
||||
github.com/go-chi/chi/v5 v5.0.4
|
||||
github.com/go-chi/cors v1.2.0
|
||||
github.com/go-chi/httprate v0.5.1
|
||||
github.com/go-chi/jwtauth/v5 v5.0.2
|
||||
github.com/go-sql-driver/mysql v1.6.0 // indirect
|
||||
github.com/golangci/golangci-lint v1.42.1
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/google/wire v0.5.0
|
||||
github.com/karrick/godirwalk v1.16.1
|
||||
github.com/kennygrant/sanitize v0.0.0-20170120101633-6a0bfdde8629
|
||||
github.com/kr/pretty v0.2.1
|
||||
github.com/kr/pretty v0.3.0
|
||||
github.com/lestrrat-go/jwx v1.2.7
|
||||
github.com/matoous/go-nanoid v1.5.0
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible
|
||||
github.com/microcosm-cc/bluemonday v1.0.4
|
||||
github.com/mitchellh/mapstructure v1.3.2 // indirect
|
||||
github.com/mattn/go-zglob v0.0.3
|
||||
github.com/microcosm-cc/bluemonday v1.0.15
|
||||
github.com/mileusna/useragent v1.0.2
|
||||
github.com/oklog/run v1.1.0
|
||||
github.com/onsi/ginkgo v1.15.2
|
||||
github.com/onsi/gomega v1.11.0
|
||||
github.com/pelletier/go-toml v1.8.0 // indirect
|
||||
github.com/onsi/ginkgo v1.16.4
|
||||
github.com/onsi/gomega v1.16.0
|
||||
github.com/pressly/goose v2.7.0+incompatible
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sirupsen/logrus v1.8.1
|
||||
github.com/spf13/afero v1.3.1 // indirect
|
||||
github.com/spf13/cast v1.3.1 // indirect
|
||||
github.com/spf13/cobra v1.1.3
|
||||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||
github.com/spf13/viper v1.7.1
|
||||
github.com/unrolled/secure v1.0.8
|
||||
github.com/spf13/cobra v1.2.1
|
||||
github.com/spf13/viper v1.9.0
|
||||
github.com/stretchr/testify v1.7.0
|
||||
github.com/unrolled/secure v1.0.9
|
||||
github.com/xrash/smetrics v0.0.0-20200730060457-89a2a8a1fb0b
|
||||
github.com/ziutek/mymysql v1.5.4 // indirect
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8
|
||||
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4
|
||||
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4 // indirect
|
||||
golang.org/x/text v0.3.5 // indirect
|
||||
golang.org/x/tools v0.1.0
|
||||
google.golang.org/protobuf v1.25.0 // indirect
|
||||
gopkg.in/djherbis/atime.v1 v1.0.0
|
||||
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d
|
||||
golang.org/x/tools v0.1.7
|
||||
gopkg.in/djherbis/atime.v1 v1.0.0 // indirect
|
||||
gopkg.in/djherbis/stream.v1 v1.3.1
|
||||
gopkg.in/ini.v1 v1.57.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/dhowden/tag => github.com/wader/tag v0.0.0-20200426234345-d072771f6a51
|
||||
|
||||
126
log/log.go
126
log/log.go
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -16,6 +17,28 @@ type Level uint8
|
||||
|
||||
type LevelFunc = func(ctx interface{}, msg interface{}, keyValuePairs ...interface{})
|
||||
|
||||
var redacted = &Hook{
|
||||
AcceptedLevels: logrus.AllLevels,
|
||||
RedactionList: []string{
|
||||
// Keys from the config
|
||||
"(ApiKey:\")[\\w]*",
|
||||
"(Secret:\")[\\w]*",
|
||||
"(Spotify.*ID:\")[\\w]*",
|
||||
"(PasswordEncryptionKey:[\\s]*\")[^\"]*",
|
||||
|
||||
// UI appConfig
|
||||
"(subsonicToken:)[\\w]+(\\s)",
|
||||
"(subsonicSalt:)[\\w]+(\\s)",
|
||||
"(token:)[^\\s]+",
|
||||
|
||||
// Subsonic query params
|
||||
"([^\\w]t=)[\\w]+",
|
||||
"([^\\w]s=)[^&]+",
|
||||
"([^\\w]p=)[^&]+",
|
||||
"([^\\w]jwt=)[^&]+",
|
||||
},
|
||||
}
|
||||
|
||||
const (
|
||||
LevelCritical = Level(logrus.FatalLevel)
|
||||
LevelError = Level(logrus.ErrorLevel)
|
||||
@@ -29,19 +52,32 @@ type contextKey string
|
||||
|
||||
const loggerCtxKey = contextKey("logger")
|
||||
|
||||
type levelPath struct {
|
||||
path string
|
||||
level Level
|
||||
}
|
||||
|
||||
var (
|
||||
currentLevel Level
|
||||
defaultLogger = logrus.New()
|
||||
logSourceLine = false
|
||||
rootPath string
|
||||
logLevels []levelPath
|
||||
)
|
||||
|
||||
// SetLevel sets the global log level used by the simple logger.
|
||||
func SetLevel(l Level) {
|
||||
currentLevel = l
|
||||
defaultLogger.Level = logrus.TraceLevel
|
||||
logrus.SetLevel(logrus.Level(l))
|
||||
}
|
||||
|
||||
func SetLevelString(l string) {
|
||||
level := levelFromString(l)
|
||||
SetLevel(level)
|
||||
}
|
||||
|
||||
func levelFromString(l string) Level {
|
||||
envLevel := strings.ToLower(l)
|
||||
var level Level
|
||||
switch envLevel {
|
||||
@@ -58,13 +94,34 @@ func SetLevelString(l string) {
|
||||
default:
|
||||
level = LevelInfo
|
||||
}
|
||||
SetLevel(level)
|
||||
return level
|
||||
}
|
||||
|
||||
func SetLogLevels(levels map[string]string) {
|
||||
for k, v := range levels {
|
||||
logLevels = append(logLevels, levelPath{path: k, level: levelFromString(v)})
|
||||
}
|
||||
sort.Slice(logLevels, func(i, j int) bool {
|
||||
return logLevels[i].path > logLevels[j].path
|
||||
})
|
||||
}
|
||||
|
||||
func SetLogSourceLine(enabled bool) {
|
||||
logSourceLine = enabled
|
||||
}
|
||||
|
||||
func SetRedacting(enabled bool) {
|
||||
if enabled {
|
||||
defaultLogger.AddHook(redacted)
|
||||
}
|
||||
}
|
||||
|
||||
// Redact applies redaction to a single string
|
||||
func Redact(msg string) string {
|
||||
r, _ := redacted.redact(msg)
|
||||
return r
|
||||
}
|
||||
|
||||
func NewContext(ctx context.Context, keyValuePairs ...interface{}) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
@@ -85,43 +142,53 @@ func CurrentLevel() Level {
|
||||
}
|
||||
|
||||
func Error(args ...interface{}) {
|
||||
if currentLevel < LevelError {
|
||||
return
|
||||
}
|
||||
logger, msg := parseArgs(args)
|
||||
logger.Error(msg)
|
||||
log(LevelError, args...)
|
||||
}
|
||||
|
||||
func Warn(args ...interface{}) {
|
||||
if currentLevel < LevelWarn {
|
||||
return
|
||||
}
|
||||
logger, msg := parseArgs(args)
|
||||
logger.Warn(msg)
|
||||
log(LevelWarn, args...)
|
||||
}
|
||||
|
||||
func Info(args ...interface{}) {
|
||||
if currentLevel < LevelInfo {
|
||||
return
|
||||
}
|
||||
logger, msg := parseArgs(args)
|
||||
logger.Info(msg)
|
||||
log(LevelInfo, args...)
|
||||
}
|
||||
|
||||
func Debug(args ...interface{}) {
|
||||
if currentLevel < LevelDebug {
|
||||
return
|
||||
}
|
||||
logger, msg := parseArgs(args)
|
||||
logger.Debug(msg)
|
||||
log(LevelDebug, args...)
|
||||
}
|
||||
|
||||
func Trace(args ...interface{}) {
|
||||
if currentLevel < LevelTrace {
|
||||
log(LevelTrace, args...)
|
||||
}
|
||||
|
||||
func log(level Level, args ...interface{}) {
|
||||
if !shouldLog(level) {
|
||||
return
|
||||
}
|
||||
logger, msg := parseArgs(args)
|
||||
logger.Trace(msg)
|
||||
logger.Log(logrus.Level(level), msg)
|
||||
}
|
||||
|
||||
func shouldLog(requiredLevel Level) bool {
|
||||
if currentLevel >= requiredLevel {
|
||||
return true
|
||||
}
|
||||
if len(logLevels) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
_, file, _, ok := runtime.Caller(3)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
file = strings.TrimPrefix(file, rootPath)
|
||||
for _, lp := range logLevels {
|
||||
if strings.HasPrefix(file, lp.path) {
|
||||
return lp.level >= requiredLevel
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseArgs(args []interface{}) (*logrus.Entry, string) {
|
||||
@@ -143,7 +210,7 @@ func parseArgs(args []interface{}) (*logrus.Entry, string) {
|
||||
l = addFields(l, kvPairs)
|
||||
}
|
||||
if logSourceLine {
|
||||
_, file, line, ok := runtime.Caller(2)
|
||||
_, file, line, ok := runtime.Caller(3)
|
||||
if !ok {
|
||||
file = "???"
|
||||
line = 0
|
||||
@@ -203,8 +270,15 @@ func extractLogger(ctx interface{}) (*logrus.Entry, error) {
|
||||
func createNewLogger() *logrus.Entry {
|
||||
//logrus.SetFormatter(&logrus.TextFormatter{ForceColors: true, DisableTimestamp: false, FullTimestamp: true})
|
||||
//l.Formatter = &logrus.TextFormatter{ForceColors: true, DisableTimestamp: false, FullTimestamp: true}
|
||||
defaultLogger.Level = logrus.Level(currentLevel)
|
||||
logger := logrus.NewEntry(defaultLogger)
|
||||
logger.Level = logrus.Level(currentLevel)
|
||||
return logger
|
||||
}
|
||||
|
||||
func init() {
|
||||
defaultLogger.Level = logrus.TraceLevel
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rootPath = strings.TrimSuffix(file, "log/log.go")
|
||||
}
|
||||
|
||||
@@ -24,11 +24,11 @@ var _ = Describe("Logger", func() {
|
||||
|
||||
BeforeEach(func() {
|
||||
l, hook = test.NewNullLogger()
|
||||
SetLevel(LevelInfo)
|
||||
SetLevel(LevelTrace)
|
||||
SetDefaultLogger(l)
|
||||
})
|
||||
|
||||
Context("Logging", func() {
|
||||
Describe("Logging", func() {
|
||||
It("logs a simple message", func() {
|
||||
Error("Simple Message")
|
||||
Expect(hook.LastEntry().Message).To(Equal("Simple Message"))
|
||||
@@ -90,13 +90,13 @@ var _ = Describe("Logger", func() {
|
||||
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
|
||||
// NOTE: This assertion breaks if the line number above changes
|
||||
Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:92"))
|
||||
Expect(hook.LastEntry().Message).To(Equal("A crash happened"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Levels", func() {
|
||||
Describe("Levels", func() {
|
||||
BeforeEach(func() {
|
||||
SetLevel(LevelTrace)
|
||||
})
|
||||
@@ -116,13 +116,28 @@ var _ = Describe("Logger", func() {
|
||||
Debug("msg")
|
||||
Expect(hook.LastEntry().Level).To(Equal(logrus.DebugLevel))
|
||||
})
|
||||
It("logs info messages", func() {
|
||||
It("logs trace messages", func() {
|
||||
Trace("msg")
|
||||
Expect(hook.LastEntry().Level).To(Equal(logrus.TraceLevel))
|
||||
})
|
||||
})
|
||||
|
||||
Context("extractLogger", func() {
|
||||
Describe("LogLevels", func() {
|
||||
It("logs at specific levels", func() {
|
||||
SetLevel(LevelError)
|
||||
Debug("message 1")
|
||||
Expect(hook.LastEntry()).To(BeNil())
|
||||
|
||||
SetLogLevels(map[string]string{
|
||||
"log/log_test": "debug",
|
||||
})
|
||||
|
||||
Debug("message 2")
|
||||
Expect(hook.LastEntry().Message).To(Equal("message 2"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("extractLogger", func() {
|
||||
It("returns an error if the context is nil", func() {
|
||||
_, err := extractLogger(nil)
|
||||
Expect(err).ToNot(BeNil())
|
||||
@@ -151,7 +166,7 @@ var _ = Describe("Logger", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Context("SetLevelString", func() {
|
||||
Describe("SetLevelString", func() {
|
||||
It("converts Critical level", func() {
|
||||
SetLevelString("Critical")
|
||||
Expect(CurrentLevel()).To(Equal(LevelCritical))
|
||||
@@ -177,4 +192,11 @@ var _ = Describe("Logger", func() {
|
||||
Expect(CurrentLevel()).To(Equal(LevelTrace))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Redact", func() {
|
||||
Describe("Subsonic API password", func() {
|
||||
msg := "getLyrics.view?v=1.2.0&c=iSub&u=user_name&p=first%20and%20other%20words&title=Title"
|
||||
Expect(Redact(msg)).To(Equal("getLyrics.view?v=1.2.0&c=iSub&u=user_name&p=[REDACTED]&title=Title"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
87
log/redactrus.go
Executable file
87
log/redactrus.go
Executable file
@@ -0,0 +1,87 @@
|
||||
package log
|
||||
|
||||
// Copied from https://github.com/whuang8/redactrus (MIT License)
|
||||
// Copyright (c) 2018 William Huang
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Hook is a logrus hook for redacting information from logs
|
||||
type Hook struct {
|
||||
// Messages with a log level not contained in this array
|
||||
// will not be dispatched. If empty, all messages will be dispatched.
|
||||
AcceptedLevels []logrus.Level
|
||||
RedactionList []string
|
||||
redactionKeys []*regexp.Regexp
|
||||
}
|
||||
|
||||
// Levels returns the user defined AcceptedLevels
|
||||
// If AcceptedLevels is empty, all logrus levels are returned
|
||||
func (h *Hook) Levels() []logrus.Level {
|
||||
if len(h.AcceptedLevels) == 0 {
|
||||
return logrus.AllLevels
|
||||
}
|
||||
return h.AcceptedLevels
|
||||
}
|
||||
|
||||
// Fire redacts values in an log Entry that match
|
||||
// with keys defined in the RedactionList
|
||||
func (h *Hook) Fire(e *logrus.Entry) error {
|
||||
if err := h.initRedaction(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, re := range h.redactionKeys {
|
||||
// Redact based on key matching in Data fields
|
||||
for k, v := range e.Data {
|
||||
if re.MatchString(k) {
|
||||
e.Data[k] = "[REDACTED]"
|
||||
continue
|
||||
}
|
||||
|
||||
// Redact based on value matching in Data fields
|
||||
switch reflect.TypeOf(v).Kind() {
|
||||
case reflect.String:
|
||||
e.Data[k] = re.ReplaceAllString(v.(string), "$1[REDACTED]$2")
|
||||
continue
|
||||
case reflect.Map:
|
||||
s := fmt.Sprintf("%+v", v)
|
||||
e.Data[k] = re.ReplaceAllString(s, "$1[REDACTED]$2")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Redact based on text matching in the Message field
|
||||
e.Message = re.ReplaceAllString(e.Message, "$1[REDACTED]$2")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Hook) initRedaction() error {
|
||||
if len(h.redactionKeys) == 0 {
|
||||
for _, redactionKey := range h.RedactionList {
|
||||
re, err := regexp.Compile(redactionKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.redactionKeys = append(h.redactionKeys, re)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Hook) redact(msg string) (string, error) {
|
||||
if err := h.initRedaction(); err != nil {
|
||||
return msg, err
|
||||
}
|
||||
for _, re := range h.redactionKeys {
|
||||
msg = re.ReplaceAllString(msg, "$1[REDACTED]$2")
|
||||
}
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
159
log/redactrus_test.go
Executable file
159
log/redactrus_test.go
Executable file
@@ -0,0 +1,159 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
var h = &Hook{}
|
||||
|
||||
type levelsTest struct {
|
||||
name string
|
||||
acceptedLevels []logrus.Level
|
||||
expected []logrus.Level
|
||||
description string
|
||||
}
|
||||
|
||||
func TestLevels(t *testing.T) {
|
||||
tests := []levelsTest{
|
||||
{
|
||||
name: "undefinedAcceptedLevels",
|
||||
acceptedLevels: []logrus.Level{},
|
||||
expected: logrus.AllLevels,
|
||||
description: "All logrus levels expected, but did not receive them",
|
||||
},
|
||||
{
|
||||
name: "definedAcceptedLevels",
|
||||
acceptedLevels: []logrus.Level{logrus.InfoLevel},
|
||||
expected: []logrus.Level{logrus.InfoLevel},
|
||||
description: "Logrus Info level expected, but did not receive that.",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
fn := func(t *testing.T) {
|
||||
h.AcceptedLevels = test.acceptedLevels
|
||||
levels := h.Levels()
|
||||
assert.Equal(t, test.expected, levels, test.description)
|
||||
}
|
||||
|
||||
t.Run(test.name, fn)
|
||||
}
|
||||
}
|
||||
|
||||
type levelThresholdTest struct {
|
||||
name string
|
||||
level logrus.Level
|
||||
expected []logrus.Level
|
||||
description string
|
||||
}
|
||||
|
||||
// levelThreshold returns a []logrus.Level including all levels
|
||||
// above and including the level given. If the provided level does not exit,
|
||||
// an empty slice is returned.
|
||||
func levelThreshold(l logrus.Level) []logrus.Level {
|
||||
//nolint
|
||||
if l < 0 || int(l) > len(logrus.AllLevels) {
|
||||
return []logrus.Level{}
|
||||
}
|
||||
return logrus.AllLevels[:l+1]
|
||||
}
|
||||
|
||||
func TestLevelThreshold(t *testing.T) {
|
||||
tests := []levelThresholdTest{
|
||||
{
|
||||
name: "unknownLogLevel",
|
||||
level: logrus.Level(100),
|
||||
expected: []logrus.Level{},
|
||||
description: "An empty Level slice was expected but was not returned",
|
||||
},
|
||||
{
|
||||
name: "errorLogLevel",
|
||||
level: logrus.ErrorLevel,
|
||||
expected: []logrus.Level{logrus.PanicLevel, logrus.FatalLevel, logrus.ErrorLevel},
|
||||
description: "The panic, fatal, and error levels were expected but were not returned",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
fn := func(t *testing.T) {
|
||||
levels := levelThreshold(test.level)
|
||||
assert.Equal(t, test.expected, levels, test.description)
|
||||
}
|
||||
|
||||
t.Run(test.name, fn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidRegex(t *testing.T) {
|
||||
e := &logrus.Entry{}
|
||||
h = &Hook{RedactionList: []string{"\\"}}
|
||||
err := h.Fire(e)
|
||||
|
||||
assert.NotNil(t, err)
|
||||
}
|
||||
|
||||
type EntryDataValuesTest struct {
|
||||
name string
|
||||
redactionList []string
|
||||
logFields logrus.Fields
|
||||
expected logrus.Fields
|
||||
description string //nolint
|
||||
}
|
||||
|
||||
// Test that any occurrence of a redaction pattern
|
||||
// in the values of the entry's data fields is redacted.
|
||||
func TestEntryDataValues(t *testing.T) {
|
||||
tests := []EntryDataValuesTest{
|
||||
{
|
||||
name: "match on key",
|
||||
redactionList: []string{"Password"},
|
||||
logFields: logrus.Fields{"Password": "password123!"},
|
||||
expected: logrus.Fields{"Password": "[REDACTED]"},
|
||||
description: "Password value should have been redacted, but was not.",
|
||||
},
|
||||
{
|
||||
name: "string value",
|
||||
redactionList: []string{"William"},
|
||||
logFields: logrus.Fields{"Description": "His name is William"},
|
||||
expected: logrus.Fields{"Description": "His name is [REDACTED]"},
|
||||
description: "William should have been redacted, but was not.",
|
||||
},
|
||||
{
|
||||
name: "map value",
|
||||
redactionList: []string{"William"},
|
||||
logFields: logrus.Fields{"Description": map[string]string{"name": "His name is William"}},
|
||||
expected: logrus.Fields{"Description": "map[name:His name is [REDACTED]]"},
|
||||
description: "William should have been redacted, but was not.",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
fn := func(t *testing.T) {
|
||||
logEntry := &logrus.Entry{
|
||||
Data: test.logFields,
|
||||
}
|
||||
h = &Hook{RedactionList: test.redactionList}
|
||||
err := h.Fire(logEntry)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, test.expected, logEntry.Data)
|
||||
}
|
||||
t.Run(test.name, fn)
|
||||
}
|
||||
}
|
||||
|
||||
// Test that any occurrence of a redaction pattern
|
||||
// in the entry's Message field is redacted.
|
||||
func TestEntryMessage(t *testing.T) {
|
||||
logEntry := &logrus.Entry{
|
||||
Message: "Secret Password: password123!",
|
||||
}
|
||||
h = &Hook{RedactionList: []string{`(Password: ).*`}}
|
||||
err := h.Fire(logEntry)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "Secret Password: [REDACTED]", logEntry.Message)
|
||||
}
|
||||
3
main.go
3
main.go
@@ -1,12 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/cmd"
|
||||
)
|
||||
|
||||
func main() {
|
||||
rand.Seed(time.Now().UTC().UnixNano())
|
||||
runtime.MemProfileRate = 0
|
||||
cmd.Execute()
|
||||
}
|
||||
|
||||
@@ -3,55 +3,56 @@ package model
|
||||
import "time"
|
||||
|
||||
type Album struct {
|
||||
Annotations
|
||||
Annotations `structs:"-"`
|
||||
|
||||
ID string `json:"id" orm:"column(id)"`
|
||||
Name string `json:"name"`
|
||||
CoverArtPath string `json:"coverArtPath"`
|
||||
CoverArtId string `json:"coverArtId"`
|
||||
ArtistID string `json:"artistId" orm:"column(artist_id)"`
|
||||
Artist string `json:"artist"`
|
||||
AlbumArtistID string `json:"albumArtistId" orm:"column(album_artist_id)"`
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
AllArtistIDs string `json:"allArtistIds" orm:"column(all_artist_ids)"`
|
||||
MaxYear int `json:"maxYear"`
|
||||
MinYear int `json:"minYear"`
|
||||
Compilation bool `json:"compilation"`
|
||||
Comment string `json:"comment"`
|
||||
SongCount int `json:"songCount"`
|
||||
Duration float32 `json:"duration"`
|
||||
Size int64 `json:"size"`
|
||||
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"`
|
||||
CatalogNum string `json:"catalogNum"`
|
||||
MbzAlbumID string `json:"mbzAlbumId" orm:"column(mbz_album_id)"`
|
||||
MbzAlbumArtistID string `json:"mbzAlbumArtistId" orm:"column(mbz_album_artist_id)"`
|
||||
MbzAlbumType string `json:"mbzAlbumType"`
|
||||
MbzAlbumComment string `json:"mbzAlbumComment"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID string `structs:"id" json:"id" orm:"column(id)"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
CoverArtPath string `structs:"cover_art_path" json:"coverArtPath"`
|
||||
CoverArtId string `structs:"cover_art_id" json:"coverArtId"`
|
||||
ArtistID string `structs:"artist_id" json:"artistId" orm:"column(artist_id)"`
|
||||
Artist string `structs:"artist" json:"artist"`
|
||||
AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId" orm:"column(album_artist_id)"`
|
||||
AlbumArtist string `structs:"album_artist" json:"albumArtist"`
|
||||
AllArtistIDs string `structs:"all_artist_ids" json:"allArtistIds" orm:"column(all_artist_ids)"`
|
||||
MaxYear int `structs:"max_year" json:"maxYear"`
|
||||
MinYear int `structs:"min_year" json:"minYear"`
|
||||
Compilation bool `structs:"compilation" json:"compilation"`
|
||||
Comment string `structs:"comment" json:"comment,omitempty"`
|
||||
SongCount int `structs:"song_count" json:"songCount"`
|
||||
Duration float32 `structs:"duration" json:"duration"`
|
||||
Size int64 `structs:"size" json:"size"`
|
||||
Genre string `structs:"genre" json:"genre"`
|
||||
Genres Genres `structs:"-" json:"genres"`
|
||||
FullText string `structs:"full_text" json:"fullText"`
|
||||
SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"`
|
||||
SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"`
|
||||
SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"`
|
||||
OrderAlbumName string `structs:"order_album_name" json:"orderAlbumName"`
|
||||
OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"`
|
||||
CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"`
|
||||
MbzAlbumID string `structs:"mbz_album_id" json:"mbzAlbumId,omitempty" orm:"column(mbz_album_id)"`
|
||||
MbzAlbumArtistID string `structs:"mbz_album_artist_id" json:"mbzAlbumArtistId,omitempty" orm:"column(mbz_album_artist_id)"`
|
||||
MbzAlbumType string `structs:"mbz_album_type" json:"mbzAlbumType,omitempty"`
|
||||
MbzAlbumComment string `structs:"mbz_album_comment" json:"mbzAlbumComment,omitempty"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Albums []Album
|
||||
type (
|
||||
Albums []Album
|
||||
DiscID struct {
|
||||
AlbumID string `json:"albumId"`
|
||||
DiscNumber int `json:"discNumber"`
|
||||
}
|
||||
)
|
||||
|
||||
type AlbumRepository interface {
|
||||
CountAll(...QueryOptions) (int64, error)
|
||||
Exists(id string) (bool, error)
|
||||
Put(*Album) error
|
||||
Get(id string) (*Album, 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
|
||||
AnnotatedRepository
|
||||
}
|
||||
|
||||
func (a Album) GetAnnotations() Annotations {
|
||||
return a.Annotations
|
||||
}
|
||||
|
||||
@@ -3,15 +3,11 @@ 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
|
||||
PlayCount int64 `structs:"-" json:"playCount"`
|
||||
PlayDate time.Time `structs:"-" json:"playDate" `
|
||||
Rating int `structs:"-" json:"rating" `
|
||||
Starred bool `structs:"-" json:"starred" `
|
||||
StarredAt time.Time `structs:"-" json:"starredAt"`
|
||||
}
|
||||
|
||||
type AnnotatedRepository interface {
|
||||
@@ -19,7 +15,3 @@ type AnnotatedRepository interface {
|
||||
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"}
|
||||
|
||||
@@ -3,24 +3,25 @@ package model
|
||||
import "time"
|
||||
|
||||
type Artist struct {
|
||||
Annotations
|
||||
Annotations `structs:"-"`
|
||||
|
||||
ID string `json:"id" orm:"column(id)"`
|
||||
Name string `json:"name"`
|
||||
AlbumCount int `json:"albumCount"`
|
||||
SongCount int `json:"songCount"`
|
||||
FullText string `json:"fullText"`
|
||||
SortArtistName string `json:"sortArtistName"`
|
||||
OrderArtistName string `json:"orderArtistName"`
|
||||
Size int64 `json:"size"`
|
||||
MbzArtistID string `json:"mbzArtistId" orm:"column(mbz_artist_id)"`
|
||||
Biography string `json:"biography"`
|
||||
SmallImageUrl string `json:"smallImageUrl"`
|
||||
MediumImageUrl string `json:"mediumImageUrl"`
|
||||
LargeImageUrl string `json:"largeImageUrl"`
|
||||
ExternalUrl string `json:"externalUrl" orm:"column(external_url)"`
|
||||
SimilarArtists Artists `json:"-" orm:"-"`
|
||||
ExternalInfoUpdatedAt time.Time `json:"externalInfoUpdatedAt"`
|
||||
ID string `structs:"id" json:"id" orm:"column(id)"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
AlbumCount int `structs:"album_count" json:"albumCount"`
|
||||
SongCount int `structs:"song_count" json:"songCount"`
|
||||
Genres Genres `structs:"-" json:"genres"`
|
||||
FullText string `structs:"full_text" json:"fullText"`
|
||||
SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"`
|
||||
OrderArtistName string `structs:"order_artist_name" json:"orderArtistName"`
|
||||
Size int64 `structs:"size" json:"size"`
|
||||
MbzArtistID string `structs:"mbz_artist_id" json:"mbzArtistId,omitempty" orm:"column(mbz_artist_id)"`
|
||||
Biography string `structs:"biography" json:"biography,omitempty"`
|
||||
SmallImageUrl string `structs:"small_image_url" json:"smallImageUrl,omitempty"`
|
||||
MediumImageUrl string `structs:"medium_image_url" json:"mediumImageUrl,omitempty"`
|
||||
LargeImageUrl string `structs:"large_image_url" json:"largeImageUrl,omitempty"`
|
||||
ExternalUrl string `structs:"external_url" json:"externalUrl,omitempty" orm:"column(external_url)"`
|
||||
SimilarArtists Artists `structs:"-" json:"-" orm:"-"`
|
||||
ExternalInfoUpdatedAt time.Time `structs:"external_info_updated_at" json:"externalInfoUpdatedAt"`
|
||||
}
|
||||
|
||||
func (a Artist) ArtistImageUrl() string {
|
||||
@@ -47,13 +48,8 @@ type ArtistRepository interface {
|
||||
Put(m *Artist) error
|
||||
Get(id string) (*Artist, error)
|
||||
GetAll(options ...QueryOptions) (Artists, error)
|
||||
GetStarred(options ...QueryOptions) (Artists, error)
|
||||
Search(q string, offset int, size int) (Artists, error)
|
||||
Refresh(ids ...string) error
|
||||
GetIndex() (ArtistIndexes, error)
|
||||
AnnotatedRepository
|
||||
}
|
||||
|
||||
func (a Artist) GetAnnotations() Annotations {
|
||||
return a.Annotations
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package model
|
||||
import "time"
|
||||
|
||||
type Bookmarkable struct {
|
||||
BookmarkPosition int64 `json:"bookmarkPosition"`
|
||||
BookmarkPosition int64 `structs:"-" json:"bookmarkPosition"`
|
||||
}
|
||||
|
||||
type BookmarkableRepository interface {
|
||||
@@ -13,16 +13,12 @@ type BookmarkableRepository interface {
|
||||
}
|
||||
|
||||
type Bookmark struct {
|
||||
Item MediaFile `json:"item"`
|
||||
Comment string `json:"comment"`
|
||||
Position int64 `json:"position"`
|
||||
ChangedBy string `json:"changed_by"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Item MediaFile `structs:"item" json:"item"`
|
||||
Comment string `structs:"comment" json:"comment"`
|
||||
Position int64 `structs:"position" json:"position"`
|
||||
ChangedBy string `structs:"changed_by" json:"changed_by"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Bookmarks []Bookmark
|
||||
|
||||
// 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 BookmarkFields = []string{"bookmarkPosition"}
|
||||
|
||||
@@ -27,10 +27,13 @@ type DataStore interface {
|
||||
Genre(ctx context.Context) GenreRepository
|
||||
Playlist(ctx context.Context) PlaylistRepository
|
||||
PlayQueue(ctx context.Context) PlayQueueRepository
|
||||
Property(ctx context.Context) PropertyRepository
|
||||
User(ctx context.Context) UserRepository
|
||||
Transcoding(ctx context.Context) TranscodingRepository
|
||||
Player(ctx context.Context) PlayerRepository
|
||||
Share(ctx context.Context) ShareRepository
|
||||
Property(ctx context.Context) PropertyRepository
|
||||
User(ctx context.Context) UserRepository
|
||||
UserProps(ctx context.Context) UserPropsRepository
|
||||
ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository
|
||||
|
||||
Resource(ctx context.Context, model interface{}) ResourceRepository
|
||||
|
||||
|
||||
15
model/genre.go
Normal file
15
model/genre.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package model
|
||||
|
||||
type Genre struct {
|
||||
ID string `structs:"id" json:"id" orm:"column(id)"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
SongCount int `structs:"-" json:"-"`
|
||||
AlbumCount int `structs:"-" json:"-"`
|
||||
}
|
||||
|
||||
type Genres []Genre
|
||||
|
||||
type GenreRepository interface {
|
||||
GetAll(...QueryOptions) (Genres, error)
|
||||
Put(*Genre) error
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package model
|
||||
|
||||
type Genre struct {
|
||||
Name string
|
||||
SongCount int
|
||||
AlbumCount int
|
||||
}
|
||||
|
||||
type Genres []Genre
|
||||
|
||||
type GenreRepository interface {
|
||||
GetAll() (Genres, error)
|
||||
}
|
||||
@@ -6,48 +6,51 @@ import (
|
||||
)
|
||||
|
||||
type MediaFile struct {
|
||||
Annotations
|
||||
Bookmarkable
|
||||
Annotations `structs:"-"`
|
||||
Bookmarkable `structs:"-"`
|
||||
|
||||
ID string `json:"id" orm:"pk;column(id)"`
|
||||
Path string `json:"path"`
|
||||
Title string `json:"title"`
|
||||
Album string `json:"album"`
|
||||
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"`
|
||||
AlbumID string `json:"albumId" orm:"pk;column(album_id)"`
|
||||
HasCoverArt bool `json:"hasCoverArt"`
|
||||
TrackNumber int `json:"trackNumber"`
|
||||
DiscNumber int `json:"discNumber"`
|
||||
DiscSubtitle string `json:"discSubtitle"`
|
||||
Year int `json:"year"`
|
||||
Size int64 `json:"size"`
|
||||
Suffix string `json:"suffix"`
|
||||
Duration float32 `json:"duration"`
|
||||
BitRate int `json:"bitRate"`
|
||||
Genre string `json:"genre"`
|
||||
FullText string `json:"fullText"`
|
||||
SortTitle string `json:"sortTitle"`
|
||||
SortAlbumName string `json:"sortAlbumName"`
|
||||
SortArtistName string `json:"sortArtistName"`
|
||||
SortAlbumArtistName string `json:"sortAlbumArtistName"`
|
||||
OrderAlbumName string `json:"orderAlbumName"`
|
||||
OrderArtistName string `json:"orderArtistName"`
|
||||
OrderAlbumArtistName string `json:"orderAlbumArtistName"`
|
||||
Compilation bool `json:"compilation"`
|
||||
Comment string `json:"comment"`
|
||||
Lyrics string `json:"lyrics"`
|
||||
CatalogNum string `json:"catalogNum"`
|
||||
MbzTrackID string `json:"mbzTrackId" orm:"column(mbz_track_id)"`
|
||||
MbzAlbumID string `json:"mbzAlbumId" orm:"column(mbz_album_id)"`
|
||||
MbzArtistID string `json:"mbzArtistId" orm:"column(mbz_artist_id)"`
|
||||
MbzAlbumArtistID string `json:"mbzAlbumArtistId" orm:"column(mbz_album_artist_id)"`
|
||||
MbzAlbumType string `json:"mbzAlbumType"`
|
||||
MbzAlbumComment string `json:"mbzAlbumComment"`
|
||||
CreatedAt time.Time `json:"createdAt"` // Time this entry was created in the DB
|
||||
UpdatedAt time.Time `json:"updatedAt"` // Time of file last update (mtime)
|
||||
ID string `structs:"id" json:"id" orm:"pk;column(id)"`
|
||||
Path string `structs:"path" json:"path"`
|
||||
Title string `structs:"title" json:"title"`
|
||||
Album string `structs:"album" json:"album"`
|
||||
ArtistID string `structs:"artist_id" json:"artistId" orm:"pk;column(artist_id)"`
|
||||
Artist string `structs:"artist" json:"artist"`
|
||||
AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId" orm:"pk;column(album_artist_id)"`
|
||||
AlbumArtist string `structs:"album_artist" json:"albumArtist"`
|
||||
AlbumID string `structs:"album_id" json:"albumId" orm:"pk;column(album_id)"`
|
||||
HasCoverArt bool `structs:"has_cover_art" json:"hasCoverArt"`
|
||||
TrackNumber int `structs:"track_number" json:"trackNumber"`
|
||||
DiscNumber int `structs:"disc_number" json:"discNumber"`
|
||||
DiscSubtitle string `structs:"disc_subtitle" json:"discSubtitle,omitempty"`
|
||||
Year int `structs:"year" json:"year"`
|
||||
Size int64 `structs:"size" json:"size"`
|
||||
Suffix string `structs:"suffix" json:"suffix"`
|
||||
Duration float32 `structs:"duration" json:"duration"`
|
||||
BitRate int `structs:"bit_rate" json:"bitRate"`
|
||||
Channels int `structs:"channels" json:"channels"`
|
||||
Genre string `structs:"genre" json:"genre"`
|
||||
Genres Genres `structs:"-" json:"genres"`
|
||||
FullText string `structs:"full_text" json:"fullText"`
|
||||
SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"`
|
||||
SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"`
|
||||
SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"`
|
||||
SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"`
|
||||
OrderAlbumName string `structs:"order_album_name" json:"orderAlbumName"`
|
||||
OrderArtistName string `structs:"order_artist_name" json:"orderArtistName"`
|
||||
OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"`
|
||||
Compilation bool `structs:"compilation" json:"compilation"`
|
||||
Comment string `structs:"comment" json:"comment,omitempty"`
|
||||
Lyrics string `structs:"lyrics" json:"lyrics,omitempty"`
|
||||
Bpm int `structs:"bpm" json:"bpm,omitempty"`
|
||||
CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"`
|
||||
MbzTrackID string `structs:"mbz_track_id" json:"mbzTrackId,omitempty" orm:"column(mbz_track_id)"`
|
||||
MbzAlbumID string `structs:"mbz_album_id" json:"mbzAlbumId,omitempty" orm:"column(mbz_album_id)"`
|
||||
MbzArtistID string `structs:"mbz_artist_id" json:"mbzArtistId,omitempty" orm:"column(mbz_artist_id)"`
|
||||
MbzAlbumArtistID string `structs:"mbz_album_artist_id" json:"mbzAlbumArtistId,omitempty" orm:"column(mbz_album_artist_id)"`
|
||||
MbzAlbumType string `structs:"mbz_album_type" json:"mbzAlbumType,omitempty"`
|
||||
MbzAlbumComment string `structs:"mbz_album_comment" json:"mbzAlbumComment,omitempty"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"` // Time this entry was created in the DB
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` // Time of file last update (mtime)
|
||||
}
|
||||
|
||||
func (mf *MediaFile) ContentType() string {
|
||||
@@ -62,12 +65,9 @@ type MediaFileRepository interface {
|
||||
Put(m *MediaFile) error
|
||||
Get(id string) (*MediaFile, error)
|
||||
GetAll(options ...QueryOptions) (MediaFiles, error)
|
||||
FindByAlbum(albumId string) (MediaFiles, error)
|
||||
FindAllByPath(path string) (MediaFiles, error)
|
||||
FindByPath(path string) (*MediaFile, error)
|
||||
FindPathsRecursively(basePath string) ([]string, error)
|
||||
GetStarred(options ...QueryOptions) (MediaFiles, error)
|
||||
GetRandom(options ...QueryOptions) (MediaFiles, error)
|
||||
Search(q string, offset int, size int) (MediaFiles, error)
|
||||
Delete(id string) error
|
||||
DeleteByPath(path string) (int64, error)
|
||||
@@ -75,7 +75,3 @@ type MediaFileRepository interface {
|
||||
AnnotatedRepository
|
||||
BookmarkableRepository
|
||||
}
|
||||
|
||||
func (mf MediaFile) GetAnnotations() Annotations {
|
||||
return mf.Annotations
|
||||
}
|
||||
|
||||
@@ -5,22 +5,23 @@ import (
|
||||
)
|
||||
|
||||
type Player struct {
|
||||
ID string `json:"id" orm:"column(id)"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
UserName string `json:"userName"`
|
||||
Client string `json:"client"`
|
||||
IPAddress string `json:"ipAddress"`
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
TranscodingId string `json:"transcodingId"`
|
||||
MaxBitRate int `json:"maxBitRate"`
|
||||
ReportRealPath bool `json:"reportRealPath"`
|
||||
ID string `structs:"id" json:"id" orm:"column(id)"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
UserAgent string `structs:"user_agent" json:"userAgent"`
|
||||
UserName string `structs:"user_name" json:"userName"`
|
||||
Client string `structs:"client" json:"client"`
|
||||
IPAddress string `structs:"ip_address" json:"ipAddress"`
|
||||
LastSeen time.Time `structs:"last_seen" json:"lastSeen"`
|
||||
TranscodingId string `structs:"transcoding_id" json:"transcodingId"`
|
||||
MaxBitRate int `structs:"max_bit_rate" json:"maxBitRate"`
|
||||
ReportRealPath bool `structs:"report_real_path" json:"reportRealPath"`
|
||||
ScrobbleEnabled bool `structs:"scrobble_enabled" json:"scrobbleEnabled"`
|
||||
}
|
||||
|
||||
type Players []Player
|
||||
|
||||
type PlayerRepository interface {
|
||||
Get(id string) (*Player, error)
|
||||
FindByName(client, userName string) (*Player, error)
|
||||
FindMatch(userName, client, typ string) (*Player, error)
|
||||
Put(p *Player) error
|
||||
}
|
||||
|
||||
@@ -5,19 +5,19 @@ import (
|
||||
)
|
||||
|
||||
type Playlist struct {
|
||||
ID string `json:"id" orm:"column(id)"`
|
||||
Name string `json:"name"`
|
||||
Comment string `json:"comment"`
|
||||
Duration float32 `json:"duration"`
|
||||
Size int64 `json:"size"`
|
||||
SongCount int `json:"songCount"`
|
||||
Owner string `json:"owner"`
|
||||
Public bool `json:"public"`
|
||||
Tracks MediaFiles `json:"tracks,omitempty"`
|
||||
Path string `json:"path"`
|
||||
Sync bool `json:"sync"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID string `structs:"id" json:"id" orm:"column(id)"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
Comment string `structs:"comment" json:"comment"`
|
||||
Duration float32 `structs:"duration" json:"duration"`
|
||||
Size int64 `structs:"size" json:"size"`
|
||||
SongCount int `structs:"song_count" json:"songCount"`
|
||||
Owner string `structs:"owner" json:"owner"`
|
||||
Public bool `structs:"public" json:"public"`
|
||||
Tracks MediaFiles `structs:"-" json:"tracks,omitempty"`
|
||||
Path string `structs:"path" json:"path"`
|
||||
Sync bool `structs:"sync" json:"sync"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Playlists []Playlist
|
||||
@@ -44,7 +44,10 @@ type PlaylistTracks []PlaylistTrack
|
||||
|
||||
type PlaylistTrackRepository interface {
|
||||
ResourceRepository
|
||||
Add(mediaFileIds []string) error
|
||||
Add(mediaFileIds []string) (int, error)
|
||||
AddAlbums(albumIds []string) (int, error)
|
||||
AddArtists(artistIds []string) (int, error)
|
||||
AddDiscs(discs []DiscID) (int, error)
|
||||
Update(mediaFileIds []string) error
|
||||
Delete(id string) error
|
||||
Reorder(pos int, newPos int) error
|
||||
|
||||
@@ -5,14 +5,14 @@ import (
|
||||
)
|
||||
|
||||
type PlayQueue struct {
|
||||
ID string `json:"id" orm:"column(id)"`
|
||||
UserID string `json:"userId" orm:"column(user_id)"`
|
||||
Current string `json:"current"`
|
||||
Position int64 `json:"position"`
|
||||
ChangedBy string `json:"changedBy"`
|
||||
Items MediaFiles `json:"items,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID string `structs:"id" json:"id" orm:"column(id)"`
|
||||
UserID string `structs:"user_id" json:"userId" orm:"column(user_id)"`
|
||||
Current string `structs:"current" json:"current"`
|
||||
Position int64 `structs:"position" json:"position"`
|
||||
ChangedBy string `structs:"changed_by" json:"changedBy"`
|
||||
Items MediaFiles `structs:"-" json:"items,omitempty"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
type PlayQueues []PlayQueue
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user