Compare commits
516 Commits
Author | SHA1 | Date |
---|---|---|
Deluan | 78f554721a | |
Deluan | 2c8c87a980 | |
Deluan | 3463d0c208 | |
Deluan | 0ae2944073 | |
Deluan | 30ae468dc1 | |
Deluan | ec68d69d56 | |
Deluan | 955a9b43af | |
Deluan | 56809419c2 | |
Deluan | 3a2a5e961b | |
Deluan | f3bb022238 | |
Deluan | 472324e280 | |
Deluan | ed83c22632 | |
edthu | 2fdc1677f7 | |
Deluan | 80e68dfbcd | |
Deluan | a9c745839b | |
Deluan | bb96d455f8 | |
Deluan | c0885b55db | |
Deluan | 00cbe4c357 | |
Valeri Sokolov | 2b49c7ff76 | |
Deluan | 09d1fd0658 | |
Deluan | 747069b229 | |
Deluan | 885cd345ab | |
Deluan Quintão | c4b05dac28 | |
Deluan Quintão | 6408dda948 | |
Deluan | 677d9947f3 | |
Deluan | a0290587b9 | |
Deluan | eb93136b3f | |
Deluan | 62cc8a2d4b | |
Deluan | dd4374cec6 | |
Deluan | 86567f5406 | |
Matthias Schmidt | ff8dca5abe | |
Matthias Schmidt | b3d70e9264 | |
Ludovic Fernandez | 4d29184998 | |
Deluan | 2470471b2b | |
Deluan | 544ae90ec1 | |
Deluan | aef49cb8d6 | |
Deluan | 7c5eec715d | |
Kendall Garner | a4c2232041 | |
Deluan | 8f11b991d2 | |
Deluan | d4a9a9e555 | |
Deluan | a8955f24e0 | |
Deluan | 2c06a4234e | |
Deluan | 7ab7b5df5e | |
Deluan | 3d9fff36f7 | |
Deluan | 31fcab07d2 | |
Deluan | de90152a71 | |
Deluan | 27875ba2dd | |
Deluan | 28f7ef43c1 | |
Deluan | 92a98cd558 | |
Deluan | 5d50558610 | |
vvdveen | 8bff1ad512 | |
crazygolem | 1e96b858a9 | |
Deluan | aafd5a952c | |
Deluan Quintão | d9cd5efd67 | |
Deluan | affa9c3478 | |
Anna Smith | 651a8fdaf9 | |
Deluan | f7fc17c0f7 | |
Deluan | f5df948eb1 | |
crazygolem | 18143fa5a1 | |
Tim | 8f9ed1b994 | |
dependabot[bot] | cf66594b6d | |
Deluan | ca005f6457 | |
Deluan | 6dcfe4d455 | |
Deluan | 7871d69adb | |
Deluan | 78182f40d6 | |
Deluan | 9aeaaa6610 | |
dependabot[bot] | 068c1e9a23 | |
Jonathan | bcec15dc13 | |
dependabot[bot] | cf6603e3ec | |
dependabot[bot] | 88d6757121 | |
Andrew Katsikas | c2f932c21c | |
Deluan | d968f7f530 | |
dependabot[bot] | 5fc78f120c | |
dependabot[bot] | 52dfa97262 | |
dependabot[bot] | c1eef058a4 | |
Deluan | 7f551a7932 | |
oftenoccur | bcb71b85c0 | |
Deluan | 8720bd154f | |
Cyrille | 699be19bb9 | |
looklose | 22cc9e0cd5 | |
dependabot[bot] | 6e36abdd62 | |
dependabot[bot] | e98c7374a9 | |
Deluan Quintão | de7f553526 | |
dependabot[bot] | 9cc0cc2e93 | |
dependabot[bot] | 24298605d4 | |
Deluan | 4865d04ec6 | |
dependabot[bot] | 81770351de | |
dependabot[bot] | b6bbba754a | |
deluan | 4f6121fae1 | |
Kendall Garner | f12dfb485a | |
Deluan | e81bf5125f | |
dependabot[bot] | a47acb6674 | |
dependabot[bot] | 4a15677474 | |
Deluan | 859cdda0bd | |
Deluan | 87ecd118bb | |
Deluan | 5abe156777 | |
Deluan | fa72aaa462 | |
Deluan | 6eb13c9f79 | |
Deluan | b67d1c0830 | |
Deluan | effd588406 | |
Deluan | 6f4c55dbde | |
Deluan | 176329343a | |
Deluan | 97c7e5daaf | |
Deluan | 166eb37787 | |
Deluan | f7a4387d0e | |
Deluan | 71e5b271fb | |
Deluan | d51148ea4c | |
Deluan | 7cb8cc115e | |
Deluan | 69d91189c2 | |
Deluan | 88063fc189 | |
Deluan | 912e144b71 | |
Deluan | 87484fe7a9 | |
Deluan | 58f64355c2 | |
Deluan Quintão | 7167e5ac87 | |
Deluan | d8e1748928 | |
Deluan | 9a051967f6 | |
Deluan | 0b2cf30096 | |
Deluan | 6d253225de | |
Caio Cotts | bf2bcb1279 | |
Deluan Quintão | ac4ceab143 | |
Deluan | 6226741517 | |
Deluan | 79a4d8f6ad | |
Deluan Quintão | 61257f89d2 | |
Deluan | 1f71e56741 | |
Kendall Garner | 3a9b3452a2 | |
Deluan | 5125558f52 | |
Deluan | 5f9b6b632d | |
Deluan | fa7cc40d23 | |
caiocotts | 58218e6dc4 | |
Deluan | 67c82f524b | |
Deluan | fb7fd21984 | |
Deluan | a6fc84a2e1 | |
Deluan | 1e5e8be192 | |
Deluan | fd61b29a84 | |
Deluan | 2b33ef72e3 | |
Deluan | 2fb913f5c9 | |
Deluan | 6c05493cda | |
Deluan | 3ca4f44118 | |
Deluan | 34c29a156f | |
dependabot[bot] | b442736a0f | |
dependabot[bot] | 90fccf00d1 | |
dependabot[bot] | bcd4a52616 | |
dependabot[bot] | 84cffa6b94 | |
dependabot[bot] | a51b1b25d2 | |
dependabot[bot] | 9f317c054b | |
dependabot[bot] | 5f8d01a207 | |
dependabot[bot] | 8a648d717a | |
dependabot[bot] | a0dc2ee051 | |
dependabot[bot] | ffb4de1e27 | |
dependabot[bot] | e1fc7983a5 | |
dependabot[bot] | 2a43f54eb1 | |
dependabot[bot] | f654e92113 | |
flyingOwl | dfa453cc4a | |
Johannes Engl | 8f03454312 | |
dependabot[bot] | 8570773b90 | |
caiocotts | 6cff91e17d | |
dependabot[bot] | d0df81a8df | |
dependabot[bot] | 75f3ef64e2 | |
dependabot[bot] | 170ac93926 | |
Deluan | 6f7b48202e | |
Deluan | 6e2be7f95f | |
Deluan | 0d8f8e3afd | |
Deluan | e50382e3bf | |
Kendall Garner | 814161d78d | |
Deluan | 130ab76c79 | |
Deluan | a186a795f6 | |
Deluan | 798b03eabd | |
Deluan | ea7ba22699 | |
Andrew Katsikas | b4815ecee5 | |
Deluan | 51e07d4cb5 | |
Deluan | 03119e5ccf | |
Deluan Quintão | 15e1394fa3 | |
Deluan | 3f349b1b58 | |
Deluan | dfcc189cff | |
Deluan | 00597e01e9 | |
Dany Marcoux | 965fc9d9be | |
Deluan Quintão | 781ff40464 | |
Deluan | a6ed0442f2 | |
dependabot[bot] | 515efe37f0 | |
dependabot[bot] | 6c28c111bb | |
dependabot[bot] | 92a88ad4d9 | |
dependabot[bot] | 4ccc0a92bf | |
dependabot[bot] | df3de047ca | |
Caio Cotts | 86757663d6 | |
dependabot[bot] | 735d670a5b | |
dependabot[bot] | 30179146c3 | |
dependabot[bot] | 03a9f22ed9 | |
dependabot[bot] | 39e92a1918 | |
Deluan | 421ce91a9e | |
Deluan | 12aae5e951 | |
Deluan | 932152eb7e | |
Deluan | 0e3175ea17 | |
Deluan | d3f6b4692d | |
Deluan | 70effa09e8 | |
Deluan | 7ccf685973 | |
Deluan | 2aef227572 | |
Deluan | d80e1a260b | |
dependabot[bot] | fd4605d7dc | |
Deluan | a6493c4c36 | |
Kendall Garner | 54597bd575 | |
Deluan Quintão | ab53313273 | |
Deluan | d3669f46a9 | |
Deluan | d89de9060a | |
Deluan | ac3668a33e | |
dependabot[bot] | 6d924ad742 | |
Deluan | 78d557c185 | |
Deluan | 546aa26a0a | |
dependabot[bot] | fc677f7951 | |
Deluan | aed0309161 | |
Deluan | 465cc091b0 | |
Deluan | 2c9035fdd0 | |
Deluan | af7eead037 | |
Deluan Quintão | 0ca0d5da22 | |
dependabot[bot] | 7074455e0e | |
caiocotts | 2f2fbeb009 | |
Kendall Garner | 742fd16a01 | |
Deluan Quintão | 7766ee069c | |
Deluan | 4cd7c7f39f | |
Deluan | 81daee3b9b | |
Deluan | 9b434d743f | |
Deluan | 4641dc0b2b | |
Deluan | 812dc2090f | |
Deluan | a9cf54afef | |
Deluan | 595186b1b2 | |
Deluan | cdccdc56c9 | |
Deluan | f580c5b8bc | |
deluan | f0e25c251d | |
Deluan | abde399e7b | |
Deluan | 1b4483d32b | |
Deluan | f7fe8ba938 | |
Deluan | f543e7accc | |
Deluan Quintão | 60a5fbe1fe | |
Deluan | 28dc98dec4 | |
Deluan | 8c8e1ea701 | |
Deluan | b964018cd7 | |
Deluan | 9aa7b80d0d | |
Deluan | c3efc57259 | |
Deluan | 27a92b05e7 | |
Deluan | 21f1354cd1 | |
Deluan | 069da5d91c | |
Deluan | 69d2ced852 | |
Deluan | 17ac8d25cb | |
Deluan | 474f32f1b8 | |
Deluan | ecadcfb403 | |
Caio Cotts | f69c27d146 | |
Caio Cotts | bb7186ce2f | |
dependabot[bot] | 5d1493e845 | |
Deluan | d0fe406800 | |
Deluan | c8fbf6b60e | |
deluan | e5bc3ca200 | |
tarokeitaro | 6d88dd2c66 | |
caiocotts | eebfbc5381 | |
Deluan | a5dfd2d4a1 | |
Drew Weymouth | 7773522803 | |
Deluan | 53607fe114 | |
Caio Cotts | fee0f40a52 | |
Caio Cotts | 9d2aaff8cb | |
Caio Cotts | 2ff4023cce | |
Kendall Garner | 79870b1090 | |
Kendall Garner | 7a858a2db3 | |
dependabot[bot] | 9cefaf66a4 | |
Kendall Garner | 3debd31b12 | |
Deluan Quintão | 24d9fb5b48 | |
certuna | 40841ab917 | |
certuna | bae5fc946b | |
Deluan | e055826068 | |
dependabot[bot] | 54bde266b4 | |
dependabot[bot] | 3a7376901b | |
dependabot[bot] | de3d870100 | |
certuna | 03175e1a9d | |
Sam Watson | 26472f46fe | |
dependabot[bot] | 6bca7531aa | |
dependabot[bot] | 68d1d5c99f | |
dependabot[bot] | db6c46091e | |
dependabot[bot] | 4cd916bb78 | |
dependabot[bot] | c40e83efab | |
Stephan Wahlen | 9094f41f25 | |
dependabot[bot] | 9ff95b6ced | |
Kendall Garner | 77ace8570c | |
Matthias Schmidt | 59f0c487e7 | |
Deluan | 2cd4358172 | |
dependabot[bot] | 248bf232ff | |
dependabot[bot] | b5664ab905 | |
Lukas H | ac7f94e620 | |
dependabot[bot] | d45f9f172d | |
dependabot[bot] | 250107d668 | |
BoniK | 64b14db55a | |
dependabot[bot] | 73d1851c0d | |
Matthias Schmidt | 1b16e1140f | |
Deluan Quintão | f941347cf1 | |
dependabot[bot] | 1b5cefdada | |
dependabot[bot] | 4cf25fc611 | |
dependabot[bot] | 14ba83ea1b | |
dependabot[bot] | 08f3fd1343 | |
dependabot[bot] | 3d66f58725 | |
dependabot[bot] | 5b1ba3df05 | |
Deluan | a002830775 | |
dependabot[bot] | 7b600bed05 | |
dependabot[bot] | 7d0a1916d8 | |
dependabot[bot] | c7fe311c7f | |
dependabot[bot] | 4520a34648 | |
BenzLeung | 3e14c3c4f8 | |
dependabot[bot] | 1e891d6b07 | |
dependabot[bot] | caf9b22d35 | |
Deluan Quintão | 4f8742bcd1 | |
Deluan | 26aa0f4fff | |
Deluan | 4898f31f6d | |
Philipp Wolfer | 9da013f339 | |
Deluan | 5af67c78af | |
Philipp Wolfer | c8608956be | |
Deluan | 36eda871f6 | |
David Casado | 7c92a73208 | |
Deluan | f5d97823e8 | |
Deluan | d6083dab6e | |
Deluan | 6b3b4d83ff | |
Deluan | 3853c3318f | |
tomleb | 257ccc5f43 | |
Deluan | cec5fb0d6c | |
Deluan | 3fc4313e89 | |
Deluan | c4c99b7f75 | |
Deluan | a984bbbc7a | |
Deluan | ba067667c9 | |
Deluan | e38a690632 | |
Deluan | 7d0656f44a | |
Deluan | 11f33ff8b6 | |
Deluan | 611363fca7 | |
Deluan | 85d43d2366 | |
Deluan | 8faaa3cf91 | |
Deluan | 20462c52a5 | |
certuna | 52b77e4194 | |
Deluan | 010ba0d15c | |
Zane van Iperen | 9b7fac5147 | |
Deluan | be12c12b28 | |
Kendall Garner | a19a643c65 | |
Deluan | f9b060af18 | |
Deluan | a3d78e95f2 | |
Deluan | d85b06332c | |
Deluan | bfa10cab62 | |
Deluan | 08fcb430e6 | |
Deluan | 5d02df62d0 | |
Deluan | c3a2e084b3 | |
Deluan | 4296741ec0 | |
Deluan | 6bee4ed147 | |
Deluan | e62c3edc1c | |
Deluan | 0a08d0af3b | |
Deluan | ad513354b9 | |
dependabot[bot] | a70b81f931 | |
dependabot[bot] | 0d920c7832 | |
dependabot[bot] | 957a73e052 | |
dependabot[bot] | abc418eaa2 | |
dependabot[bot] | 1128322011 | |
dependabot[bot] | 2e479defd5 | |
dependabot[bot] | 8311a7f215 | |
dependabot[bot] | 6ec8f78076 | |
Logan Marchione | 3e879d2a8c | |
Jeff Henson | 6d3d005fca | |
Deluan | c12510d6e2 | |
Deluan | 0bd73bd3f4 | |
Deluan | 8c120ee3c9 | |
Deluan | 9590b3c25d | |
Deluan | 4887c33053 | |
Subhajit Ghosh | da21acba92 | |
Deluan Quintão | 9154e44eb4 | |
Deluan Quintão | 2e01063429 | |
Deluan | 597e5abed6 | |
Deluan Quintão | 92994efe48 | |
Deluan | 9628b1389d | |
Deluan | 347424009d | |
Deluan | ecac74c2bd | |
Deluan | ddfde7bfc8 | |
Deluan | 96c50d369a | |
Deluan | 310c816cdd | |
Deluan | bd402fb2a8 | |
dependabot[bot] | 8bb141b730 | |
Deluan | f25b91b4d8 | |
dependabot[bot] | f959701d9d | |
Deluan | 61dd8d55ca | |
Deluan | bbb9461000 | |
Deluan | 95016f687e | |
Deluan | c3cc7dee01 | |
Deluan | 7847f19c9d | |
dependabot[bot] | 7a0df4429e | |
Deluan | 6a8d2dc87d | |
Deluan | de816e8e5d | |
Deluan | b22d0366d5 | |
Deluan | fea2de8f90 | |
Deluan | d6dd0aaae7 | |
Fadeeeeeeee | 458017b112 | |
Deluan | e6bfa2bb0b | |
Deluan | 1c7fb74a1d | |
Deluan | 83ae2ba3e6 | |
Joakim Repomaa | 2ccc5bc941 | |
Deluan | 406554f1c4 | |
Deluan | e89cdf6199 | |
Deluan | cf804a52ef | |
Deluan | 628fd69d3d | |
Deluan | 1d00d1e986 | |
Deluan | 607c4067b8 | |
Deluan | e3079d81ea | |
Deluan | 3bedd89c17 | |
dependabot[bot] | 57829bfa4c | |
Deluan | b998c05ca0 | |
Deluan | 05d381c26f | |
zayedalsaidi | 59a9c056b4 | |
Deluan | 0de81b8352 | |
Deluan | 91785ecf36 | |
Deluan | 65eeb5ec1a | |
Julien Voisin | 17e0cd5504 | |
Deluan | 3a6d2dcd49 | |
Deluan | 183b462fed | |
Deluan | 16fc4eb792 | |
dependabot[bot] | 6fee744d99 | |
dependabot[bot] | 74d5c7bc82 | |
dependabot[bot] | 880fc9e195 | |
Xidorn Quan | 1430aa108d | |
Deluan | 673880d661 | |
Deluan | 7ea111322b | |
Deluan | 377e7ebd52 | |
Deluan | 23c483da10 | |
Deluan | c380139606 | |
Deluan | 63fbccf5a9 | |
Deluan | 1f6ec1d9f5 | |
dependabot[bot] | cad8156353 | |
Deluan Quintão | f7d4fcdcc1 | |
Deluan Quintão | 002cb4ed71 | |
Deluan Quintão | e13eaebbde | |
dependabot[bot] | 539c0faedb | |
Moink | 4ccb6ccb09 | |
Deluan | ec0eb2866b | |
Deluan | b520d8827a | |
Deluan | a7d3e6e1f1 | |
Deluan | a22eef39f7 | |
Torsten Curdt | 50d9838652 | |
Deluan | 016454c217 | |
Deluan | 41a5db72e7 | |
Deluan | 6e6ec58429 | |
Deluan | c88e1baa7c | |
Deluan | e16e3d2e7b | |
Deluan | 339a6239fd | |
Deluan | 47f15ccbc3 | |
Deluan | 9667f3cd48 | |
Deluan | 5773fa0349 | |
Deluan | 527c378c41 | |
Deluan | caa0788853 | |
Deluan | 40b14e6d81 | |
Deluan | becd50eb68 | |
Deluan | 15b5aa9143 | |
Deluan | 7987d982cf | |
Deluan | 1dd074bbb4 | |
Deluan | 7eac9d2bbe | |
dependabot[bot] | 362d8c50fe | |
dependabot[bot] | 01c604ba7b | |
dependabot[bot] | 2c129a2890 | |
Deluan | 5fc4076aec | |
Deluan | d303ad2676 | |
Deluan | c4a68c8a0a | |
Deluan | ad9ce98cc2 | |
Deluan | a134b1b608 | |
Deluan | 6dce4b2478 | |
Deluan | 10108c63c9 | |
Deluan | aac6e2cb07 | |
Deluan | 0ffdb2eee0 | |
Kendall Garner | 8b93962fad | |
Kendall Garner | b129cae0d8 | |
Deluan | 2400e4f60d | |
Deluan Quintão | 3cd934abd7 | |
Deluan | 727632b616 | |
Kendall Garner | 9e268678f2 | |
RTapeLoadingError | bb29ad3b12 | |
Deluan | b68ed2e4f9 | |
Deluan | 0c3ac906b8 | |
Deluan | b0e58cb885 | |
Deluan | 806713719f | |
Deluan | a3b8682d44 | |
Deluan | 0bbb54934b | |
Deluan | 759ff844e2 | |
Deluan | b8c5e49dd3 | |
Deluan | 05c6cdea1a | |
Daniel Hammer | fc8462dc8a | |
Deluan | 9d459fbd0a | |
Deluan | 9b2dd1bb06 | |
Deluan | bfaf4a3388 | |
Deluan | a7f15facf9 | |
Deluan | ee8f6447eb | |
Deluan | dad4949a6d | |
Deluan | 3ce3185118 | |
Deluan | a50d9c8b67 | |
Kendall Garner | f8dfb3ad86 | |
Deluan | 255f8e4a76 | |
Deluan | eba70ab826 | |
Deluan | ee6b10db72 | |
Deluan | 797cc87141 | |
dependabot[bot] | bfbe980637 | |
Deluan | d9d0a97674 | |
Deluan | c031167bb1 | |
Deluan | 4a25e6d3d8 | |
Deluan | ad2ad514b3 | |
Deluan | 588ee94f7c | |
Deluan | 3c5032a3e8 | |
Deluan | bcab3cc0f9 | |
Deluan | 9b81aa4403 | |
Deluan | f904784e67 | |
Deluan | 0ce750d469 | |
Deluan | cf04db7a98 | |
Deluan | f4b50c493c | |
Deluan | 4a7e86e989 | |
vlfldr | a1a5b2fc30 | |
Deluan | f00e6117ff | |
Deluan | d8e794317f | |
Deluan | 128b626ec9 | |
Deluan | d683297fa7 | |
Deluan | aaf58bbd32 | |
deluan | 58c46827cd | |
Deluan | 712d8f9fcc | |
Deluan | b6fcfa9fc8 | |
Deluan | 762a1ba998 |
|
@ -2,7 +2,7 @@
|
|||
|
||||
# [Choice] Go version: 1, 1.15, 1.14
|
||||
ARG VARIANT="1"
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/go:0-${VARIANT}
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/go:${VARIANT}
|
||||
|
||||
# [Option] Install Node.js
|
||||
ARG INSTALL_NODE="true"
|
||||
|
@ -17,4 +17,4 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
|||
# RUN go get -x <your-dependency-or-tool>
|
||||
|
||||
# [Optional] Uncomment this line to install global node packages.
|
||||
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
|
||||
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
|
||||
|
|
|
@ -4,10 +4,10 @@
|
|||
"dockerfile": "Dockerfile",
|
||||
"args": {
|
||||
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
|
||||
"VARIANT": "1.19",
|
||||
"VARIANT": "1.22",
|
||||
// Options
|
||||
"INSTALL_NODE": "true",
|
||||
"NODE_VERSION": "v16"
|
||||
"NODE_VERSION": "v20"
|
||||
}
|
||||
},
|
||||
"workspaceMount": "",
|
||||
|
|
|
@ -1,37 +0,0 @@
|
|||
---
|
||||
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 -->
|
|
@ -0,0 +1,103 @@
|
|||
name: Bug Report
|
||||
description: Before opening a new issue, please search to see if an issue already exists for the bug you encountered.
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "triage"]
|
||||
#assignees:
|
||||
# - deluan
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
### Thanks for taking the time to fill out this bug report!
|
||||
- type: checkboxes
|
||||
id: requirements
|
||||
attributes:
|
||||
label: "I confirm that:"
|
||||
options:
|
||||
- label: I have searched the existing [open AND closed issues](https://github.com/navidrome/navidrome/issues?q=is%3Aissue) to see if an issue already exists for the bug I've encountered
|
||||
required: true
|
||||
- label: I'm using the latest version (your issue may have been fixed already)
|
||||
required: false
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: What version of Navidrome are you running? (please try upgrading first, as your issue may have been fixed already).
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Current Behavior
|
||||
description: A concise description of what you're experiencing.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: A concise description of what you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps To Reproduce
|
||||
description: Steps to reproduce the behavior.
|
||||
placeholder: |
|
||||
1. In this scenario...
|
||||
2. With this config...
|
||||
3. Click (or Execute) '...'
|
||||
4. See error...
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: env
|
||||
attributes:
|
||||
label: Environment
|
||||
description: |
|
||||
examples:
|
||||
- **OS**: Ubuntu 20.04
|
||||
- **Browser**: Chrome 110.0.5481.177 on Windows 11
|
||||
- **Client**: DSub 5.5.1
|
||||
value: |
|
||||
- OS:
|
||||
- Browser:
|
||||
- Client:
|
||||
render: markdown
|
||||
- type: dropdown
|
||||
id: distribution
|
||||
attributes:
|
||||
label: How Navidrome is installed?
|
||||
multiple: false
|
||||
options:
|
||||
- Docker
|
||||
- Binary (from downloads page)
|
||||
- Package
|
||||
- Built from sources
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: config
|
||||
attributes:
|
||||
label: Configuration
|
||||
description: Please copy and paste your `navidrome.toml` (and/or `docker-compose.yml`) configuration. This will be automatically formatted into code, so no need for backticks.
|
||||
render: toml
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant log output
|
||||
description: Please copy and paste any relevant log output (change your `LogLevel` (`ND_LOGLEVEL`) to debug). This will be automatically formatted into code, so no need for backticks. ([Where I can find the logs?](https://www.navidrome.org/docs/faq/#where-are-the-logs))
|
||||
render: shell
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Anything else?
|
||||
description: |
|
||||
Links? References? Anything that will give us more context about the issue you are encountering!
|
||||
|
||||
Tip: You can attach screenshots by clicking this area to highlight it and then dragging files in.
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: Code of Conduct
|
||||
description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/navidrome/navidrome/blob/master/CODE_OF_CONDUCT.md).
|
||||
options:
|
||||
- label: I agree to follow Navidrome's Code of Conduct
|
||||
required: true
|
|
@ -0,0 +1,8 @@
|
|||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Ideas for new features
|
||||
url: https://github.com/navidrome/navidrome/discussions/categories/ideas
|
||||
about: This is the place to share and discuss new ideas and potentially new features.
|
||||
- name: Support requests
|
||||
url: https://github.com/navidrome/navidrome/discussions/categories/q-a
|
||||
about: This is the place to ask questions.
|
|
@ -1,24 +0,0 @@
|
|||
---
|
||||
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,22 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
GIT_TAG="${GITHUB_REF##refs/tags/}"
|
||||
GIT_BRANCH="${GITHUB_REF##refs/heads/}"
|
||||
GIT_SHA=$(git rev-parse --short HEAD)
|
||||
PR_NUM=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH")
|
||||
|
||||
DOCKER_IMAGE_TAG="--tag ${DOCKER_IMAGE}:sha-${GIT_SHA}"
|
||||
|
||||
if [[ $PR_NUM != "null" ]]; then
|
||||
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG} --tag ${DOCKER_IMAGE}:pr-${PR_NUM}"
|
||||
fi
|
||||
|
||||
if [[ $GITHUB_REF != "$GIT_TAG" ]]; then
|
||||
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG} --tag ${DOCKER_IMAGE}:${GIT_TAG#v} --tag ${DOCKER_IMAGE}:latest"
|
||||
elif [[ $GITHUB_REF == "refs/heads/master" ]]; then
|
||||
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG} --tag ${DOCKER_IMAGE}:develop"
|
||||
elif [[ $GIT_BRANCH = feature/* ]]; then
|
||||
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG} --tag ${DOCKER_IMAGE}:$(echo $GIT_BRANCH | tr / -)"
|
||||
fi
|
||||
|
||||
echo ${DOCKER_IMAGE_TAG}
|
|
@ -1,7 +1,7 @@
|
|||
name: Add download link to PR
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['Pipeline']
|
||||
workflows: ['Pipeline: Test, Lint, Build']
|
||||
types: [completed]
|
||||
jobs:
|
||||
pr_comment:
|
||||
|
|
|
@ -16,11 +16,13 @@ RUN chmod +x /navidrome
|
|||
|
||||
#####################################################
|
||||
### Build Final Image
|
||||
FROM alpine as release
|
||||
FROM alpine:3.18
|
||||
LABEL maintainer="deluan@navidrome.org"
|
||||
|
||||
# Install ffmpeg and output build config
|
||||
RUN apk add --no-cache ffmpeg
|
||||
# Install ffmpeg and mpv
|
||||
RUN apk add -U --no-cache ffmpeg mpv
|
||||
|
||||
# Show ffmpeg build info, for troubleshooting purposes
|
||||
RUN ffmpeg -buildconf
|
||||
|
||||
COPY --from=copy-binary /navidrome /app/
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
name: Pipeline
|
||||
name: "Pipeline: Test, Lint, Build"
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
|
@ -8,30 +8,28 @@ on:
|
|||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
go-lint:
|
||||
name: Lint Go code
|
||||
runs-on: ubuntu-latest
|
||||
container: deluan/ci-goreleaser:1.22.3-1
|
||||
steps:
|
||||
- name: Install taglib
|
||||
run: sudo apt-get install libtag1-dev
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go 1.19
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.19
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
- name: Config workspace folder as trusted
|
||||
run: git config --global --add safe.directory $GITHUB_WORKSPACE; git describe --dirty --always --tags
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: latest
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
problem-matchers: true
|
||||
args: --timeout 2m
|
||||
|
||||
- name: Install goimports
|
||||
run: go install golang.org/x/tools/cmd/goimports
|
||||
run: go install golang.org/x/tools/cmd/goimports@latest
|
||||
|
||||
- run: goimports -w `find . -name '*.go' | grep -v '_gen.go$'`
|
||||
- run: go mod tidy
|
||||
|
@ -39,28 +37,20 @@ jobs:
|
|||
run: |
|
||||
git status --porcelain
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "To fix this check, run: goimports -w $(find . -name '*.go' | grep -v '_gen.go$') && go mod tidy"
|
||||
echo 'To fix this check, run "make format" and commit the changes'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
go:
|
||||
name: Test with Go ${{ matrix.go_version }}
|
||||
name: Test Go code
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
go_version: [1.18.x,1.19.x]
|
||||
container: deluan/ci-goreleaser:1.22.3-1
|
||||
steps:
|
||||
- name: Install taglib
|
||||
run: sudo apt-get install libtag1-dev
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go ${{ matrix.go_version }}
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ${{ matrix.go_version }}
|
||||
cache: true
|
||||
- name: Config workspace folder as trusted
|
||||
run: git config --global --add safe.directory $GITHUB_WORKSPACE; git describe --dirty --always --tags
|
||||
|
||||
- name: Download dependencies
|
||||
if: steps.cache-go.outputs.cache-hit != 'true'
|
||||
|
@ -69,20 +59,20 @@ jobs:
|
|||
|
||||
- name: Test
|
||||
continue-on-error: ${{contains(matrix.go_version, 'beta') || contains(matrix.go_version, 'rc')}}
|
||||
run: go test -race -cover ./... -v
|
||||
run: go test -shuffle=on -race -cover ./... -v
|
||||
|
||||
js:
|
||||
name: Build JS bundle
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 16
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
node-version: 20
|
||||
cache: "npm"
|
||||
cache-dependency-path: "**/package-lock.json"
|
||||
|
||||
- name: npm install dependencies
|
||||
run: |
|
||||
|
@ -104,7 +94,7 @@ jobs:
|
|||
cd ui
|
||||
npm run build
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: js-bundle
|
||||
path: ui/build
|
||||
|
@ -114,41 +104,34 @@ jobs:
|
|||
name: Build binaries
|
||||
needs: [js, go, go-lint]
|
||||
runs-on: ubuntu-latest
|
||||
container: deluan/ci-goreleaser:1.22.3-1
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/download-artifact@v3
|
||||
- name: Config workspace folder as trusted
|
||||
run: git config --global --add safe.directory $GITHUB_WORKSPACE; git describe --dirty --always --tags
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: js-bundle
|
||||
path: ui/build
|
||||
|
||||
- name: Config /github/workspace folder as trusted
|
||||
uses: docker://deluan/ci-goreleaser:1.19.5-1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
args: /bin/bash -c "git config --global --add safe.directory /github/workspace; git describe --dirty --always --tags"
|
||||
|
||||
- name: Run GoReleaser - SNAPSHOT
|
||||
if: startsWith(github.ref, 'refs/tags/') != true
|
||||
uses: docker://deluan/ci-goreleaser:1.19.5-1
|
||||
run: goreleaser release --clean --skip=publish --snapshot
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
args: goreleaser release --rm-dist --skip-publish --snapshot
|
||||
|
||||
- name: Run GoReleaser - RELEASE
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: docker://deluan/ci-goreleaser:1.19.5-1
|
||||
run: goreleaser release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
args: goreleaser release --rm-dist
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: binaries
|
||||
path: |
|
||||
|
@ -158,7 +141,7 @@ jobs:
|
|||
retention-days: 7
|
||||
|
||||
docker:
|
||||
name: Build Docker images
|
||||
name: Build and publish Docker images
|
||||
needs: [binaries]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
|
@ -166,28 +149,59 @@ jobs:
|
|||
steps:
|
||||
- name: Set up QEMU
|
||||
id: qemu
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
|
||||
- uses: actions/download-artifact@v3
|
||||
- uses: actions/download-artifact@v4
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
with:
|
||||
name: binaries
|
||||
path: dist
|
||||
|
||||
- name: Build the Docker image and push
|
||||
- name: Login to Docker Hub
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
env:
|
||||
DOCKER_IMAGE: ${{secrets.DOCKER_IMAGE}}
|
||||
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 .
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
labels: |
|
||||
maintainer=deluan
|
||||
images: |
|
||||
name=${{secrets.DOCKER_IMAGE}}
|
||||
name=ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=develop,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and Push
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: .github/workflows/pipeline.dockerfile
|
||||
platforms: linux/amd64,linux/386,linux/arm/v6,linux/arm/v7,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
name: 'Close stale issues and PRs'
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '30 1 * * *'
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
stale:
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v5
|
||||
with:
|
||||
process-only: 'issues, prs'
|
||||
issue-inactive-days: 120
|
||||
pr-inactive-days: 120
|
||||
log-output: true
|
||||
add-issue-labels: 'frozen-due-to-age'
|
||||
add-pr-labels: 'frozen-due-to-age'
|
||||
issue-comment: >
|
||||
This issue has been automatically locked since there
|
||||
has not been any recent activity after it was closed.
|
||||
Please open a new issue for related bugs.
|
||||
pr-comment: >
|
||||
This pull request has been automatically locked since there
|
||||
has not been any recent activity after it was closed.
|
||||
Please open a new issue for related bugs.
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
operations-per-run: 999
|
||||
days-before-issue-stale: 180
|
||||
days-before-pr-stale: 180
|
||||
days-before-issue-close: 30
|
||||
days-before-pr-close: 30
|
||||
stale-issue-message: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. The resources of the Navidrome team are limited, and so we are asking for your help.
|
||||
|
||||
If this is a **bug** and you can still reproduce this error on the <code>master</code> branch, please reply with all of the information you have about it in order to keep the issue open.
|
||||
|
||||
If this is a **feature request**, and you feel that it is still relevant and valuable, please tell us why.
|
||||
|
||||
This issue will automatically be closed in the near future if no further activity occurs. Thank you for all your contributions.
|
||||
stale-pr-message: This PR has been automatically marked as stale because it has not had
|
||||
recent activity. The resources of the Navidrome team are limited, and so we are asking for your help.
|
||||
|
||||
Please check https://github.com/navidrome/navidrome/blob/master/CONTRIBUTING.md#pull-requests and verify that this code contribution fits with the description. If yes, tell it in a comment.
|
||||
|
||||
This PR will automatically be closed in the near future if no further activity occurs. Thank you for all your contributions.
|
||||
stale-issue-label: 'stale'
|
||||
exempt-issue-labels: 'keep,security'
|
||||
stale-pr-label: 'stale'
|
||||
exempt-pr-labels: 'keep,security'
|
|
@ -1,4 +1,4 @@
|
|||
name: Update Translations
|
||||
name: POEditor import
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
|
@ -6,16 +6,21 @@ on:
|
|||
jobs:
|
||||
update-translations:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'navidrome' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Get updated translations
|
||||
env:
|
||||
POEDITOR_PROJECTID: ${{ secrets.POEDITOR_PROJECTID }}
|
||||
POEDITOR_APIKEY: ${{ secrets.POEDITOR_APIKEY }}
|
||||
run: |
|
||||
./update-translations.sh
|
||||
- name: Show changes, if any
|
||||
run: |
|
||||
git status --porcelain
|
||||
git diff
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v4
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
token: ${{ secrets.PAT }}
|
||||
commit-message: Update translations
|
||||
|
|
|
@ -14,12 +14,15 @@ navidrome.toml
|
|||
master.zip
|
||||
testDB
|
||||
navidrome.db
|
||||
cache/*
|
||||
*.swp
|
||||
embedded_gen.go
|
||||
dist
|
||||
music
|
||||
docker-compose.yml
|
||||
navidrome.db-shm
|
||||
navidrome.db-wal
|
||||
tags
|
||||
.gitinfo
|
||||
docker-compose.yml
|
||||
!contrib/docker-compose.yml
|
||||
test-123.db
|
||||
|
|
|
@ -1,13 +1,9 @@
|
|||
run:
|
||||
go: "1.19"
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- asasalint
|
||||
- asciicheck
|
||||
- bidichk
|
||||
- bodyclose
|
||||
- depguard
|
||||
- dogsled
|
||||
- durationcheck
|
||||
- errcheck
|
||||
|
@ -29,8 +25,9 @@ linters:
|
|||
- unused
|
||||
- whitespace
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
- linters:
|
||||
- gosec
|
||||
text: "(G501|G401|G505):"
|
||||
linters-settings:
|
||||
gosec:
|
||||
excludes:
|
||||
- G501
|
||||
- G401
|
||||
- G505
|
||||
|
|
|
@ -116,12 +116,6 @@ archives:
|
|||
- format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
replacements:
|
||||
darwin: macOS
|
||||
linux: Linux
|
||||
windows: Windows
|
||||
386: i386
|
||||
amd64: x86_64
|
||||
|
||||
checksum:
|
||||
name_template: "{{ .ProjectName }}_checksums.txt"
|
||||
|
|
|
@ -2,12 +2,15 @@
|
|||
|
||||
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.
|
||||
|
||||
- [Asking Support Questions](#asking-support-questions)
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [Issues](#issues)
|
||||
- [Questions](#questions)
|
||||
- [Pull Requests](#pull-requests)
|
||||
|
||||
|
||||
## Asking Support Questions
|
||||
We have an active [discussion forum](https://github.com/navidrome/navidrome/discussions) where users and developers can ask questions. Please don't use the GitHub issue tracker to ask questions.
|
||||
|
||||
## Code of Conduct
|
||||
Please read the following [Code of Conduct](https://github.com/navidrome/navidrome/blob/master/CODE_OF_CONDUCT.md).
|
||||
|
||||
|
@ -19,9 +22,6 @@ 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)
|
||||
|
|
57
Makefile
57
Makefile
|
@ -9,7 +9,7 @@ GIT_SHA=source_archive
|
|||
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))
|
||||
endif
|
||||
|
||||
CI_RELEASER_VERSION=1.19.5-1 ## https://github.com/navidrome/ci-goreleaser
|
||||
CI_RELEASER_VERSION=1.22.3-1 ## https://github.com/navidrome/ci-goreleaser
|
||||
|
||||
setup: check_env download-deps setup-git ##@1_Run_First Install dependencies and prepare development environment
|
||||
@echo Downloading Node dependencies...
|
||||
|
@ -21,15 +21,15 @@ dev: check_env ##@Development Start Navidrome in development mode, with hot-re
|
|||
.PHONY: dev
|
||||
|
||||
server: check_go_env ##@Development Start the backend in development mode
|
||||
@go run github.com/cespare/reflex -d none -c reflex.conf
|
||||
@go run github.com/cespare/reflex@latest -d none -c reflex.conf
|
||||
.PHONY: server
|
||||
|
||||
watch: ##@Development Start Go tests in watch mode (re-run when code changes)
|
||||
go run github.com/onsi/ginkgo/v2/ginkgo watch -notify ./...
|
||||
go run github.com/onsi/ginkgo/v2/ginkgo@latest watch -notify ./...
|
||||
.PHONY: watch
|
||||
|
||||
test: ##@Development Run Go tests
|
||||
go test -race ./...
|
||||
go test -race -shuffle=on ./...
|
||||
.PHONY: test
|
||||
|
||||
testall: test ##@Development Run Go and JS tests
|
||||
|
@ -37,24 +37,36 @@ testall: test ##@Development Run Go and JS tests
|
|||
.PHONY: testall
|
||||
|
||||
lint: ##@Development Lint Go code
|
||||
go run github.com/golangci/golangci-lint/cmd/golangci-lint run -v --timeout 5m
|
||||
go run github.com/golangci/golangci-lint/cmd/golangci-lint@latest run -v --timeout 5m
|
||||
.PHONY: lint
|
||||
|
||||
lintall: lint ##@Development Lint Go and JS code
|
||||
@(cd ./ui && npm run check-formatting && npm run lint)
|
||||
@(cd ./ui && npm run check-formatting) || (echo "\n\nPlease run 'npm run prettier' to fix formatting issues." && exit 1)
|
||||
@(cd ./ui && npm run lint)
|
||||
.PHONY: lintall
|
||||
|
||||
format: ##@Development Format code
|
||||
@(cd ./ui && npm run prettier)
|
||||
@go run golang.org/x/tools/cmd/goimports@latest -w `find . -name '*.go' | grep -v _gen.go$$`
|
||||
@go mod tidy
|
||||
.PHONY: format
|
||||
|
||||
wire: check_go_env ##@Development Update Dependency Injection
|
||||
go run github.com/google/wire/cmd/wire ./...
|
||||
go run github.com/google/wire/cmd/wire@latest ./...
|
||||
.PHONY: wire
|
||||
|
||||
snapshots: ##@Development Update (GoLang) Snapshot tests
|
||||
UPDATE_SNAPSHOTS=true go run github.com/onsi/ginkgo/v2/ginkgo ./server/subsonic/...
|
||||
UPDATE_SNAPSHOTS=true go run github.com/onsi/ginkgo/v2/ginkgo@latest ./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}
|
||||
migration-sql: ##@Development Create an empty SQL migration file
|
||||
@if [ -z "${name}" ]; then echo "Usage: make migration-sql name=name_of_migration_file"; exit 1; fi
|
||||
go run github.com/pressly/goose/v3/cmd/goose@latest -dir db/migrations create ${name} sql
|
||||
.PHONY: migration
|
||||
|
||||
migration-go: ##@Development Create an empty Go migration file
|
||||
@if [ -z "${name}" ]; then echo "Usage: make migration-go name=name_of_migration_file"; exit 1; fi
|
||||
go run github.com/pressly/goose/v3/cmd/goose@latest -dir db/migrations create ${name}
|
||||
.PHONY: migration
|
||||
|
||||
setup-dev: setup
|
||||
|
@ -73,13 +85,18 @@ build: warning-noui-build check_go_env ##@Build Build only backend
|
|||
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
|
||||
|
||||
debug-build: warning-noui-build check_go_env ##@Build Build only backend (with remote debug on)
|
||||
go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT" -tags=netgo
|
||||
.PHONY: debug-build
|
||||
|
||||
buildjs: check_node_env ##@Build Build only frontend
|
||||
@(cd ./ui && npm run build)
|
||||
.PHONY: buildjs
|
||||
|
||||
all: warning-noui-build ##@Cross_Compilation Build binaries for all supported platforms. It does not build the frontend
|
||||
@echo "Building binaries for all platforms using builder ${CI_RELEASER_VERSION}"
|
||||
docker run -t -v $(PWD):/workspace -w /workspace deluan/ci-goreleaser:$(CI_RELEASER_VERSION) \
|
||||
goreleaser release --rm-dist --skip-publish --snapshot
|
||||
goreleaser release --clean --skip=publish --snapshot
|
||||
.PHONY: all
|
||||
|
||||
single: warning-noui-build ##@Cross_Compilation Build binaries for a single supported platforms. It does not build the frontend
|
||||
|
@ -89,11 +106,17 @@ single: warning-noui-build ##@Cross_Compilation Build binaries for a single supp
|
|||
grep -- "- id: navidrome_" .goreleaser.yml | sed 's/- id: navidrome_//g'; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "Building binaries for ${GOOS}/${GOARCH}"
|
||||
@echo "Building binaries for ${GOOS}/${GOARCH} using builder ${CI_RELEASER_VERSION}"
|
||||
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}
|
||||
goreleaser build --clean --snapshot -p 2 --single-target --id navidrome_${GOOS}_${GOARCH}
|
||||
.PHONY: single
|
||||
|
||||
docker: buildjs ##@Build Build Docker linux/amd64 image (tagged as `deluan/navidrome:develop`)
|
||||
GOOS=linux GOARCH=amd64 make single
|
||||
@echo "Building Docker image"
|
||||
docker build . --platform linux/amd64 -t deluan/navidrome:develop -f .github/workflows/pipeline.dockerfile
|
||||
.PHONY: docker
|
||||
|
||||
warning-noui-build:
|
||||
@echo "WARNING: This command does not build the frontend, it uses the latest built with 'make buildjs'"
|
||||
.PHONY: warning-noui-build
|
||||
|
@ -102,9 +125,9 @@ get-music: ##@Development Download some free music from Navidrome's demo instanc
|
|||
mkdir -p music
|
||||
( cd music; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=ec2093ec4801402f1e17cc462195cdbb" > brock.zip; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=NavidromeUI&id=b376eeb4652d2498aa2b25ba0696725e" > back_on_earth.zip; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=NavidromeUI&id=e49c609b542fc51899ee8b53aa858cb4" > ugress.zip; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=NavidromeUI&id=350bcab3a4c1d93869e39ce496464f03" > voodoocuts.zip; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=b376eeb4652d2498aa2b25ba0696725e" > back_on_earth.zip; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=e49c609b542fc51899ee8b53aa858cb4" > ugress.zip; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=350bcab3a4c1d93869e39ce496464f03" > voodoocuts.zip; \
|
||||
for file in *.zip; do unzip -n $${file}; done )
|
||||
@echo "Done. Remember to set your MusicFolder to ./music"
|
||||
.PHONY: get-music
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
JS: sh -c "cd ./ui && npm start"
|
||||
GO: go run github.com/cespare/reflex -d none -c reflex.conf
|
||||
GO: go run github.com/cespare/reflex@latest -d none -c reflex.conf
|
||||
|
|
16
README.md
16
README.md
|
@ -1,13 +1,13 @@
|
|||
<a href="https://www.navidrome.org"><img src="resources/logo-192x192.png" alt="Navidrome logo" title="navidrome" align="right" height="60px" /></a>
|
||||
|
||||
# Navidrome Music Server
|
||||
# Navidrome Music Server [![Tweet](https://img.shields.io/twitter/url/http/shields.io.svg?style=social)](https://twitter.com/intent/tweet?text=Tired%20of%20paying%20for%20music%20subscriptions%2C%20and%20not%20finding%20what%20you%20really%20like%3F%20Roll%20your%20own%20streaming%20service%21&url=https://navidrome.org&via=navidrome)
|
||||
|
||||
[![Last Release](https://img.shields.io/github/v/release/navidrome/navidrome?logo=github&label=latest&style=flat-square)](https://github.com/navidrome/navidrome/releases)
|
||||
[![Build](https://img.shields.io/github/actions/workflow/status/navidrome/navidrome/pipeline.yml?branch=master&logo=github&style=flat-square)](https://nightly.link/navidrome/navidrome/workflows/pipeline/master)
|
||||
[![Downloads](https://img.shields.io/github/downloads/navidrome/navidrome/total?logo=github&style=flat-square)](https://github.com/navidrome/navidrome/releases/latest)
|
||||
[![Docker Pulls](https://img.shields.io/docker/pulls/deluan/navidrome?logo=docker&label=pulls&style=flat-square)](https://hub.docker.com/r/deluan/navidrome)
|
||||
[![Dev Chat](https://img.shields.io/discord/671335427726114836?logo=discord&label=discord&style=flat-square)](https://discord.gg/xh7j7yF)
|
||||
[![Subreddit](https://img.shields.io/reddit/subreddit-subscribers/navidrome?logo=reddit&label=/r/navidrome&style=flat-square)](https://www.reddit.com/r/navidrome/)
|
||||
[![Subreddit](https://img.shields.io/badge/%2Fr%2Fnavidrome-%2B3000-red?logo=reddit)](https://www.reddit.com/r/navidrome/)
|
||||
[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-v2.0-ff69b4.svg?style=flat-square)](CODE_OF_CONDUCT.md)
|
||||
|
||||
Navidrome is an open source web-based music collection server and streamer. It gives you freedom to listen to your
|
||||
|
@ -30,11 +30,15 @@ please file a [GitHub issue](https://github.com/navidrome/navidrome/issues) or j
|
|||
|
||||
## Installation
|
||||
|
||||
See instructions in the [project's website](https://www.navidrome.org/docs/installation/)
|
||||
See instructions on the [project's website](https://www.navidrome.org/docs/installation/)
|
||||
|
||||
If you plan to host Navidrome in the cloud, a great option is to get a virtual server at [BuyVM](https://my.frantech.ca/aff.php?aff=4605).
|
||||
They have plans that start at $3.50/month! If you decide to sign up, please consider using our [affliliate link](https://my.frantech.ca/aff.php?aff=4605),
|
||||
to help support the project <3
|
||||
## Cloud Hosting
|
||||
|
||||
[PikaPods](https://www.pikapods.com) has partnered with us to offer you an
|
||||
[officially supported, cloud-hosted solution](https://www.navidrome.org/docs/installation/managed/#pikapods).
|
||||
A share of the revenue helps fund the development of Navidrome at no additional cost for you.
|
||||
|
||||
[![PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=navidrome)
|
||||
|
||||
## Features
|
||||
|
||||
|
|
|
@ -0,0 +1,99 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/scanner/metadata"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"github.com/spf13/cobra"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
extractor string
|
||||
format string
|
||||
)
|
||||
|
||||
func init() {
|
||||
inspectCmd.Flags().StringVarP(&extractor, "extractor", "x", "", "extractor to use (ffmpeg or taglib, default: auto)")
|
||||
inspectCmd.Flags().StringVarP(&format, "format", "f", "pretty", "output format (pretty, toml, yaml, json, jsonindent)")
|
||||
rootCmd.AddCommand(inspectCmd)
|
||||
}
|
||||
|
||||
var inspectCmd = &cobra.Command{
|
||||
Use: "inspect [files to inspect]",
|
||||
Short: "Inspect tags",
|
||||
Long: "Show file tags as seen by Navidrome",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runInspector(args)
|
||||
},
|
||||
}
|
||||
|
||||
var marshalers = map[string]func(interface{}) ([]byte, error){
|
||||
"pretty": prettyMarshal,
|
||||
"toml": toml.Marshal,
|
||||
"yaml": yaml.Marshal,
|
||||
"json": json.Marshal,
|
||||
"jsonindent": func(v interface{}) ([]byte, error) {
|
||||
return json.MarshalIndent(v, "", " ")
|
||||
},
|
||||
}
|
||||
|
||||
func prettyMarshal(v interface{}) ([]byte, error) {
|
||||
out := v.([]inspectorOutput)
|
||||
var res strings.Builder
|
||||
for i := range out {
|
||||
res.WriteString(fmt.Sprintf("====================\nFile: %s\n\n", out[i].File))
|
||||
t, _ := toml.Marshal(out[i].RawTags)
|
||||
res.WriteString(fmt.Sprintf("Raw tags:\n%s\n\n", t))
|
||||
t, _ = toml.Marshal(out[i].MappedTags)
|
||||
res.WriteString(fmt.Sprintf("Mapped tags:\n%s\n\n", t))
|
||||
}
|
||||
return []byte(res.String()), nil
|
||||
}
|
||||
|
||||
type inspectorOutput struct {
|
||||
File string
|
||||
RawTags metadata.ParsedTags
|
||||
MappedTags model.MediaFile
|
||||
}
|
||||
|
||||
func runInspector(args []string) {
|
||||
if extractor != "" {
|
||||
conf.Server.Scanner.Extractor = extractor
|
||||
}
|
||||
log.Info("Using extractor", "extractor", conf.Server.Scanner.Extractor)
|
||||
md, err := metadata.Extract(args...)
|
||||
if err != nil {
|
||||
log.Fatal("Error extracting tags", err)
|
||||
}
|
||||
mapper := scanner.NewMediaFileMapper(conf.Server.MusicFolder, &tests.MockedGenreRepo{})
|
||||
marshal := marshalers[format]
|
||||
if marshal == nil {
|
||||
log.Fatal("Invalid format", "format", format)
|
||||
}
|
||||
var out []inspectorOutput
|
||||
for k, v := range md {
|
||||
if !model.IsAudioFile(k) {
|
||||
continue
|
||||
}
|
||||
if len(v.Tags) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, inspectorOutput{
|
||||
File: k,
|
||||
RawTags: v.Tags,
|
||||
MappedTags: mapper.ToMediaFile(v),
|
||||
})
|
||||
}
|
||||
data, _ := marshal(out)
|
||||
fmt.Println(string(data))
|
||||
}
|
98
cmd/root.go
98
cmd/root.go
|
@ -2,12 +2,14 @@ package cmd
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
|
@ -17,14 +19,11 @@ import (
|
|||
"github.com/navidrome/navidrome/scheduler"
|
||||
"github.com/navidrome/navidrome/server/backgrounds"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
var interrupted = errors.New("service was interrupted")
|
||||
|
||||
var (
|
||||
cfgFile string
|
||||
noBanner bool
|
||||
|
@ -40,10 +39,14 @@ Complete documentation is available at https://www.navidrome.org/docs`,
|
|||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runNavidrome()
|
||||
},
|
||||
PostRun: func(cmd *cobra.Command, args []string) {
|
||||
postRun()
|
||||
},
|
||||
Version: consts.Version,
|
||||
}
|
||||
)
|
||||
|
||||
// Execute runs the root cobra command, which will start the Navidrome server by calling the runNavidrome function.
|
||||
func Execute() {
|
||||
rootCmd.SetVersionTemplate(`{{println .Version}}`)
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
|
@ -59,26 +62,42 @@ func preRun() {
|
|||
conf.Load()
|
||||
}
|
||||
|
||||
func runNavidrome() {
|
||||
db.EnsureLatestVersion()
|
||||
defer func() {
|
||||
if err := db.Close(); err != nil {
|
||||
log.Error("Error closing DB", err)
|
||||
}
|
||||
log.Info("Navidrome stopped, bye.")
|
||||
}()
|
||||
func postRun() {
|
||||
log.Info("Navidrome stopped, bye.")
|
||||
}
|
||||
|
||||
g, ctx := errgroup.WithContext(context.Background())
|
||||
// runNavidrome is the main entry point for the Navidrome server. It starts all the services and blocks.
|
||||
// If any of the services returns an error, it will log it and exit. If the process receives a signal to exit,
|
||||
// it will cancel the context and exit gracefully.
|
||||
func runNavidrome() {
|
||||
defer db.Init()()
|
||||
|
||||
ctx, cancel := mainContext()
|
||||
defer cancel()
|
||||
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
g.Go(startServer(ctx))
|
||||
g.Go(startSignaler(ctx))
|
||||
g.Go(startSignaller(ctx))
|
||||
g.Go(startScheduler(ctx))
|
||||
g.Go(startPlaybackServer(ctx))
|
||||
g.Go(schedulePeriodicScan(ctx))
|
||||
|
||||
if err := g.Wait(); err != nil && !errors.Is(err, interrupted) {
|
||||
if err := g.Wait(); err != nil {
|
||||
log.Error("Fatal error in Navidrome. Aborting", err)
|
||||
}
|
||||
}
|
||||
|
||||
// mainContext returns a context that is cancelled when the process receives a signal to exit.
|
||||
func mainContext() (context.Context, context.CancelFunc) {
|
||||
return signal.NotifyContext(context.Background(),
|
||||
os.Interrupt,
|
||||
syscall.SIGHUP,
|
||||
syscall.SIGTERM,
|
||||
syscall.SIGABRT,
|
||||
)
|
||||
}
|
||||
|
||||
// startServer starts the Navidrome web server, adding all the necessary routers.
|
||||
func startServer(ctx context.Context) func() error {
|
||||
return func() error {
|
||||
a := CreateServer(conf.Server.MusicFolder)
|
||||
|
@ -96,13 +115,17 @@ func startServer(ctx context.Context) func() error {
|
|||
core.WriteInitialMetrics()
|
||||
a.MountRouter("Prometheus metrics", conf.Server.Prometheus.MetricsPath, promhttp.Handler())
|
||||
}
|
||||
if conf.Server.DevEnableProfiler {
|
||||
a.MountRouter("Profiling", "/debug", middleware.Profiler())
|
||||
}
|
||||
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
|
||||
a.MountRouter("Background images", consts.DefaultUILoginBackgroundURL, backgrounds.NewHandler())
|
||||
}
|
||||
return a.Run(ctx, fmt.Sprintf("%s:%d", conf.Server.Address, conf.Server.Port))
|
||||
return a.Run(ctx, conf.Server.Address, conf.Server.Port, conf.Server.TLSCert, conf.Server.TLSKey)
|
||||
}
|
||||
}
|
||||
|
||||
// schedulePeriodicScan schedules a periodic scan of the music library, if configured.
|
||||
func schedulePeriodicScan(ctx context.Context) func() error {
|
||||
return func() error {
|
||||
schedule := conf.Server.ScanSchedule
|
||||
|
@ -132,16 +155,30 @@ func schedulePeriodicScan(ctx context.Context) func() error {
|
|||
}
|
||||
}
|
||||
|
||||
// startScheduler starts the Navidrome scheduler, which is used to run periodic tasks.
|
||||
func startScheduler(ctx context.Context) func() error {
|
||||
log.Info(ctx, "Starting scheduler")
|
||||
schedulerInstance := scheduler.GetInstance()
|
||||
|
||||
return func() error {
|
||||
log.Info(ctx, "Starting scheduler")
|
||||
schedulerInstance := scheduler.GetInstance()
|
||||
schedulerInstance.Run(ctx)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// startPlaybackServer starts the Navidrome playback server, if configured.
|
||||
// It is responsible for the Jukebox functionality
|
||||
func startPlaybackServer(ctx context.Context) func() error {
|
||||
return func() error {
|
||||
if !conf.Server.Jukebox.Enabled {
|
||||
log.Debug("Jukebox is DISABLED")
|
||||
return nil
|
||||
}
|
||||
log.Info(ctx, "Starting Jukebox service")
|
||||
playbackInstance := GetPlaybackServer()
|
||||
return playbackInstance.Run(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Implement some struct tags to map flags to viper
|
||||
func init() {
|
||||
cobra.OnInitialize(func() {
|
||||
|
@ -151,22 +188,29 @@ func init() {
|
|||
rootCmd.PersistentFlags().StringVarP(&cfgFile, "configfile", "c", "", `config file (default "./navidrome.toml")`)
|
||||
rootCmd.PersistentFlags().BoolVarP(&noBanner, "nobanner", "n", false, `don't show banner`)
|
||||
rootCmd.PersistentFlags().String("musicfolder", viper.GetString("musicfolder"), "folder where your music is stored")
|
||||
rootCmd.PersistentFlags().String("datafolder", viper.GetString("datafolder"), "folder to store application data (DB, cache...), needs write access")
|
||||
rootCmd.PersistentFlags().String("datafolder", viper.GetString("datafolder"), "folder to store application data (DB), needs write access")
|
||||
rootCmd.PersistentFlags().String("cachefolder", viper.GetString("cachefolder"), "folder to store cache data (transcoding, images...), needs write access")
|
||||
rootCmd.PersistentFlags().StringP("loglevel", "l", viper.GetString("loglevel"), "log level, possible values: error, info, debug, trace")
|
||||
|
||||
_ = viper.BindPFlag("musicfolder", rootCmd.PersistentFlags().Lookup("musicfolder"))
|
||||
_ = viper.BindPFlag("datafolder", rootCmd.PersistentFlags().Lookup("datafolder"))
|
||||
_ = viper.BindPFlag("cachefolder", rootCmd.PersistentFlags().Lookup("cachefolder"))
|
||||
_ = viper.BindPFlag("loglevel", rootCmd.PersistentFlags().Lookup("loglevel"))
|
||||
|
||||
rootCmd.Flags().StringP("address", "a", viper.GetString("address"), "IP address to bind")
|
||||
rootCmd.Flags().IntP("port", "p", viper.GetInt("port"), "HTTP port Navidrome will use")
|
||||
rootCmd.Flags().StringP("address", "a", viper.GetString("address"), "IP address to bind to")
|
||||
rootCmd.Flags().IntP("port", "p", viper.GetInt("port"), "HTTP port Navidrome will listen to")
|
||||
rootCmd.Flags().String("baseurl", viper.GetString("baseurl"), "base URL to configure Navidrome behind a proxy (ex: /music or http://my.server.com)")
|
||||
rootCmd.Flags().String("tlscert", viper.GetString("tlscert"), "optional path to a TLS cert file (enables HTTPS listening)")
|
||||
rootCmd.Flags().String("unixsocketperm", viper.GetString("unixsocketperm"), "optional file permission for the unix socket")
|
||||
rootCmd.Flags().String("tlskey", viper.GetString("tlskey"), "optional path to a TLS key file (enables HTTPS listening)")
|
||||
|
||||
rootCmd.Flags().Duration("sessiontimeout", viper.GetDuration("sessiontimeout"), "how long Navidrome will wait before closing web ui idle sessions")
|
||||
rootCmd.Flags().Duration("scaninterval", viper.GetDuration("scaninterval"), "how frequently to scan for changes in your music library")
|
||||
rootCmd.Flags().String("baseurl", viper.GetString("baseurl"), "base URL (only the path part) to configure Navidrome behind a proxy (ex: /music)")
|
||||
rootCmd.Flags().String("uiloginbackgroundurl", viper.GetString("uiloginbackgroundurl"), "URL to a backaground image used in the Login page")
|
||||
rootCmd.Flags().Bool("enabletranscodingconfig", viper.GetBool("enabletranscodingconfig"), "enables transcoding configuration in the UI")
|
||||
rootCmd.Flags().String("transcodingcachesize", viper.GetString("transcodingcachesize"), "size of transcoding cache")
|
||||
rootCmd.Flags().String("imagecachesize", viper.GetString("imagecachesize"), "size of image (art work) cache. set to 0 to disable cache")
|
||||
rootCmd.Flags().String("albumplaycountmode", viper.GetString("albumplaycountmode"), "how to compute playcount for albums. absolute (default) or normalized")
|
||||
rootCmd.Flags().Bool("autoimportplaylists", viper.GetBool("autoimportplaylists"), "enable/disable .m3u playlist auto-import`")
|
||||
|
||||
rootCmd.Flags().Bool("prometheus.enabled", viper.GetBool("prometheus.enabled"), "enable/disable prometheus metrics endpoint`")
|
||||
|
@ -174,9 +218,13 @@ func init() {
|
|||
|
||||
_ = viper.BindPFlag("address", rootCmd.Flags().Lookup("address"))
|
||||
_ = viper.BindPFlag("port", rootCmd.Flags().Lookup("port"))
|
||||
_ = viper.BindPFlag("tlscert", rootCmd.Flags().Lookup("tlscert"))
|
||||
_ = viper.BindPFlag("unixsocketperm", rootCmd.Flags().Lookup("unixsocketperm"))
|
||||
_ = viper.BindPFlag("tlskey", rootCmd.Flags().Lookup("tlskey"))
|
||||
_ = viper.BindPFlag("baseurl", rootCmd.Flags().Lookup("baseurl"))
|
||||
|
||||
_ = viper.BindPFlag("sessiontimeout", rootCmd.Flags().Lookup("sessiontimeout"))
|
||||
_ = viper.BindPFlag("scaninterval", rootCmd.Flags().Lookup("scaninterval"))
|
||||
_ = viper.BindPFlag("baseurl", rootCmd.Flags().Lookup("baseurl"))
|
||||
_ = viper.BindPFlag("uiloginbackgroundurl", rootCmd.Flags().Lookup("uiloginbackgroundurl"))
|
||||
|
||||
_ = viper.BindPFlag("prometheus.enabled", rootCmd.Flags().Lookup("prometheus.enabled"))
|
||||
|
|
|
@ -1,27 +0,0 @@
|
|||
//go:build windows || plan9
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
func startSignaler(ctx context.Context) func() error {
|
||||
log.Info(ctx, "Starting signaler")
|
||||
|
||||
return func() error {
|
||||
var sigChan = make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, os.Interrupt)
|
||||
select {
|
||||
case sig := <-sigChan:
|
||||
log.Info(ctx, "Received termination signal", "signal", sig)
|
||||
return interrupted
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
//go:build windows || plan9
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// Windows and Plan9 don't support SIGUSR1, so we don't need to start a signaler
|
||||
func startSignaller(ctx context.Context) func() error {
|
||||
return func() error {
|
||||
return nil
|
||||
}
|
||||
}
|
|
@ -14,28 +14,17 @@ import (
|
|||
|
||||
const triggerScanSignal = syscall.SIGUSR1
|
||||
|
||||
func startSignaler(ctx context.Context) func() error {
|
||||
func startSignaller(ctx context.Context) func() error {
|
||||
log.Info(ctx, "Starting signaler")
|
||||
scanner := GetScanner()
|
||||
|
||||
return func() error {
|
||||
var sigChan = make(chan os.Signal, 1)
|
||||
signal.Notify(
|
||||
sigChan,
|
||||
os.Interrupt,
|
||||
triggerScanSignal,
|
||||
syscall.SIGHUP,
|
||||
syscall.SIGTERM,
|
||||
syscall.SIGABRT,
|
||||
)
|
||||
signal.Notify(sigChan, triggerScanSignal)
|
||||
|
||||
for {
|
||||
select {
|
||||
case sig := <-sigChan:
|
||||
if sig != triggerScanSignal {
|
||||
log.Info(ctx, "Received termination signal", "signal", sig)
|
||||
return interrupted
|
||||
}
|
||||
log.Info(ctx, "Received signal, triggering a new scan", "signal", sig)
|
||||
start := time.Now()
|
||||
err := scanner.RescanAll(ctx, false)
|
|
@ -1,6 +1,6 @@
|
|||
// Code generated by Wire. DO NOT EDIT.
|
||||
|
||||
//go:generate go run github.com/google/wire/cmd/wire
|
||||
//go:generate go run -mod=mod github.com/google/wire/cmd/wire
|
||||
//go:build !wireinject
|
||||
// +build !wireinject
|
||||
|
||||
|
@ -14,6 +14,7 @@ import (
|
|||
"github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
|
@ -23,7 +24,6 @@ import (
|
|||
"github.com/navidrome/navidrome/server/nativeapi"
|
||||
"github.com/navidrome/navidrome/server/public"
|
||||
"github.com/navidrome/navidrome/server/subsonic"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Injectors from wire_injectors.go:
|
||||
|
@ -31,16 +31,17 @@ import (
|
|||
func CreateServer(musicFolder string) *server.Server {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
serverServer := server.New(dataStore)
|
||||
broker := events.GetBroker()
|
||||
serverServer := server.New(dataStore, broker)
|
||||
return serverServer
|
||||
}
|
||||
|
||||
func CreateNativeAPIRouter() *nativeapi.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
broker := events.GetBroker()
|
||||
share := core.NewShare(dataStore)
|
||||
router := nativeapi.New(dataStore, broker, share)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
router := nativeapi.New(dataStore, share, playlists)
|
||||
return router
|
||||
}
|
||||
|
||||
|
@ -54,14 +55,16 @@ func CreateSubsonicAPIRouter() *subsonic.Router {
|
|||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata)
|
||||
transcodingCache := core.GetTranscodingCache()
|
||||
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore)
|
||||
players := core.NewPlayers(dataStore)
|
||||
scanner := GetScanner()
|
||||
broker := events.GetBroker()
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker)
|
||||
share := core.NewShare(dataStore)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, externalMetadata, scanner, broker, playlists, playTracker, share)
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||
players := core.NewPlayers(dataStore)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
broker := events.GetBroker()
|
||||
scannerScanner := scanner.GetInstance(dataStore, playlists, cacheWarmer, broker)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker)
|
||||
playbackServer := playback.GetInstance(dataStore)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, externalMetadata, scannerScanner, broker, playlists, playTracker, share, playbackServer)
|
||||
return router
|
||||
}
|
||||
|
||||
|
@ -76,7 +79,8 @@ func CreatePublicRouter() *public.Router {
|
|||
transcodingCache := core.GetTranscodingCache()
|
||||
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
share := core.NewShare(dataStore)
|
||||
router := public.New(dataStore, artworkArtwork, mediaStreamer, share)
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||
router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver)
|
||||
return router
|
||||
}
|
||||
|
||||
|
@ -94,7 +98,7 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
|
|||
return router
|
||||
}
|
||||
|
||||
func createScanner() scanner.Scanner {
|
||||
func GetScanner() scanner.Scanner {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
|
@ -105,23 +109,17 @@ func createScanner() scanner.Scanner {
|
|||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
broker := events.GetBroker()
|
||||
scannerScanner := scanner.New(dataStore, playlists, cacheWarmer, broker)
|
||||
scannerScanner := scanner.GetInstance(dataStore, playlists, cacheWarmer, broker)
|
||||
return scannerScanner
|
||||
}
|
||||
|
||||
func GetPlaybackServer() playback.PlaybackServer {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
playbackServer := playback.GetInstance(dataStore)
|
||||
return playbackServer
|
||||
}
|
||||
|
||||
// wire_injectors.go:
|
||||
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, db.Db)
|
||||
|
||||
// Scanner must be a Singleton
|
||||
var (
|
||||
onceScanner sync.Once
|
||||
scannerInstance scanner.Scanner
|
||||
)
|
||||
|
||||
func GetScanner() scanner.Scanner {
|
||||
onceScanner.Do(func() {
|
||||
scannerInstance = createScanner()
|
||||
})
|
||||
return scannerInstance
|
||||
}
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.GetInstance, db.Db)
|
||||
|
|
|
@ -3,13 +3,12 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/google/wire"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/agents/lastfm"
|
||||
"github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
|
@ -23,6 +22,7 @@ import (
|
|||
var allProviders = wire.NewSet(
|
||||
core.Set,
|
||||
artwork.Set,
|
||||
server.New,
|
||||
subsonic.New,
|
||||
nativeapi.New,
|
||||
public.New,
|
||||
|
@ -30,12 +30,12 @@ var allProviders = wire.NewSet(
|
|||
lastfm.NewRouter,
|
||||
listenbrainz.NewRouter,
|
||||
events.GetBroker,
|
||||
scanner.GetInstance,
|
||||
db.Db,
|
||||
)
|
||||
|
||||
func CreateServer(musicFolder string) *server.Server {
|
||||
panic(wire.Build(
|
||||
server.New,
|
||||
allProviders,
|
||||
))
|
||||
}
|
||||
|
@ -49,7 +49,6 @@ func CreateNativeAPIRouter() *nativeapi.Router {
|
|||
func CreateSubsonicAPIRouter() *subsonic.Router {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
GetScanner,
|
||||
))
|
||||
}
|
||||
|
||||
|
@ -71,22 +70,14 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
|
|||
))
|
||||
}
|
||||
|
||||
// Scanner must be a Singleton
|
||||
var (
|
||||
onceScanner sync.Once
|
||||
scannerInstance scanner.Scanner
|
||||
)
|
||||
|
||||
func GetScanner() scanner.Scanner {
|
||||
onceScanner.Do(func() {
|
||||
scannerInstance = createScanner()
|
||||
})
|
||||
return scannerInstance
|
||||
}
|
||||
|
||||
func createScanner() scanner.Scanner {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
scanner.New,
|
||||
))
|
||||
}
|
||||
|
||||
func GetPlaybackServer() playback.PlaybackServer {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
))
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package conf
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
@ -11,7 +12,6 @@ import (
|
|||
"github.com/kr/pretty"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils/number"
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
@ -20,39 +20,54 @@ type configOptions struct {
|
|||
ConfigFile string
|
||||
Address string
|
||||
Port int
|
||||
UnixSocketPerm string
|
||||
MusicFolder string
|
||||
DataFolder string
|
||||
CacheFolder string
|
||||
DbPath string
|
||||
LogLevel string
|
||||
ScanInterval time.Duration
|
||||
ScanSchedule string
|
||||
SessionTimeout time.Duration
|
||||
BaseURL string
|
||||
BasePath string
|
||||
BaseHost string
|
||||
BaseScheme string
|
||||
TLSCert string
|
||||
TLSKey string
|
||||
UILoginBackgroundURL string
|
||||
UIWelcomeMessage string
|
||||
MaxSidebarPlaylists int
|
||||
EnableTranscodingConfig bool
|
||||
EnableDownloads bool
|
||||
EnableExternalServices bool
|
||||
EnableMediaFileCoverArt bool
|
||||
TranscodingCacheSize string
|
||||
ImageCacheSize string
|
||||
AlbumPlayCountMode string
|
||||
EnableArtworkPrecache bool
|
||||
AutoImportPlaylists bool
|
||||
PlaylistsPath string
|
||||
AutoTranscodeDownload bool
|
||||
DefaultDownsamplingFormat string
|
||||
SearchFullString bool
|
||||
RecentlyAddedByModTime bool
|
||||
PreferSortTags bool
|
||||
IgnoredArticles string
|
||||
IndexGroups string
|
||||
SubsonicArtistParticipations bool
|
||||
ProbeCommand string
|
||||
FFmpegPath string
|
||||
MPVPath string
|
||||
MPVCmdTemplate string
|
||||
CoverArtPriority string
|
||||
CoverJpegQuality int
|
||||
UIWelcomeMessage string
|
||||
ArtistArtPriority string
|
||||
EnableGravatar bool
|
||||
EnableFavourites bool
|
||||
EnableStarRating bool
|
||||
EnableUserEditing bool
|
||||
EnableSharing bool
|
||||
DefaultDownloadableShare bool
|
||||
DefaultTheme string
|
||||
DefaultLanguage string
|
||||
DefaultUIVolume int
|
||||
|
@ -65,8 +80,10 @@ type configOptions struct {
|
|||
PasswordEncryptionKey string
|
||||
ReverseProxyUserHeader string
|
||||
ReverseProxyWhitelist string
|
||||
HTTPSecurityHeaders secureOptions
|
||||
Prometheus prometheusOptions
|
||||
Scanner scannerOptions
|
||||
Jukebox jukeboxOptions
|
||||
|
||||
Agents string
|
||||
LastFM lastfmOptions
|
||||
|
@ -76,20 +93,25 @@ type configOptions struct {
|
|||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
DevLogSourceLine bool
|
||||
DevLogLevels map[string]string
|
||||
DevEnableProfiler bool
|
||||
DevAutoCreateAdminPassword string
|
||||
DevAutoLoginUsername string
|
||||
DevActivityPanel bool
|
||||
DevSidebarPlaylists bool
|
||||
DevEnableBufferedScrobble bool
|
||||
DevShowArtistPage bool
|
||||
DevOffsetOptimize int
|
||||
DevArtworkMaxRequests int
|
||||
DevArtworkThrottleBacklogLimit int
|
||||
DevArtworkThrottleBacklogTimeout time.Duration
|
||||
DevArtistInfoTimeToLive time.Duration
|
||||
DevAlbumInfoTimeToLive time.Duration
|
||||
}
|
||||
|
||||
type scannerOptions struct {
|
||||
Extractor string
|
||||
GenreSeparators string
|
||||
Extractor string
|
||||
GenreSeparators string
|
||||
GroupAlbumReleases bool
|
||||
}
|
||||
|
||||
type lastfmOptions struct {
|
||||
|
@ -109,11 +131,24 @@ type listenBrainzOptions struct {
|
|||
BaseURL string
|
||||
}
|
||||
|
||||
type secureOptions struct {
|
||||
CustomFrameOptionsValue string
|
||||
}
|
||||
|
||||
type prometheusOptions struct {
|
||||
Enabled bool
|
||||
MetricsPath string
|
||||
}
|
||||
|
||||
type AudioDeviceDefinition []string
|
||||
|
||||
type jukeboxOptions struct {
|
||||
Enabled bool
|
||||
Devices []AudioDeviceDefinition
|
||||
Default string
|
||||
AdminOnly bool
|
||||
}
|
||||
|
||||
var (
|
||||
Server = &configOptions{}
|
||||
hooks []func()
|
||||
|
@ -121,6 +156,11 @@ var (
|
|||
|
||||
func LoadFromFile(confFile string) {
|
||||
viper.SetConfigFile(confFile)
|
||||
err := viper.ReadInConfig()
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error reading config file:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
Load()
|
||||
}
|
||||
|
||||
|
@ -135,6 +175,16 @@ func Load() {
|
|||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating data path:", "path", Server.DataFolder, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if Server.CacheFolder == "" {
|
||||
Server.CacheFolder = filepath.Join(Server.DataFolder, "cache")
|
||||
}
|
||||
err = os.MkdirAll(Server.CacheFolder, os.ModePerm)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating cache path:", "path", Server.CacheFolder, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
Server.ConfigFile = viper.GetViper().ConfigFileUsed()
|
||||
if Server.DbPath == "" {
|
||||
Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath)
|
||||
|
@ -149,8 +199,21 @@ func Load() {
|
|||
os.Exit(1)
|
||||
}
|
||||
|
||||
if Server.BaseURL != "" {
|
||||
u, err := url.Parse(Server.BaseURL)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "FATAL: Invalid BaseURL %s: %s\n", Server.BaseURL, err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
Server.BasePath = u.Path
|
||||
u.Path = ""
|
||||
u.RawQuery = ""
|
||||
Server.BaseHost = u.Host
|
||||
Server.BaseScheme = u.Scheme
|
||||
}
|
||||
|
||||
// Print current configuration if log level is Debug
|
||||
if log.CurrentLevel() >= log.LevelDebug {
|
||||
if log.IsGreaterOrEqualTo(log.LevelDebug) {
|
||||
prettyConf := pretty.Sprintf("Loaded configuration from '%s': %# v", Server.ConfigFile, Server)
|
||||
if Server.EnableLogRedacting {
|
||||
prettyConf = log.Redact(prettyConf)
|
||||
|
@ -215,34 +278,45 @@ func AddHook(hook func()) {
|
|||
|
||||
func init() {
|
||||
viper.SetDefault("musicfolder", filepath.Join(".", "music"))
|
||||
viper.SetDefault("cachefolder", "")
|
||||
viper.SetDefault("datafolder", ".")
|
||||
viper.SetDefault("loglevel", "info")
|
||||
viper.SetDefault("address", "0.0.0.0")
|
||||
viper.SetDefault("port", 4533)
|
||||
viper.SetDefault("unixsocketperm", "0660")
|
||||
viper.SetDefault("sessiontimeout", consts.DefaultSessionTimeout)
|
||||
viper.SetDefault("scaninterval", -1)
|
||||
viper.SetDefault("scanschedule", "@every 1m")
|
||||
viper.SetDefault("baseurl", "")
|
||||
viper.SetDefault("tlscert", "")
|
||||
viper.SetDefault("tlskey", "")
|
||||
viper.SetDefault("uiloginbackgroundurl", consts.DefaultUILoginBackgroundURL)
|
||||
viper.SetDefault("uiwelcomemessage", "")
|
||||
viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists)
|
||||
viper.SetDefault("enabletranscodingconfig", false)
|
||||
viper.SetDefault("transcodingcachesize", "100MB")
|
||||
viper.SetDefault("imagecachesize", "100MB")
|
||||
viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute)
|
||||
viper.SetDefault("enableartworkprecache", true)
|
||||
viper.SetDefault("autoimportplaylists", true)
|
||||
viper.SetDefault("playlistspath", consts.DefaultPlaylistsPath)
|
||||
viper.SetDefault("enabledownloads", true)
|
||||
viper.SetDefault("enableexternalservices", true)
|
||||
viper.SetDefault("enableMediaFileCoverArt", true)
|
||||
viper.SetDefault("enablemediafilecoverart", true)
|
||||
viper.SetDefault("autotranscodedownload", false)
|
||||
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)
|
||||
viper.SetDefault("searchfullstring", false)
|
||||
viper.SetDefault("recentlyaddedbymodtime", false)
|
||||
viper.SetDefault("prefersorttags", false)
|
||||
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
|
||||
viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)")
|
||||
viper.SetDefault("subsonicartistparticipations", false)
|
||||
viper.SetDefault("probecommand", "ffmpeg %s -f ffmetadata")
|
||||
viper.SetDefault("ffmpegpath", "")
|
||||
viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s")
|
||||
|
||||
viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
|
||||
viper.SetDefault("coverjpegquality", 75)
|
||||
viper.SetDefault("uiwelcomemessage", "")
|
||||
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
|
||||
viper.SetDefault("enablegravatar", false)
|
||||
viper.SetDefault("enablefavourites", true)
|
||||
viper.SetDefault("enablestarrating", true)
|
||||
|
@ -250,7 +324,7 @@ func init() {
|
|||
viper.SetDefault("defaulttheme", "Dark")
|
||||
viper.SetDefault("defaultlanguage", "")
|
||||
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
|
||||
viper.SetDefault("enablereplaygain", false)
|
||||
viper.SetDefault("enablereplaygain", true)
|
||||
viper.SetDefault("enablecoveranimation", true)
|
||||
viper.SetDefault("gatrackingid", "")
|
||||
viper.SetDefault("enablelogredacting", true)
|
||||
|
@ -264,31 +338,44 @@ func init() {
|
|||
viper.SetDefault("prometheus.enabled", false)
|
||||
viper.SetDefault("prometheus.metricspath", "/metrics")
|
||||
|
||||
viper.SetDefault("jukebox.enabled", false)
|
||||
viper.SetDefault("jukebox.devices", []AudioDeviceDefinition{})
|
||||
viper.SetDefault("jukebox.default", "")
|
||||
viper.SetDefault("jukebox.adminonly", true)
|
||||
|
||||
viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor)
|
||||
viper.SetDefault("scanner.genreseparators", ";/,")
|
||||
viper.SetDefault("scanner.groupalbumreleases", false)
|
||||
|
||||
viper.SetDefault("agents", "lastfm,spotify")
|
||||
viper.SetDefault("lastfm.enabled", true)
|
||||
viper.SetDefault("lastfm.language", "en")
|
||||
viper.SetDefault("lastfm.apikey", consts.LastFMAPIKey)
|
||||
viper.SetDefault("lastfm.secret", consts.LastFMAPISecret)
|
||||
viper.SetDefault("lastfm.apikey", "")
|
||||
viper.SetDefault("lastfm.secret", "")
|
||||
viper.SetDefault("spotify.id", "")
|
||||
viper.SetDefault("spotify.secret", "")
|
||||
viper.SetDefault("listenbrainz.enabled", true)
|
||||
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
|
||||
|
||||
viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY")
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
viper.SetDefault("devlogsourceline", false)
|
||||
viper.SetDefault("devenableprofiler", false)
|
||||
viper.SetDefault("devautocreateadminpassword", "")
|
||||
viper.SetDefault("devautologinusername", "")
|
||||
viper.SetDefault("devactivitypanel", true)
|
||||
viper.SetDefault("enablesharing", false)
|
||||
viper.SetDefault("defaultdownloadableshare", false)
|
||||
viper.SetDefault("devenablebufferedscrobble", true)
|
||||
viper.SetDefault("devsidebarplaylists", true)
|
||||
viper.SetDefault("devshowartistpage", true)
|
||||
viper.SetDefault("devartworkmaxrequests", number.Max(2, runtime.NumCPU()))
|
||||
viper.SetDefault("devoffsetoptimize", 50000)
|
||||
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/3))
|
||||
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
|
||||
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
|
||||
viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive)
|
||||
}
|
||||
|
||||
func InitConfig(cfgFile string) {
|
||||
|
@ -311,6 +398,7 @@ func InitConfig(cfgFile string) {
|
|||
err := viper.ReadInConfig()
|
||||
if viper.ConfigFileUsed() != "" && err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Navidrome could not open config file: ", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
package mime
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type mimeConf struct {
|
||||
Types map[string]string `yaml:"types"`
|
||||
Lossless []string `yaml:"lossless"`
|
||||
}
|
||||
|
||||
var LosslessFormats []string
|
||||
|
||||
func initMimeTypes() {
|
||||
// In some circumstances, Windows sets JS mime-type to `text/plain`!
|
||||
_ = mime.AddExtensionType(".js", "text/javascript")
|
||||
_ = mime.AddExtensionType(".css", "text/css")
|
||||
|
||||
f, err := resources.FS().Open("mime_types.yaml")
|
||||
if err != nil {
|
||||
log.Fatal("Fatal error opening mime_types.yaml", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var mimeConf mimeConf
|
||||
err = yaml.NewDecoder(f).Decode(&mimeConf)
|
||||
if err != nil {
|
||||
log.Fatal("Fatal error parsing mime_types.yaml", err)
|
||||
}
|
||||
for ext, typ := range mimeConf.Types {
|
||||
_ = mime.AddExtensionType(ext, typ)
|
||||
}
|
||||
|
||||
for _, ext := range mimeConf.Lossless {
|
||||
LosslessFormats = append(LosslessFormats, strings.TrimPrefix(ext, "."))
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
conf.AddHook(initMimeTypes)
|
||||
}
|
|
@ -43,6 +43,7 @@ const (
|
|||
// DefaultUILoginBackgroundOffline Background image used in case external integrations are disabled
|
||||
DefaultUILoginBackgroundOffline = "iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAIAAAAiOjnJAAAABGdBTUEAALGPC/xhBQAAAiJJREFUeF7t0IEAAAAAw6D5Ux/khVBhwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDDwMDDVlwABBWcSrQAAAABJRU5ErkJggg=="
|
||||
DefaultUILoginBackgroundURLOffline = "data:image/png;base64," + DefaultUILoginBackgroundOffline
|
||||
DefaultMaxSidebarPlaylists = 100
|
||||
|
||||
RequestThrottleBacklogLimit = 100
|
||||
RequestThrottleBacklogTimeout = time.Minute
|
||||
|
@ -70,42 +71,46 @@ const (
|
|||
|
||||
// Cache options
|
||||
const (
|
||||
TranscodingCacheDir = "cache/transcoding"
|
||||
TranscodingCacheDir = "transcoding"
|
||||
DefaultTranscodingCacheMaxItems = 0 // Unlimited
|
||||
|
||||
ImageCacheDir = "cache/images"
|
||||
ImageCacheDir = "images"
|
||||
DefaultImageCacheMaxItems = 0 // Unlimited
|
||||
|
||||
DefaultCacheSize = 100 * 1024 * 1024 // 100MB
|
||||
DefaultCacheCleanUpInterval = 10 * time.Minute
|
||||
)
|
||||
|
||||
// Shared secrets (only add here "secrets" that can be public)
|
||||
const (
|
||||
LastFMAPIKey = "9b94a5515ea66b2da3ec03c12300327e" // nolint:gosec
|
||||
LastFMAPISecret = "74cb6557cec7171d921af5d7d887c587" // nolint:gosec
|
||||
AlbumPlayCountModeAbsolute = "absolute"
|
||||
AlbumPlayCountModeNormalized = "normalized"
|
||||
)
|
||||
|
||||
var (
|
||||
DefaultDownsamplingFormat = "opus"
|
||||
DefaultTranscodings = []map[string]interface{}{
|
||||
DefaultTranscodings = []struct {
|
||||
Name string
|
||||
TargetFormat string
|
||||
DefaultBitRate int
|
||||
Command string
|
||||
}{
|
||||
{
|
||||
"name": "mp3 audio",
|
||||
"targetFormat": "mp3",
|
||||
"defaultBitRate": 192,
|
||||
"command": "ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -f mp3 -",
|
||||
Name: "mp3 audio",
|
||||
TargetFormat: "mp3",
|
||||
DefaultBitRate: 192,
|
||||
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
|
||||
},
|
||||
{
|
||||
"name": "opus audio",
|
||||
"targetFormat": "opus",
|
||||
"defaultBitRate": 128,
|
||||
"command": "ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -c:a libopus -f opus -",
|
||||
Name: "opus audio",
|
||||
TargetFormat: "opus",
|
||||
DefaultBitRate: 128,
|
||||
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
|
||||
},
|
||||
{
|
||||
"name": "aac audio",
|
||||
"targetFormat": "aac",
|
||||
"defaultBitRate": 256,
|
||||
"command": "ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -c:a aac -f adts -",
|
||||
Name: "aac audio",
|
||||
TargetFormat: "aac",
|
||||
DefaultBitRate: 256,
|
||||
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -115,7 +120,9 @@ var (
|
|||
var (
|
||||
VariousArtists = "Various Artists"
|
||||
VariousArtistsID = fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(VariousArtists))))
|
||||
UnknownAlbum = "[Unknown Album]"
|
||||
UnknownArtist = "[Unknown Artist]"
|
||||
UnknownArtistID = fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(UnknownArtist))))
|
||||
VariousArtistsMbzId = "89ad4ac3-39f7-470e-963a-56509c546377"
|
||||
|
||||
ServerStart = time.Now()
|
||||
|
|
|
@ -1,64 +0,0 @@
|
|||
package consts
|
||||
|
||||
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},
|
||||
".mka": {typ: "audio/x-matroska"},
|
||||
}
|
||||
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() {
|
||||
for ext, fmt := range audioFormats {
|
||||
_ = mime.AddExtensionType(ext, fmt.typ)
|
||||
if fmt.lossless {
|
||||
LosslessFormats = append(LosslessFormats, strings.TrimPrefix(ext, "."))
|
||||
}
|
||||
}
|
||||
sort.Strings(LosslessFormats)
|
||||
for ext, typ := range imageFormats {
|
||||
_ = mime.AddExtensionType(ext, typ)
|
||||
}
|
||||
|
||||
// In some circumstances, Windows sets JS mime-type to `text/plain`!
|
||||
_ = mime.AddExtensionType(".js", "text/javascript")
|
||||
_ = mime.AddExtensionType(".css", "text/css")
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
https://your.website {
|
||||
reverse_proxy * navidrome:4533 {
|
||||
header_up Host {http.reverse_proxy.upstream.hostport}
|
||||
header_up X-Forwarded-For {http.request.remote}
|
||||
header_up X-Real-IP {http.reverse_proxy.upstream.port}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
version: '3.6'
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
navidrome_data:
|
||||
|
||||
services:
|
||||
|
||||
caddy:
|
||||
container_name: "caddy"
|
||||
image: caddy:2.6-alpine
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
volumes:
|
||||
- "caddy_data:/data:rw"
|
||||
- "./Caddyfile:/etc/caddy/Caddyfile:ro"
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
|
||||
navidrome:
|
||||
container_name: "navidrome"
|
||||
image: deluan/navidrome:latest
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
# user: 1000:1000
|
||||
ports:
|
||||
- "4533:4533"
|
||||
volumes:
|
||||
- "navidrome_data:/data"
|
||||
#- "/mnt/music:/music:ro"
|
|
@ -0,0 +1,51 @@
|
|||
version: "3.6"
|
||||
|
||||
volumes:
|
||||
traefik_data:
|
||||
navidrome_data:
|
||||
|
||||
services:
|
||||
|
||||
traefik:
|
||||
container_name: "traefik"
|
||||
image: traefik:2.9
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
command:
|
||||
- "--log.level=ERROR"
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--entrypoints.websecure.address=:443"
|
||||
- "--certificatesresolvers.tc.acme.tlschallenge=true"
|
||||
#- "--certificatesresolvers.tc.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory"
|
||||
- "--certificatesresolvers.tc.acme.email=foo@foo.com"
|
||||
- "--certificatesresolvers.tc.acme.storage=/letsencrypt/acme.json"
|
||||
ports:
|
||||
- "443:443"
|
||||
volumes:
|
||||
- "traefik_data:/letsencrypt"
|
||||
#- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
||||
|
||||
navidrome:
|
||||
container_name: "navidrome"
|
||||
image: deluan/navidrome:latest
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
# user: 1000:1000
|
||||
ports:
|
||||
- "4533:4533"
|
||||
environment:
|
||||
ND_SCANINTERVAL: 6h
|
||||
ND_LOGLEVEL: info
|
||||
ND_SESSIONTIMEOUT: 168h
|
||||
ND_BASEURL: ""
|
||||
volumes:
|
||||
- "navidrome_data:/data"
|
||||
#- "/mnt/music:/music:ro"
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.navidrome.rule=Host(`foo.com`)"
|
||||
- "traefik.http.routers.navidrome.entrypoints=websecure"
|
||||
- "traefik.http.routers.navidrome.tls=true"
|
||||
- "traefik.http.routers.navidrome.tls.certresolver=tc"
|
||||
- "traefik.http.services.navidrome.loadbalancer.server.port=4533"
|
|
@ -0,0 +1,18 @@
|
|||
version: '3.6'
|
||||
|
||||
volumes:
|
||||
navidrome_data:
|
||||
|
||||
services:
|
||||
|
||||
navidrome:
|
||||
container_name: "navidrome"
|
||||
image: deluan/navidrome:latest
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
# user: 1000:1000
|
||||
ports:
|
||||
- "4533:4533"
|
||||
volumes:
|
||||
- "navidrome_data:/data"
|
||||
#- "/mnt/music:/music:ro"
|
|
@ -11,7 +11,7 @@
|
|||
#
|
||||
# navidrome_enable (bool): Set to YES to enable navidrome
|
||||
# Default: NO
|
||||
# navidrome_config (str): navidrome configration file
|
||||
# navidrome_config (str): navidrome configuration file
|
||||
# Default: /usr/local/etc/navidrome/config.toml
|
||||
# navidrome_datafolder (str): navidrome Folder to store application data
|
||||
# Default: www
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
# Kubernetes
|
||||
|
||||
A couple things to keep in mind with this manifest:
|
||||
|
||||
1. This creates a namespace called `navidrome`. Adjust this as needed.
|
||||
1. This manifest was created on [K3s](https://github.com/k3s-io/k3s), which uses its own storage provisioner called [local-path-provisioner](https://github.com/rancher/local-path-provisioner). Be sure to change the `storageClassName` of the `PersistentVolumeClaim` as needed.
|
||||
1. The `PersistentVolumeClaim` sets up a 2Gi volume for Navidrome's database. Adjust this as needed.
|
||||
1. Be sure to change the `image` tag from `ghcr.io/navidrome/navidrome:0.49.3` to whatever the newest version is.
|
||||
1. This assumes your music is mounted on the host using `hostPath` at `/path/to/your/music/on/the/host`. Adjust this as needed.
|
||||
1. The `Ingress` is already configured for `cert-manager` to obtain a Let's Encrypt TLS certificate and uses Traefik for routing. Adjust this as needed.
|
||||
1. The `Ingress` presents the service at `navidrome.${SECRET_INTERNAL_DOMAIN_NAME}`, which needs to already be setup in DNS.
|
|
@ -0,0 +1,111 @@
|
|||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: navidrome
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: navidrome-data-pvc
|
||||
namespace: navidrome
|
||||
annotations:
|
||||
volumeType: local
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 2Gi
|
||||
storageClassName: local-path
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: navidrome-deployment
|
||||
namespace: navidrome
|
||||
spec:
|
||||
replicas: 1
|
||||
revisionHistoryLimit: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: navidrome
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: navidrome
|
||||
spec:
|
||||
containers:
|
||||
- name: navidrome
|
||||
image: ghcr.io/navidrome/navidrome:0.49.3
|
||||
ports:
|
||||
- containerPort: 4533
|
||||
env:
|
||||
- name: ND_SCANSCHEDULE
|
||||
value: "12h"
|
||||
- name: ND_SESSIONTIMEOUT
|
||||
value: "24h"
|
||||
- name: ND_LOGLEVEL
|
||||
value: "info"
|
||||
- name: ND_ENABLETRANSCODINGCONFIG
|
||||
value: "false"
|
||||
- name: ND_TRANSCODINGCACHESIZE
|
||||
value: "512MB"
|
||||
- name: ND_ENABLESTARRATING
|
||||
value: "false"
|
||||
- name: ND_ENABLEFAVOURITES
|
||||
value: "false"
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
- name: music
|
||||
mountPath: /music
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: navidrome-data-pvc
|
||||
- name: music
|
||||
hostPath:
|
||||
path: /path/to/your/music/on/the/host
|
||||
type: Directory
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: navidrome-service
|
||||
namespace: navidrome
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- name: http
|
||||
targetPort: 4533
|
||||
port: 4533
|
||||
protocol: TCP
|
||||
selector:
|
||||
app: navidrome
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: navidrome-ingress
|
||||
namespace: navidrome
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-production
|
||||
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||
spec:
|
||||
rules:
|
||||
- host: navidrome.${SECRET_INTERNAL_DOMAIN_NAME}
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: navidrome-service
|
||||
port:
|
||||
number: 4533
|
||||
tls:
|
||||
- hosts:
|
||||
- navidrome.${SECRET_INTERNAL_DOMAIN_NAME}
|
||||
secretName: navidrome-tls
|
|
@ -38,6 +38,7 @@ RestrictNamespaces=yes
|
|||
RestrictRealtime=yes
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallFilter=~@privileged @resources
|
||||
SystemCallFilter=setrlimit
|
||||
SystemCallArchitectures=native
|
||||
UMask=0066
|
||||
|
||||
|
|
|
@ -5,10 +5,10 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
)
|
||||
|
||||
|
@ -42,6 +42,12 @@ func (a *Agents) AgentName() string {
|
|||
}
|
||||
|
||||
func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
|
||||
switch id {
|
||||
case consts.UnknownArtistID:
|
||||
return "", ErrNotFound
|
||||
case consts.VariousArtistsID:
|
||||
return "", nil
|
||||
}
|
||||
start := time.Now()
|
||||
for _, ag := range a.agents {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
|
@ -61,6 +67,12 @@ func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (str
|
|||
}
|
||||
|
||||
func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
switch id {
|
||||
case consts.UnknownArtistID:
|
||||
return "", ErrNotFound
|
||||
case consts.VariousArtistsID:
|
||||
return "", nil
|
||||
}
|
||||
start := time.Now()
|
||||
for _, ag := range a.agents {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
|
@ -80,6 +92,12 @@ func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (strin
|
|||
}
|
||||
|
||||
func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
switch id {
|
||||
case consts.UnknownArtistID:
|
||||
return "", ErrNotFound
|
||||
case consts.VariousArtistsID:
|
||||
return "", nil
|
||||
}
|
||||
start := time.Now()
|
||||
for _, ag := range a.agents {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
|
@ -90,7 +108,7 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string)
|
|||
continue
|
||||
}
|
||||
bio, err := agent.GetArtistBiography(ctx, id, name, mbid)
|
||||
if bio != "" && err == nil {
|
||||
if err == nil {
|
||||
log.Debug(ctx, "Got Biography", "agent", ag.AgentName(), "artist", name, "len", len(bio), "elapsed", time.Since(start))
|
||||
return bio, nil
|
||||
}
|
||||
|
@ -99,6 +117,12 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string)
|
|||
}
|
||||
|
||||
func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]Artist, error) {
|
||||
switch id {
|
||||
case consts.UnknownArtistID:
|
||||
return nil, ErrNotFound
|
||||
case consts.VariousArtistsID:
|
||||
return nil, nil
|
||||
}
|
||||
start := time.Now()
|
||||
for _, ag := range a.agents {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
|
@ -110,7 +134,7 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l
|
|||
}
|
||||
similar, err := agent.GetSimilarArtists(ctx, id, name, mbid, limit)
|
||||
if len(similar) > 0 && err == nil {
|
||||
if log.CurrentLevel() >= log.LevelTrace {
|
||||
if log.IsGreaterOrEqualTo(log.LevelTrace) {
|
||||
log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start))
|
||||
} else {
|
||||
log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similarReceived", len(similar), "elapsed", time.Since(start))
|
||||
|
@ -122,6 +146,12 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l
|
|||
}
|
||||
|
||||
func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]ExternalImage, error) {
|
||||
switch id {
|
||||
case consts.UnknownArtistID:
|
||||
return nil, ErrNotFound
|
||||
case consts.VariousArtistsID:
|
||||
return nil, nil
|
||||
}
|
||||
start := time.Now()
|
||||
for _, ag := range a.agents {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
|
@ -141,6 +171,12 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]
|
|||
}
|
||||
|
||||
func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) {
|
||||
switch id {
|
||||
case consts.UnknownArtistID:
|
||||
return nil, ErrNotFound
|
||||
case consts.VariousArtistsID:
|
||||
return nil, nil
|
||||
}
|
||||
start := time.Now()
|
||||
for _, ag := range a.agents {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
|
@ -160,6 +196,9 @@ func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid str
|
|||
}
|
||||
|
||||
func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) {
|
||||
if name == consts.UnknownAlbum {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
start := time.Now()
|
||||
for _, ag := range a.agents {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
|
||||
|
@ -61,6 +62,18 @@ var _ = Describe("Agents", func() {
|
|||
Expect(ag.GetArtistMBID(ctx, "123", "test")).To(Equal("mbid"))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test"))
|
||||
})
|
||||
It("returns empty if artist is Various Artists", func() {
|
||||
mbid, err := ag.GetArtistMBID(ctx, consts.VariousArtistsID, consts.VariousArtists)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mbid).To(BeEmpty())
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
It("returns not found if artist is Unknown Artist", func() {
|
||||
mbid, err := ag.GetArtistMBID(ctx, consts.VariousArtistsID, consts.VariousArtists)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mbid).To(BeEmpty())
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
It("skips the agent if it returns an error", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetArtistMBID(ctx, "123", "test")
|
||||
|
@ -80,6 +93,18 @@ var _ = Describe("Agents", func() {
|
|||
Expect(ag.GetArtistURL(ctx, "123", "test", "mb123")).To(Equal("url"))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
|
||||
})
|
||||
It("returns empty if artist is Various Artists", func() {
|
||||
url, err := ag.GetArtistURL(ctx, consts.VariousArtistsID, consts.VariousArtists, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(url).To(BeEmpty())
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
It("returns not found if artist is Unknown Artist", func() {
|
||||
url, err := ag.GetArtistURL(ctx, consts.VariousArtistsID, consts.VariousArtists, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(url).To(BeEmpty())
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
It("skips the agent if it returns an error", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetArtistURL(ctx, "123", "test", "mb123")
|
||||
|
@ -99,6 +124,18 @@ var _ = Describe("Agents", func() {
|
|||
Expect(ag.GetArtistBiography(ctx, "123", "test", "mb123")).To(Equal("bio"))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
|
||||
})
|
||||
It("returns empty if artist is Various Artists", func() {
|
||||
bio, err := ag.GetArtistBiography(ctx, consts.VariousArtistsID, consts.VariousArtists, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(bio).To(BeEmpty())
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
It("returns not found if artist is Unknown Artist", func() {
|
||||
bio, err := ag.GetArtistBiography(ctx, consts.VariousArtistsID, consts.VariousArtists, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(bio).To(BeEmpty())
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
It("skips the agent if it returns an error", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetArtistBiography(ctx, "123", "test", "mb123")
|
||||
|
|
|
@ -6,6 +6,7 @@ import (
|
|||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
|
@ -13,7 +14,7 @@ import (
|
|||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -21,6 +22,11 @@ const (
|
|||
sessionKeyProperty = "LastFMSessionKey"
|
||||
)
|
||||
|
||||
var ignoredBiographies = []string{
|
||||
// Unknown Artist
|
||||
`<a href="https://www.last.fm/music/`,
|
||||
}
|
||||
|
||||
type lastfmAgent struct {
|
||||
ds model.DataStore
|
||||
sessionKeys *agents.SessionKeys
|
||||
|
@ -41,7 +47,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
|
|||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
chc := utils.NewCachedHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
l.client = newClient(l.apiKey, l.secret, l.lang, chc)
|
||||
return l
|
||||
}
|
||||
|
@ -124,9 +130,15 @@ func (l *lastfmAgent) GetArtistBiography(ctx context.Context, id, name, mbid str
|
|||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
a.Bio.Summary = strings.TrimSpace(a.Bio.Summary)
|
||||
if a.Bio.Summary == "" {
|
||||
return "", agents.ErrNotFound
|
||||
}
|
||||
for _, ign := range ignoredBiographies {
|
||||
if strings.HasPrefix(a.Bio.Summary, ign) {
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
return a.Bio.Summary, nil
|
||||
}
|
||||
|
||||
|
@ -245,7 +257,7 @@ func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *mode
|
|||
track: track.Title,
|
||||
album: track.Album,
|
||||
trackNumber: track.TrackNumber,
|
||||
mbid: track.MbzTrackID,
|
||||
mbid: track.MbzRecordingID,
|
||||
duration: int(track.Duration),
|
||||
albumArtist: track.AlbumArtist,
|
||||
})
|
||||
|
@ -271,7 +283,7 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S
|
|||
track: s.Title,
|
||||
album: s.Album,
|
||||
trackNumber: s.TrackNumber,
|
||||
mbid: s.MbzTrackID,
|
||||
mbid: s.MbzRecordingID,
|
||||
duration: int(s.Duration),
|
||||
albumArtist: s.AlbumArtist,
|
||||
timestamp: s.TimeStamp,
|
||||
|
@ -299,12 +311,14 @@ func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
|
|||
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)
|
||||
})
|
||||
if conf.Server.LastFM.ApiKey != "" && conf.Server.LastFM.Secret != "" {
|
||||
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
|
||||
return lastFMConstructor(ds)
|
||||
})
|
||||
scrobbler.Register(lastFMAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
|
||||
return lastFMConstructor(ds)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -234,14 +234,14 @@ var _ = Describe("lastfmAgent", func() {
|
|||
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",
|
||||
ID: "123",
|
||||
Title: "Track Title",
|
||||
Album: "Track Album",
|
||||
Artist: "Track Artist",
|
||||
AlbumArtist: "Track AlbumArtist",
|
||||
TrackNumber: 1,
|
||||
Duration: 180,
|
||||
MbzRecordingID: "mbz-123",
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -262,7 +262,7 @@ var _ = Describe("lastfmAgent", func() {
|
|||
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("mbid")).To(Equal(track.MbzRecordingID))
|
||||
})
|
||||
|
||||
It("returns ErrNotAuthorized if user is not linked", func() {
|
||||
|
@ -289,7 +289,7 @@ var _ = Describe("lastfmAgent", func() {
|
|||
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("mbid")).To(Equal(track.MbzRecordingID))
|
||||
Expect(sentParams.Get("timestamp")).To(Equal(strconv.FormatInt(ts.Unix(), 10)))
|
||||
})
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@ import (
|
|||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
//go:embed token_received.html
|
||||
|
@ -89,13 +89,14 @@ func (s *Router) unlink(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
|
||||
token := utils.ParamString(r, "token")
|
||||
if token == "" {
|
||||
p := req.Params(r)
|
||||
token, err := p.String("token")
|
||||
if err != nil {
|
||||
_ = rest.RespondWithError(w, http.StatusBadRequest, "token not received")
|
||||
return
|
||||
}
|
||||
uid := utils.ParamString(r, "uid")
|
||||
if uid == "" {
|
||||
uid, err := p.String("uid")
|
||||
if err != nil {
|
||||
_ = rest.RespondWithError(w, http.StatusBadRequest, "uid not received")
|
||||
return
|
||||
}
|
||||
|
@ -103,7 +104,7 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
|
|||
// 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)
|
||||
err = s.fetchSessionKey(ctx, uid, token)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
|
|
@ -8,13 +8,13 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -191,6 +191,7 @@ func (c *client) makeRequest(ctx context.Context, method string, params url.Valu
|
|||
req, _ := http.NewRequestWithContext(ctx, method, apiBaseUrl, nil)
|
||||
req.URL.RawQuery = params.Encode()
|
||||
|
||||
log.Trace(ctx, fmt.Sprintf("Sending Last.fm %s request", req.Method), "url", req.URL)
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
@ -11,7 +11,7 @@ import (
|
|||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -35,7 +35,7 @@ func listenBrainzConstructor(ds model.DataStore) *listenBrainzAgent {
|
|||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
chc := utils.NewCachedHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
l.client = newClient(l.baseURL, chc)
|
||||
return l
|
||||
}
|
||||
|
@ -55,8 +55,9 @@ func (l *listenBrainzAgent) formatListen(track *model.MediaFile) listenInfo {
|
|||
SubmissionClientVersion: consts.Version,
|
||||
TrackNumber: track.TrackNumber,
|
||||
ArtistMbzIDs: []string{track.MbzArtistID},
|
||||
TrackMbzID: track.MbzTrackID,
|
||||
RecordingMbzID: track.MbzRecordingID,
|
||||
ReleaseMbID: track.MbzAlbumID,
|
||||
DurationMs: int(track.Duration * 1000),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -32,14 +32,15 @@ var _ = Describe("listenBrainzAgent", func() {
|
|||
agent = listenBrainzConstructor(ds)
|
||||
agent.client = newClient("http://localhost:8080", httpClient)
|
||||
track = &model.MediaFile{
|
||||
ID: "123",
|
||||
Title: "Track Title",
|
||||
Album: "Track Album",
|
||||
Artist: "Track Artist",
|
||||
TrackNumber: 1,
|
||||
MbzTrackID: "mbz-123",
|
||||
MbzAlbumID: "mbz-456",
|
||||
MbzArtistID: "mbz-789",
|
||||
ID: "123",
|
||||
Title: "Track Title",
|
||||
Album: "Track Album",
|
||||
Artist: "Track Artist",
|
||||
TrackNumber: 1,
|
||||
MbzRecordingID: "mbz-123",
|
||||
MbzAlbumID: "mbz-456",
|
||||
MbzArtistID: "mbz-789",
|
||||
Duration: 142.2,
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -60,11 +61,12 @@ var _ = Describe("listenBrainzAgent", func() {
|
|||
"SubmissionClient": Equal(consts.AppName),
|
||||
"SubmissionClientVersion": Equal(consts.Version),
|
||||
"TrackNumber": Equal(track.TrackNumber),
|
||||
"TrackMbzID": Equal(track.MbzTrackID),
|
||||
"RecordingMbzID": Equal(track.MbzRecordingID),
|
||||
"ReleaseMbID": Equal(track.MbzAlbumID),
|
||||
"ArtistMbzIDs": MatchAllElements(idArtistId, Elements{
|
||||
"mbz-789": Equal(track.MbzArtistID),
|
||||
}),
|
||||
"DurationMs": Equal(142200),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
|
|
@ -76,9 +76,10 @@ type additionalInfo struct {
|
|||
SubmissionClient string `json:"submission_client,omitempty"`
|
||||
SubmissionClientVersion string `json:"submission_client_version,omitempty"`
|
||||
TrackNumber int `json:"tracknumber,omitempty"`
|
||||
TrackMbzID string `json:"track_mbid,omitempty"`
|
||||
RecordingMbzID string `json:"recording_mbid,omitempty"`
|
||||
ArtistMbzIDs []string `json:"artist_mbids,omitempty"`
|
||||
ReleaseMbID string `json:"release_mbid,omitempty"`
|
||||
DurationMs int `json:"duration_ms,omitempty"`
|
||||
}
|
||||
|
||||
func (c *client) validateToken(ctx context.Context, apiKey string) (*listenBrainzResponse, error) {
|
||||
|
@ -151,6 +152,7 @@ func (c *client) makeRequest(ctx context.Context, method string, endpoint string
|
|||
req.Header.Add("Authorization", fmt.Sprintf("Token %s", r.ApiKey))
|
||||
}
|
||||
|
||||
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz %s request", req.Method), "url", req.URL)
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
@ -74,10 +74,11 @@ var _ = Describe("client", func() {
|
|||
TrackName: "Track Title",
|
||||
ReleaseName: "Track Album",
|
||||
AdditionalInfo: additionalInfo{
|
||||
TrackNumber: 1,
|
||||
TrackMbzID: "mbz-123",
|
||||
ArtistMbzIDs: []string{"mbz-789"},
|
||||
ReleaseMbID: "mbz-456",
|
||||
TrackNumber: 1,
|
||||
RecordingMbzID: "mbz-123",
|
||||
ArtistMbzIDs: []string{"mbz-789"},
|
||||
ReleaseMbID: "mbz-456",
|
||||
DurationMs: 142200,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -87,6 +87,7 @@ func (c *client) authorize(ctx context.Context) (string, error) {
|
|||
}
|
||||
|
||||
func (c *client) makeRequest(req *http.Request, response interface{}) error {
|
||||
log.Trace(req.Context(), fmt.Sprintf("Sending Spotify %s request", req.Method), "url", req.URL)
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
@ -13,7 +13,7 @@ import (
|
|||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/xrash/smetrics"
|
||||
)
|
||||
|
||||
|
@ -35,7 +35,7 @@ func spotifyConstructor(ds model.DataStore) agents.Interface {
|
|||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
chc := utils.NewCachedHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
l.client = newClient(l.id, l.secret, chc)
|
||||
return l
|
||||
}
|
||||
|
|
|
@ -18,16 +18,18 @@ import (
|
|||
type Archiver interface {
|
||||
ZipAlbum(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||
ZipArtist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||
ZipShare(ctx context.Context, id string, w io.Writer) error
|
||||
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||
}
|
||||
|
||||
func NewArchiver(ms MediaStreamer, ds model.DataStore) Archiver {
|
||||
return &archiver{ds: ds, ms: ms}
|
||||
func NewArchiver(ms MediaStreamer, ds model.DataStore, shares Share) Archiver {
|
||||
return &archiver{ds: ds, ms: ms, shares: shares}
|
||||
}
|
||||
|
||||
type archiver struct {
|
||||
ds model.DataStore
|
||||
ms MediaStreamer
|
||||
ds model.DataStore
|
||||
ms MediaStreamer
|
||||
shares Share
|
||||
}
|
||||
|
||||
func (a *archiver) ZipAlbum(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||
|
@ -69,7 +71,7 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr
|
|||
func createZipWriter(out io.Writer, format string, bitrate int) *zip.Writer {
|
||||
z := zip.NewWriter(out)
|
||||
comment := "Downloaded from Navidrome"
|
||||
if format != "raw" {
|
||||
if format != "raw" && format != "" {
|
||||
comment = fmt.Sprintf("%s, transcoded to %s %dbps", comment, format, bitrate)
|
||||
}
|
||||
_ = z.SetComment(comment)
|
||||
|
@ -84,7 +86,19 @@ func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultDisc b
|
|||
if isMultDisc {
|
||||
file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file)
|
||||
}
|
||||
return fmt.Sprintf("%s/%s", mf.Album, file)
|
||||
return fmt.Sprintf("%s/%s", sanitizeName(mf.Album), file)
|
||||
}
|
||||
|
||||
func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error {
|
||||
s, err := a.shares.Load(ctx, id)
|
||||
if !s.Downloadable {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks))
|
||||
return a.zipMediaFiles(ctx, id, s.Format, s.MaxBitRate, out, s.Tracks)
|
||||
}
|
||||
|
||||
func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||
|
@ -93,13 +107,13 @@ func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bi
|
|||
log.Error(ctx, "Error loading mediafiles from playlist", "id", id, err)
|
||||
return err
|
||||
}
|
||||
return a.zipPlaylist(ctx, id, format, bitrate, out, pls)
|
||||
}
|
||||
|
||||
func (a *archiver) zipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer, pls *model.Playlist) error {
|
||||
z := createZipWriter(out, format, bitrate)
|
||||
mfs := pls.MediaFiles()
|
||||
log.Debug(ctx, "Zipping playlist", "name", pls.Name, "format", format, "bitrate", bitrate, "numTracks", len(mfs))
|
||||
return a.zipMediaFiles(ctx, id, format, bitrate, out, mfs)
|
||||
}
|
||||
|
||||
func (a *archiver) zipMediaFiles(ctx context.Context, id string, format string, bitrate int, out io.Writer, mfs model.MediaFiles) error {
|
||||
z := createZipWriter(out, format, bitrate)
|
||||
for idx, mf := range mfs {
|
||||
file := a.playlistFilename(mf, format, idx)
|
||||
_ = a.addFileToZip(ctx, z, mf, format, bitrate, file)
|
||||
|
@ -113,11 +127,14 @@ func (a *archiver) zipPlaylist(ctx context.Context, id string, format string, bi
|
|||
|
||||
func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int) string {
|
||||
ext := mf.Suffix
|
||||
if format != "raw" {
|
||||
if format != "" && format != "raw" {
|
||||
ext = format
|
||||
}
|
||||
file := fmt.Sprintf("%02d - %s - %s.%s", idx+1, mf.Artist, mf.Title, ext)
|
||||
return file
|
||||
return fmt.Sprintf("%02d - %s - %s.%s", idx+1, sanitizeName(mf.Artist), sanitizeName(mf.Title), ext)
|
||||
}
|
||||
|
||||
func sanitizeName(target string) string {
|
||||
return strings.ReplaceAll(target, "/", "_")
|
||||
}
|
||||
|
||||
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error {
|
||||
|
@ -132,8 +149,8 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
|
|||
}
|
||||
|
||||
var r io.ReadCloser
|
||||
if format != "raw" {
|
||||
r, err = a.ms.DoStream(ctx, &mf, format, bitrate)
|
||||
if format != "raw" && format != "" {
|
||||
r, err = a.ms.DoStream(ctx, &mf, format, bitrate, 0)
|
||||
} else {
|
||||
r, err = os.Open(mf.Path)
|
||||
}
|
||||
|
@ -143,7 +160,7 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
|
|||
}
|
||||
|
||||
defer func() {
|
||||
if err := r.Close(); err != nil && log.CurrentLevel() >= log.LevelDebug {
|
||||
if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
|
||||
log.Error(ctx, "Error closing stream", "id", mf.ID, "file", mf.Path, err)
|
||||
}
|
||||
}()
|
||||
|
|
|
@ -0,0 +1,211 @@
|
|||
package core_test
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
var _ = Describe("Archiver", func() {
|
||||
var (
|
||||
arch core.Archiver
|
||||
ms *mockMediaStreamer
|
||||
ds *mockDataStore
|
||||
sh *mockShare
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ms = &mockMediaStreamer{}
|
||||
ds = &mockDataStore{}
|
||||
sh = &mockShare{}
|
||||
arch = core.NewArchiver(ms, ds, sh)
|
||||
})
|
||||
|
||||
Context("ZipAlbum", func() {
|
||||
It("zips an album correctly", func() {
|
||||
mfs := model.MediaFiles{
|
||||
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album/Promo", DiscNumber: 1},
|
||||
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album/Promo", DiscNumber: 1},
|
||||
}
|
||||
|
||||
mfRepo := &mockMediaFileRepository{}
|
||||
mfRepo.On("GetAll", []model.QueryOptions{{
|
||||
Filters: squirrel.Eq{"album_id": "1"},
|
||||
Sort: "album",
|
||||
}}).Return(mfs, nil)
|
||||
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(len(zr.File)).To(Equal(2))
|
||||
Expect(zr.File[0].Name).To(Equal("Album_Promo/01 - track1.mp3"))
|
||||
Expect(zr.File[1].Name).To(Equal("Album_Promo/02 - track2.mp3"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("ZipArtist", func() {
|
||||
It("zips an artist's albums correctly", func() {
|
||||
mfs := model.MediaFiles{
|
||||
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumArtistID: "1", AlbumID: "1", Album: "Album 1", DiscNumber: 1},
|
||||
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumArtistID: "1", AlbumID: "1", Album: "Album 1", DiscNumber: 1},
|
||||
}
|
||||
|
||||
mfRepo := &mockMediaFileRepository{}
|
||||
mfRepo.On("GetAll", []model.QueryOptions{{
|
||||
Filters: squirrel.Eq{"album_artist_id": "1"},
|
||||
Sort: "album",
|
||||
}}).Return(mfs, nil)
|
||||
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipArtist(context.Background(), "1", "mp3", 128, out)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(len(zr.File)).To(Equal(2))
|
||||
Expect(zr.File[0].Name).To(Equal("Album 1/01 - track1.mp3"))
|
||||
Expect(zr.File[1].Name).To(Equal("Album 1/02 - track2.mp3"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("ZipShare", func() {
|
||||
It("zips a share correctly", func() {
|
||||
mfs := model.MediaFiles{
|
||||
{ID: "1", Path: "test_data/01 - track1.mp3", Suffix: "mp3", Artist: "Artist 1", Title: "track1"},
|
||||
{ID: "2", Path: "test_data/02 - track2.mp3", Suffix: "mp3", Artist: "Artist 2", Title: "track2"},
|
||||
}
|
||||
|
||||
share := &model.Share{
|
||||
ID: "1",
|
||||
Downloadable: true,
|
||||
Format: "mp3",
|
||||
MaxBitRate: 128,
|
||||
Tracks: mfs,
|
||||
}
|
||||
|
||||
sh.On("Load", mock.Anything, "1").Return(share, nil)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipShare(context.Background(), "1", out)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(len(zr.File)).To(Equal(2))
|
||||
Expect(zr.File[0].Name).To(Equal("01 - Artist 1 - track1.mp3"))
|
||||
Expect(zr.File[1].Name).To(Equal("02 - Artist 2 - track2.mp3"))
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
Context("ZipPlaylist", func() {
|
||||
It("zips a playlist correctly", func() {
|
||||
tracks := []model.PlaylistTrack{
|
||||
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "AC/DC", Title: "track1"}},
|
||||
{MediaFile: model.MediaFile{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "Artist 2", Title: "track2"}},
|
||||
}
|
||||
|
||||
pls := &model.Playlist{
|
||||
ID: "1",
|
||||
Name: "Test Playlist",
|
||||
Tracks: tracks,
|
||||
}
|
||||
|
||||
plRepo := &mockPlaylistRepository{}
|
||||
plRepo.On("GetWithTracks", "1", true).Return(pls, nil)
|
||||
ds.On("Playlist", mock.Anything).Return(plRepo)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(len(zr.File)).To(Equal(2))
|
||||
Expect(zr.File[0].Name).To(Equal("01 - AC_DC - track1.mp3"))
|
||||
Expect(zr.File[1].Name).To(Equal("02 - Artist 2 - track2.mp3"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockDataStore struct {
|
||||
mock.Mock
|
||||
model.DataStore
|
||||
}
|
||||
|
||||
func (m *mockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository {
|
||||
args := m.Called(ctx)
|
||||
return args.Get(0).(model.MediaFileRepository)
|
||||
}
|
||||
|
||||
func (m *mockDataStore) Playlist(ctx context.Context) model.PlaylistRepository {
|
||||
args := m.Called(ctx)
|
||||
return args.Get(0).(model.PlaylistRepository)
|
||||
}
|
||||
|
||||
type mockMediaFileRepository struct {
|
||||
mock.Mock
|
||||
model.MediaFileRepository
|
||||
}
|
||||
|
||||
func (m *mockMediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
args := m.Called(options)
|
||||
return args.Get(0).(model.MediaFiles), args.Error(1)
|
||||
}
|
||||
|
||||
type mockPlaylistRepository struct {
|
||||
mock.Mock
|
||||
model.PlaylistRepository
|
||||
}
|
||||
|
||||
func (m *mockPlaylistRepository) GetWithTracks(id string, includeTracks bool) (*model.Playlist, error) {
|
||||
args := m.Called(id, includeTracks)
|
||||
return args.Get(0).(*model.Playlist), args.Error(1)
|
||||
}
|
||||
|
||||
type mockMediaStreamer struct {
|
||||
mock.Mock
|
||||
core.MediaStreamer
|
||||
}
|
||||
|
||||
func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*core.Stream, error) {
|
||||
args := m.Called(ctx, mf, reqFormat, reqBitRate, reqOffset)
|
||||
if args.Error(1) != nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return &core.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
|
||||
}
|
||||
|
||||
type mockShare struct {
|
||||
mock.Mock
|
||||
core.Share
|
||||
}
|
||||
|
||||
func (m *mockShare) Load(ctx context.Context, id string) (*model.Share, error) {
|
||||
args := m.Called(ctx, id)
|
||||
return args.Get(0).(*model.Share), args.Error(1)
|
||||
}
|
|
@ -7,16 +7,21 @@ import (
|
|||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
var ErrUnavailable = errors.New("artwork unavailable")
|
||||
|
||||
type Artwork interface {
|
||||
Get(ctx context.Context, id string, size int) (io.ReadCloser, time.Time, error)
|
||||
Get(ctx context.Context, artID model.ArtworkID, size int) (io.ReadCloser, time.Time, error)
|
||||
GetOrPlaceholder(ctx context.Context, id string, size int) (io.ReadCloser, time.Time, error)
|
||||
}
|
||||
|
||||
func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, em core.ExternalMetadata) Artwork {
|
||||
|
@ -36,12 +41,23 @@ type artworkReader interface {
|
|||
Reader(ctx context.Context) (io.ReadCloser, string, error)
|
||||
}
|
||||
|
||||
func (a *artwork) Get(ctx context.Context, id string, size int) (reader io.ReadCloser, lastUpdate time.Time, err error) {
|
||||
func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int) (reader io.ReadCloser, lastUpdate time.Time, err error) {
|
||||
artID, err := a.getArtworkId(ctx, id)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
if err == nil {
|
||||
reader, lastUpdate, err = a.Get(ctx, artID, size)
|
||||
}
|
||||
if errors.Is(err, ErrUnavailable) {
|
||||
if artID.Kind == model.KindArtistArtwork {
|
||||
reader, _ = resources.FS().Open(consts.PlaceholderArtistArt)
|
||||
} else {
|
||||
reader, _ = resources.FS().Open(consts.PlaceholderAlbumArt)
|
||||
}
|
||||
return reader, consts.ServerStart, nil
|
||||
}
|
||||
return reader, lastUpdate, err
|
||||
}
|
||||
|
||||
func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int) (reader io.ReadCloser, lastUpdate time.Time, err error) {
|
||||
artReader, err := a.getArtworkReader(ctx, artID, size)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
|
@ -49,17 +65,21 @@ func (a *artwork) Get(ctx context.Context, id string, size int) (reader io.ReadC
|
|||
|
||||
r, err := a.cache.Get(ctx, artReader)
|
||||
if err != nil {
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
log.Error(ctx, "Error accessing image cache", "id", id, "size", size, err)
|
||||
if !errors.Is(err, context.Canceled) && !errors.Is(err, ErrUnavailable) {
|
||||
log.Error(ctx, "Error accessing image cache", "id", artID, "size", size, err)
|
||||
}
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
return r, artReader.LastUpdated(), nil
|
||||
}
|
||||
|
||||
type coverArtGetter interface {
|
||||
CoverArtID() model.ArtworkID
|
||||
}
|
||||
|
||||
func (a *artwork) getArtworkId(ctx context.Context, id string) (model.ArtworkID, error) {
|
||||
if id == "" {
|
||||
return model.ArtworkID{}, nil
|
||||
return model.ArtworkID{}, ErrUnavailable
|
||||
}
|
||||
artID, err := model.ParseArtworkID(id)
|
||||
if err == nil {
|
||||
|
@ -71,18 +91,17 @@ func (a *artwork) getArtworkId(ctx context.Context, id string) (model.ArtworkID,
|
|||
if err != nil {
|
||||
return model.ArtworkID{}, err
|
||||
}
|
||||
if e, ok := entity.(coverArtGetter); ok {
|
||||
artID = e.CoverArtID()
|
||||
}
|
||||
switch e := entity.(type) {
|
||||
case *model.Artist:
|
||||
artID = model.NewArtworkID(model.KindArtistArtwork, e.ID)
|
||||
log.Trace(ctx, "ID is for an Artist", "id", id, "name", e.Name, "artist", e.Name)
|
||||
case *model.Album:
|
||||
artID = model.NewArtworkID(model.KindAlbumArtwork, e.ID)
|
||||
log.Trace(ctx, "ID is for an Album", "id", id, "name", e.Name, "artist", e.AlbumArtist)
|
||||
case *model.MediaFile:
|
||||
artID = model.NewArtworkID(model.KindMediaFileArtwork, e.ID)
|
||||
log.Trace(ctx, "ID is for a MediaFile", "id", id, "title", e.Title, "album", e.Album)
|
||||
case *model.Playlist:
|
||||
artID = model.NewArtworkID(model.KindPlaylistArtwork, e.ID)
|
||||
log.Trace(ctx, "ID is for a Playlist", "id", id, "name", e.Name)
|
||||
}
|
||||
return artID, nil
|
||||
|
@ -104,7 +123,7 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s
|
|||
case model.KindPlaylistArtwork:
|
||||
artReader, err = newPlaylistArtworkReader(ctx, a, artID)
|
||||
default:
|
||||
artReader, err = newEmptyIDReader(ctx, artID)
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
}
|
||||
return artReader, err
|
||||
|
|
|
@ -22,7 +22,8 @@ var _ = Describe("Artwork", func() {
|
|||
var ffmpeg *tests.MockFFmpeg
|
||||
ctx := log.NewContext(context.TODO())
|
||||
var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers model.Album
|
||||
var mfWithEmbed, mfWithoutEmbed, mfCorruptedCover model.MediaFile
|
||||
var arMultipleCovers model.Artist
|
||||
var mfWithEmbed, mfAnotherWithEmbed, mfWithoutEmbed, mfCorruptedCover model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
|
@ -30,14 +31,23 @@ var _ = Describe("Artwork", func() {
|
|||
conf.Server.CoverArtPriority = "folder.*, cover.*, embedded , front.*"
|
||||
|
||||
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
|
||||
alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/test.mp3"}
|
||||
alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3"}
|
||||
alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3"}
|
||||
alOnlyExternal = model.Album{ID: "444", Name: "Only external", ImageFiles: "tests/fixtures/front.png"}
|
||||
alOnlyExternal = model.Album{ID: "444", Name: "Only external", ImageFiles: "tests/fixtures/artist/an-album/front.png"}
|
||||
alExternalNotFound = model.Album{ID: "555", Name: "External not found", ImageFiles: "tests/fixtures/NON_EXISTENT.png"}
|
||||
alMultipleCovers = model.Album{ID: "666", Name: "All options", EmbedArtPath: "tests/fixtures/test.mp3",
|
||||
ImageFiles: "tests/fixtures/cover.jpg:tests/fixtures/front.png",
|
||||
arMultipleCovers = model.Artist{ID: "777", Name: "All options"}
|
||||
alMultipleCovers = model.Album{
|
||||
ID: "666",
|
||||
Name: "All options",
|
||||
EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3",
|
||||
Paths: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: "tests/fixtures/artist/an-album/cover.jpg" + consts.Zwsp +
|
||||
"tests/fixtures/artist/an-album/front.png" + consts.Zwsp +
|
||||
"tests/fixtures/artist/an-album/artist.png",
|
||||
AlbumArtistID: "777",
|
||||
}
|
||||
mfWithEmbed = model.MediaFile{ID: "22", Path: "tests/fixtures/test.mp3", HasCoverArt: true, AlbumID: "222"}
|
||||
mfAnotherWithEmbed = model.MediaFile{ID: "23", Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true, AlbumID: "666"}
|
||||
mfWithoutEmbed = model.MediaFile{ID: "44", Path: "tests/fixtures/test.ogg", AlbumID: "444"}
|
||||
mfCorruptedCover = model.MediaFile{ID: "45", Path: "tests/fixtures/test.ogg", HasCoverArt: true, AlbumID: "444"}
|
||||
|
||||
|
@ -49,7 +59,7 @@ var _ = Describe("Artwork", func() {
|
|||
Describe("albumArtworkReader", func() {
|
||||
Context("ID not found", func() {
|
||||
It("returns ErrNotFound if album is not in the DB", func() {
|
||||
_, err := newAlbumArtworkReader(ctx, aw, model.MustParseArtworkID("al-NOT_FOUND"), nil)
|
||||
_, err := newAlbumArtworkReader(ctx, aw, model.MustParseArtworkID("al-NOT-FOUND"), nil)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
@ -65,15 +75,14 @@ var _ = Describe("Artwork", func() {
|
|||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal("tests/fixtures/test.mp3"))
|
||||
Expect(path).To(Equal("tests/fixtures/artist/an-album/test.mp3"))
|
||||
})
|
||||
It("returns placeholder if embed path is not available", func() {
|
||||
It("returns ErrUnavailable if embed path is not available", func() {
|
||||
ffmpeg.Error = errors.New("not available")
|
||||
aw, err := newAlbumArtworkReader(ctx, aw, alEmbedNotFound.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal(consts.PlaceholderAlbumArt))
|
||||
_, _, err = aw.Reader(ctx)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
})
|
||||
})
|
||||
Context("External images", func() {
|
||||
|
@ -88,14 +97,13 @@ var _ = Describe("Artwork", func() {
|
|||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal("tests/fixtures/front.png"))
|
||||
Expect(path).To(Equal("tests/fixtures/artist/an-album/front.png"))
|
||||
})
|
||||
It("returns placeholder if external file is not available", func() {
|
||||
It("returns ErrUnavailable if external file is not available", func() {
|
||||
aw, err := newAlbumArtworkReader(ctx, aw, alExternalNotFound.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal(consts.PlaceholderAlbumArt))
|
||||
_, _, err = aw.Reader(ctx)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
})
|
||||
})
|
||||
Context("Multiple covers", func() {
|
||||
|
@ -113,9 +121,36 @@ var _ = Describe("Artwork", func() {
|
|||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal(expected))
|
||||
},
|
||||
Entry(nil, " folder.* , cover.*,embedded,front.*", "tests/fixtures/cover.jpg"),
|
||||
Entry(nil, "front.* , cover.*, embedded ,folder.*", "tests/fixtures/front.png"),
|
||||
Entry(nil, " embedded , front.* , cover.*,folder.*", "tests/fixtures/test.mp3"),
|
||||
Entry(nil, " folder.* , cover.*,embedded,front.*", "tests/fixtures/artist/an-album/cover.jpg"),
|
||||
Entry(nil, "front.* , cover.*, embedded ,folder.*", "tests/fixtures/artist/an-album/front.png"),
|
||||
Entry(nil, " embedded , front.* , cover.*,folder.*", "tests/fixtures/artist/an-album/test.mp3"),
|
||||
)
|
||||
})
|
||||
})
|
||||
Describe("artistArtworkReader", func() {
|
||||
Context("Multiple covers", func() {
|
||||
BeforeEach(func() {
|
||||
ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{
|
||||
arMultipleCovers,
|
||||
})
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alMultipleCovers,
|
||||
})
|
||||
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||
mfAnotherWithEmbed,
|
||||
})
|
||||
})
|
||||
DescribeTable("ArtistArtPriority",
|
||||
func(priority string, expected string) {
|
||||
conf.Server.ArtistArtPriority = priority
|
||||
aw, err := newArtistReader(ctx, aw, arMultipleCovers.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal(expected))
|
||||
},
|
||||
Entry(nil, " folder.* , artist.*,album/artist.*", "tests/fixtures/artist/artist.jpg"),
|
||||
Entry(nil, "album/artist.*, folder.*,artist.*", "tests/fixtures/artist/an-album/artist.png"),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
@ -159,14 +194,14 @@ var _ = Describe("Artwork", func() {
|
|||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal("al-444"))
|
||||
Expect(path).To(Equal("al-444_0"))
|
||||
})
|
||||
It("returns album cover if media file has no cover art", func() {
|
||||
aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfWithoutEmbed.ID))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal("al-444"))
|
||||
Expect(path).To(Equal("al-444_0"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -178,7 +213,7 @@ var _ = Describe("Artwork", func() {
|
|||
})
|
||||
It("returns a PNG if original image is a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "front.png"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID().String(), 15)
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
br, format, err := asImageReader(r)
|
||||
|
@ -192,7 +227,7 @@ var _ = Describe("Artwork", func() {
|
|||
})
|
||||
It("returns a JPEG if original image is not a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "cover.jpg"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID().String(), 200)
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
br, format, err := asImageReader(r)
|
||||
|
|
|
@ -28,20 +28,30 @@ var _ = Describe("Artwork", func() {
|
|||
aw = artwork.NewArtwork(ds, cache, ffmpeg, nil)
|
||||
})
|
||||
|
||||
Context("Empty ID", func() {
|
||||
It("returns placeholder if album is not in the DB", func() {
|
||||
r, _, err := aw.Get(context.Background(), "", 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Context("GetOrPlaceholder", func() {
|
||||
Context("Empty ID", func() {
|
||||
It("returns placeholder if album is not in the DB", func() {
|
||||
r, _, err := aw.GetOrPlaceholder(context.Background(), "", 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
ph, err := resources.FS().Open(consts.PlaceholderAlbumArt)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
phBytes, err := io.ReadAll(ph)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
ph, err := resources.FS().Open(consts.PlaceholderAlbumArt)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
phBytes, err := io.ReadAll(ph)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
result, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
result, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(result).To(Equal(phBytes))
|
||||
Expect(result).To(Equal(phBytes))
|
||||
})
|
||||
})
|
||||
})
|
||||
Context("Get", func() {
|
||||
Context("Empty ID", func() {
|
||||
It("returns an ErrUnavailable error", func() {
|
||||
_, _, err := aw.Get(context.Background(), model.ArtworkID{}, 0)
|
||||
Expect(err).To(MatchError(artwork.ErrUnavailable))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -23,14 +23,14 @@ type CacheWarmer interface {
|
|||
|
||||
func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
|
||||
// If image cache is disabled, return a NOOP implementation
|
||||
if conf.Server.ImageCacheSize == "0" {
|
||||
if conf.Server.ImageCacheSize == "0" || !conf.Server.EnableArtworkPrecache {
|
||||
return &noopCacheWarmer{}
|
||||
}
|
||||
|
||||
a := &cacheWarmer{
|
||||
artwork: artwork,
|
||||
cache: cache,
|
||||
buffer: make(map[string]struct{}),
|
||||
buffer: make(map[model.ArtworkID]struct{}),
|
||||
wakeSignal: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
|
@ -42,16 +42,24 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
|
|||
|
||||
type cacheWarmer struct {
|
||||
artwork Artwork
|
||||
buffer map[string]struct{}
|
||||
buffer map[model.ArtworkID]struct{}
|
||||
mutex sync.Mutex
|
||||
cache cache.FileCache
|
||||
wakeSignal chan struct{}
|
||||
}
|
||||
|
||||
var ignoredIds = map[string]struct{}{
|
||||
consts.VariousArtistsID: {},
|
||||
consts.UnknownArtistID: {},
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) PreCache(artID model.ArtworkID) {
|
||||
if _, shouldIgnore := ignoredIds[artID.ID]; shouldIgnore {
|
||||
return
|
||||
}
|
||||
a.mutex.Lock()
|
||||
defer a.mutex.Unlock()
|
||||
a.buffer[artID.String()] = struct{}{}
|
||||
a.buffer[artID] = struct{}{}
|
||||
a.sendWakeSignal()
|
||||
}
|
||||
|
||||
|
@ -87,7 +95,7 @@ func (a *cacheWarmer) run(ctx context.Context) {
|
|||
}
|
||||
|
||||
batch := maps.Keys(a.buffer)
|
||||
a.buffer = make(map[string]struct{})
|
||||
a.buffer = make(map[model.ArtworkID]struct{})
|
||||
a.mutex.Unlock()
|
||||
|
||||
a.processBatch(ctx, batch)
|
||||
|
@ -108,7 +116,7 @@ func (a *cacheWarmer) waitSignal(ctx context.Context, timeout time.Duration) {
|
|||
}
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) processBatch(ctx context.Context, batch []string) {
|
||||
func (a *cacheWarmer) processBatch(ctx context.Context, batch []model.ArtworkID) {
|
||||
log.Trace(ctx, "PreCaching a new batch of artwork", "batchSize", len(batch))
|
||||
input := pl.FromSlice(ctx, batch)
|
||||
errs := pl.Sink(ctx, 2, input, a.doCacheImage)
|
||||
|
@ -117,13 +125,13 @@ func (a *cacheWarmer) processBatch(ctx context.Context, batch []string) {
|
|||
}
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) doCacheImage(ctx context.Context, id string) error {
|
||||
func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
r, _, err := a.artwork.Get(ctx, id, consts.UICoverArtSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error cacheing id='%s': %w", id, err)
|
||||
return fmt.Errorf("error caching id='%s': %w", id, err)
|
||||
}
|
||||
defer r.Close()
|
||||
_, err = io.Copy(io.Discard, r)
|
||||
|
|
|
@ -20,8 +20,9 @@ type cacheKey struct {
|
|||
|
||||
func (k *cacheKey) Key() string {
|
||||
return fmt.Sprintf(
|
||||
"%s.%d",
|
||||
k.artID,
|
||||
"%s-%s.%d",
|
||||
k.artID.Kind,
|
||||
k.artID.ID,
|
||||
k.lastUpdate.UnixMilli(),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -54,7 +54,6 @@ func (a *albumArtworkReader) LastUpdated() time.Time {
|
|||
|
||||
func (a *albumArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
var ff = a.fromCoverArtPriority(ctx, a.a.ffmpeg, conf.Server.CoverArtPriority)
|
||||
ff = append(ff, fromAlbumPlaceholder())
|
||||
return selectImageReader(ctx, a.artID, ff...)
|
||||
}
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ import (
|
|||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
|
@ -42,17 +43,19 @@ func newArtistReader(ctx context.Context, artwork *artwork, artID model.ArtworkI
|
|||
em: em,
|
||||
artist: *ar,
|
||||
}
|
||||
a.cacheKey.lastUpdate = ar.ExternalInfoUpdatedAt
|
||||
// TODO Find a way to factor in the ExternalUpdateInfoAt in the cache key. Problem is that it can
|
||||
// change _after_ retrieving from external sources, making the key invalid
|
||||
//a.cacheKey.lastUpdate = ar.ExternalInfoUpdatedAt
|
||||
var files []string
|
||||
var paths []string
|
||||
for _, al := range als {
|
||||
files = append(files, al.ImageFiles)
|
||||
paths = append(paths, filepath.SplitList(al.Paths)...)
|
||||
paths = append(paths, splitList(al.Paths)...)
|
||||
if a.cacheKey.lastUpdate.Before(al.UpdatedAt) {
|
||||
a.cacheKey.lastUpdate = al.UpdatedAt
|
||||
}
|
||||
}
|
||||
a.files = strings.Join(files, string(filepath.ListSeparator))
|
||||
a.files = strings.Join(files, consts.Zwsp)
|
||||
a.artistFolder = utils.LongestCommonPrefix(paths)
|
||||
if !strings.HasSuffix(a.artistFolder, string(filepath.Separator)) {
|
||||
a.artistFolder, _ = filepath.Split(a.artistFolder)
|
||||
|
@ -64,10 +67,10 @@ func newArtistReader(ctx context.Context, artwork *artwork, artID model.ArtworkI
|
|||
func (a *artistReader) Key() string {
|
||||
hash := md5.Sum([]byte(conf.Server.Agents + conf.Server.Spotify.ID))
|
||||
return fmt.Sprintf(
|
||||
"%s.%x.%t",
|
||||
"%s.%t.%x",
|
||||
a.cacheKey.Key(),
|
||||
hash,
|
||||
conf.Server.EnableExternalServices,
|
||||
hash,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -76,12 +79,24 @@ func (a *artistReader) LastUpdated() time.Time {
|
|||
}
|
||||
|
||||
func (a *artistReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
return selectImageReader(ctx, a.artID,
|
||||
fromArtistFolder(ctx, a.artistFolder, "artist.*"),
|
||||
fromExternalFile(ctx, a.files, "artist.*"),
|
||||
fromArtistExternalSource(ctx, a.artist, a.em),
|
||||
fromArtistPlaceholder(),
|
||||
)
|
||||
var ff = a.fromArtistArtPriority(ctx, conf.Server.ArtistArtPriority)
|
||||
return selectImageReader(ctx, a.artID, ff...)
|
||||
}
|
||||
|
||||
func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority string) []sourceFunc {
|
||||
var ff []sourceFunc
|
||||
for _, pattern := range strings.Split(strings.ToLower(priority), ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case pattern == "external":
|
||||
ff = append(ff, fromArtistExternalSource(ctx, a.artist, a.em))
|
||||
case strings.HasPrefix(pattern, "album/"):
|
||||
ff = append(ff, fromExternalFile(ctx, a.files, strings.TrimPrefix(pattern, "album/")))
|
||||
default:
|
||||
ff = append(ff, fromArtistFolder(ctx, a.artistFolder, pattern))
|
||||
}
|
||||
}
|
||||
return ff
|
||||
}
|
||||
|
||||
func fromArtistFolder(ctx context.Context, artistFolder string, pattern string) sourceFunc {
|
||||
|
@ -95,12 +110,18 @@ func fromArtistFolder(ctx context.Context, artistFolder string, pattern string)
|
|||
if len(matches) == 0 {
|
||||
return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, artistFolder)
|
||||
}
|
||||
filePath := filepath.Join(artistFolder, matches[0])
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not open cover art file", "file", filePath, err)
|
||||
return nil, "", err
|
||||
for _, m := range matches {
|
||||
filePath := filepath.Join(artistFolder, m)
|
||||
if !model.IsImageFile(m) {
|
||||
continue
|
||||
}
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not open cover art file", "file", filePath, err)
|
||||
return nil, "", err
|
||||
}
|
||||
return f, filePath, nil
|
||||
}
|
||||
return f, filePath, err
|
||||
return nil, "", nil
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,35 +0,0 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type emptyIDReader struct {
|
||||
artID model.ArtworkID
|
||||
}
|
||||
|
||||
func newEmptyIDReader(_ context.Context, artID model.ArtworkID) (*emptyIDReader, error) {
|
||||
a := &emptyIDReader{
|
||||
artID: artID,
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *emptyIDReader) LastUpdated() time.Time {
|
||||
return consts.ServerStart // Invalidate cached placeholder every server start
|
||||
}
|
||||
|
||||
func (a *emptyIDReader) Key() string {
|
||||
return fmt.Sprintf("placeholder.%d.0.%d", a.LastUpdated().UnixMilli(), conf.Server.CoverJpegQuality)
|
||||
}
|
||||
|
||||
func (a *emptyIDReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
return selectImageReader(ctx, a.artID, fromAlbumPlaceholder())
|
||||
}
|
|
@ -16,7 +16,6 @@ import (
|
|||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/number"
|
||||
)
|
||||
|
||||
type resizedArtworkReader struct {
|
||||
|
@ -57,7 +56,7 @@ func (a *resizedArtworkReader) LastUpdated() time.Time {
|
|||
|
||||
func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
// Get artwork in original size, possibly from cache
|
||||
orig, _, err := a.a.Get(ctx, a.artID.String(), 0)
|
||||
orig, _, err := a.a.Get(ctx, a.artID, 0)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
@ -113,7 +112,7 @@ func resizeImage(reader io.Reader, size int) (io.Reader, int, error) {
|
|||
|
||||
// Don't upscale the image
|
||||
bounds := img.Bounds()
|
||||
originalSize := number.Max(bounds.Max.X, bounds.Max.Y)
|
||||
originalSize := max(bounds.Max.X, bounds.Max.Y)
|
||||
if originalSize <= size {
|
||||
return nil, originalSize, nil
|
||||
}
|
||||
|
|
|
@ -37,7 +37,7 @@ func selectImageReader(ctx context.Context, artID model.ArtworkID, extractFuncs
|
|||
}
|
||||
log.Trace(ctx, "Failed trying to extract artwork", "artID", artID, "source", f, "elapsed", time.Since(start), err)
|
||||
}
|
||||
return nil, "", fmt.Errorf("could not get a cover art for %s", artID)
|
||||
return nil, "", fmt.Errorf("could not get a cover art for %s: %w", artID, ErrUnavailable)
|
||||
}
|
||||
|
||||
type sourceFunc func() (r io.ReadCloser, path string, err error)
|
||||
|
@ -52,9 +52,13 @@ func (f sourceFunc) String() string {
|
|||
return name
|
||||
}
|
||||
|
||||
func splitList(s string) []string {
|
||||
return strings.Split(s, consts.Zwsp)
|
||||
}
|
||||
|
||||
func fromExternalFile(ctx context.Context, files string, pattern string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
for _, file := range filepath.SplitList(files) {
|
||||
for _, file := range splitList(files) {
|
||||
_, name := filepath.Split(file)
|
||||
match, err := filepath.Match(pattern, strings.ToLower(name))
|
||||
if err != nil {
|
||||
|
@ -120,7 +124,7 @@ func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourc
|
|||
|
||||
func fromAlbum(ctx context.Context, a *artwork, id model.ArtworkID) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
r, _, err := a.Get(ctx, id.String(), 0)
|
||||
r, _, err := a.Get(ctx, id, 0)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
@ -134,14 +138,6 @@ func fromAlbumPlaceholder() sourceFunc {
|
|||
return r, consts.PlaceholderAlbumArt, nil
|
||||
}
|
||||
}
|
||||
|
||||
func fromArtistPlaceholder() sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
r, _ := resources.FS().Open(consts.PlaceholderArtistArt)
|
||||
return r, consts.PlaceholderArtistArt, nil
|
||||
}
|
||||
}
|
||||
|
||||
func fromArtistExternalSource(ctx context.Context, ar model.Artist, em core.ExternalMetadata) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
imageUrl, err := em.ArtistImage(ctx, ar.ID)
|
||||
|
|
|
@ -6,6 +6,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
|
@ -23,9 +24,10 @@ var (
|
|||
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 {
|
||||
secret, err := ds.Property(context.TODO()).Get(consts.JWTSecretKey)
|
||||
if err != nil || secret == "" {
|
||||
log.Error("No JWT secret found in DB. Setting a temp one, but please report this error", err)
|
||||
secret = uuid.NewString()
|
||||
}
|
||||
Secret = []byte(secret)
|
||||
TokenAuth = jwtauth.New("HS256", Secret, nil)
|
||||
|
|
|
@ -6,12 +6,10 @@ import (
|
|||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/sanitize"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
_ "github.com/navidrome/navidrome/core/agents/lastfm"
|
||||
_ "github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||
|
@ -19,12 +17,17 @@ import (
|
|||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/number"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
"github.com/navidrome/navidrome/utils/random"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
const (
|
||||
unavailableArtistID = "-1"
|
||||
maxSimilarArtists = 100
|
||||
refreshDelay = 5 * time.Second
|
||||
refreshTimeout = 15 * time.Second
|
||||
refreshQueueLength = 2000
|
||||
)
|
||||
|
||||
type ExternalMetadata interface {
|
||||
|
@ -37,8 +40,10 @@ type ExternalMetadata interface {
|
|||
}
|
||||
|
||||
type externalMetadata struct {
|
||||
ds model.DataStore
|
||||
ag *agents.Agents
|
||||
ds model.DataStore
|
||||
ag *agents.Agents
|
||||
artistQueue chan<- *auxArtist
|
||||
albumQueue chan<- *auxAlbum
|
||||
}
|
||||
|
||||
type auxAlbum struct {
|
||||
|
@ -52,7 +57,10 @@ type auxArtist struct {
|
|||
}
|
||||
|
||||
func NewExternalMetadata(ds model.DataStore, agents *agents.Agents) ExternalMetadata {
|
||||
return &externalMetadata{ds: ds, ag: agents}
|
||||
e := &externalMetadata{ds: ds, ag: agents}
|
||||
e.artistQueue = startRefreshQueue(context.TODO(), e.populateArtistInfo)
|
||||
e.albumQueue = startRefreshQueue(context.TODO(), e.populateAlbumInfo)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *externalMetadata) getAlbum(ctx context.Context, id string) (*auxAlbum, error) {
|
||||
|
@ -82,39 +90,36 @@ func (e *externalMetadata) UpdateAlbumInfo(ctx context.Context, id string) (*mod
|
|||
return nil, err
|
||||
}
|
||||
|
||||
if album.ExternalInfoUpdatedAt.IsZero() {
|
||||
log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", album.ExternalInfoUpdatedAt, "id", id, "name", album.Name)
|
||||
err = e.refreshAlbumInfo(ctx, album)
|
||||
updatedAt := V(album.ExternalInfoUpdatedAt)
|
||||
if updatedAt.IsZero() {
|
||||
log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", album.Name)
|
||||
err = e.populateAlbumInfo(ctx, album)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if time.Since(album.ExternalInfoUpdatedAt) > consts.AlbumInfoTimeToLive {
|
||||
if time.Since(updatedAt) > conf.Server.DevAlbumInfoTimeToLive {
|
||||
log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", album.Name)
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
err := e.refreshAlbumInfo(ctx, album)
|
||||
if err != nil {
|
||||
log.Error("Error refreshing AlbumInfo", "id", id, "name", album.Name, err)
|
||||
}
|
||||
}()
|
||||
enqueueRefresh(e.albumQueue, album)
|
||||
}
|
||||
|
||||
return &album.Album, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) refreshAlbumInfo(ctx context.Context, album *auxAlbum) error {
|
||||
func (e *externalMetadata) populateAlbumInfo(ctx context.Context, album *auxAlbum) error {
|
||||
start := time.Now()
|
||||
info, err := e.ag.GetAlbumInfo(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
|
||||
if errors.Is(err, agents.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("Error refreshing AlbumInfo", "id", album.ID, "name", album.Name, "artist", album.AlbumArtist,
|
||||
"elapsed", time.Since(start), err)
|
||||
return err
|
||||
}
|
||||
|
||||
album.ExternalInfoUpdatedAt = time.Now()
|
||||
album.ExternalInfoUpdatedAt = P(time.Now())
|
||||
album.ExternalUrl = info.URL
|
||||
|
||||
if info.Description != "" {
|
||||
|
@ -139,10 +144,12 @@ func (e *externalMetadata) refreshAlbumInfo(ctx context.Context, album *auxAlbum
|
|||
|
||||
err = e.ds.Album(ctx).Put(&album.Album)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", album.Name, err)
|
||||
log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", album.Name,
|
||||
"elapsed", time.Since(start), err)
|
||||
} else {
|
||||
log.Trace(ctx, "AlbumInfo collected", "album", album, "elapsed", time.Since(start))
|
||||
}
|
||||
|
||||
log.Trace(ctx, "AlbumInfo collected", "album", album)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -180,38 +187,41 @@ func clearName(name string) string {
|
|||
}
|
||||
|
||||
func (e *externalMetadata) UpdateArtistInfo(ctx context.Context, id string, similarCount int, includeNotPresent bool) (*model.Artist, error) {
|
||||
artist, err := e.getArtist(ctx, id)
|
||||
artist, err := e.refreshArtistInfo(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If we don't have any info, retrieves it now
|
||||
if artist.ExternalInfoUpdatedAt.IsZero() {
|
||||
log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", artist.ExternalInfoUpdatedAt, "id", id, "name", artist.Name)
|
||||
err = e.refreshArtistInfo(ctx, artist)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// If info is expired, trigger a refresh in the background
|
||||
if time.Since(artist.ExternalInfoUpdatedAt) > consts.ArtistInfoTimeToLive {
|
||||
log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", artist.ExternalInfoUpdatedAt, "name", artist.Name)
|
||||
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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
err = e.loadSimilar(ctx, artist, similarCount, includeNotPresent)
|
||||
return &artist.Artist, err
|
||||
}
|
||||
|
||||
func (e *externalMetadata) refreshArtistInfo(ctx context.Context, artist *auxArtist) error {
|
||||
func (e *externalMetadata) refreshArtistInfo(ctx context.Context, id string) (*auxArtist, error) {
|
||||
artist, err := e.getArtist(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If we don't have any info, retrieves it now
|
||||
updatedAt := V(artist.ExternalInfoUpdatedAt)
|
||||
if updatedAt.IsZero() {
|
||||
log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", artist.Name)
|
||||
err := e.populateArtistInfo(ctx, artist)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// If info is expired, trigger a populateArtistInfo in the background
|
||||
if time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive {
|
||||
log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", updatedAt, "name", artist.Name)
|
||||
enqueueRefresh(e.artistQueue, artist)
|
||||
}
|
||||
return artist, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) populateArtistInfo(ctx context.Context, artist *auxArtist) error {
|
||||
start := time.Now()
|
||||
// Get MBID first, if it is not yet available
|
||||
if artist.MbzArtistID == "" {
|
||||
mbid, err := e.ag.GetArtistMBID(ctx, artist.ID, artist.Name)
|
||||
|
@ -221,40 +231,30 @@ func (e *externalMetadata) refreshArtistInfo(ctx context.Context, artist *auxArt
|
|||
}
|
||||
|
||||
// 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) },
|
||||
})
|
||||
g := errgroup.Group{}
|
||||
g.SetLimit(2)
|
||||
g.Go(func() error { e.callGetImage(ctx, e.ag, artist); return nil })
|
||||
g.Go(func() error { e.callGetBiography(ctx, e.ag, artist); return nil })
|
||||
g.Go(func() error { e.callGetURL(ctx, e.ag, artist); return nil })
|
||||
g.Go(func() error { e.callGetSimilar(ctx, e.ag, artist, maxSimilarArtists, true); return nil })
|
||||
_ = g.Wait()
|
||||
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "ArtistInfo update canceled", ctx.Err())
|
||||
log.Warn(ctx, "ArtistInfo update canceled", "elapsed", "id", artist.ID, "name", artist.Name, time.Since(start), ctx.Err())
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
artist.ExternalInfoUpdatedAt = time.Now()
|
||||
artist.ExternalInfoUpdatedAt = P(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.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artist.Name,
|
||||
"elapsed", time.Since(start), err)
|
||||
} else {
|
||||
log.Trace(ctx, "ArtistInfo collected", "artist", artist, "elapsed", time.Since(start))
|
||||
}
|
||||
|
||||
log.Trace(ctx, "ArtistInfo collected", "artist", artist)
|
||||
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) {
|
||||
artist, err := e.getArtist(ctx, id)
|
||||
if err != nil {
|
||||
|
@ -267,14 +267,14 @@ func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count in
|
|||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
weightedSongs := utils.NewWeightedRandomChooser()
|
||||
addArtist := func(a model.Artist, weightedSongs *utils.WeightedChooser, count, artistWeight int) error {
|
||||
weightedSongs := random.NewWeightedRandomChooser()
|
||||
addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser, count, artistWeight int) error {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
topCount := number.Max(count, 20)
|
||||
topCount := max(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)
|
||||
|
@ -401,7 +401,7 @@ func (e *externalMetadata) getMatchingTopSongs(ctx context.Context, agent agents
|
|||
func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, artistID, title string) (*model.MediaFile, error) {
|
||||
if mbid != "" {
|
||||
mfs, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"mbz_track_id": mbid},
|
||||
Filters: squirrel.Eq{"mbz_recording_id": mbid},
|
||||
})
|
||||
if err == nil && len(mfs) > 0 {
|
||||
return &mfs[0], nil
|
||||
|
@ -414,9 +414,9 @@ func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, a
|
|||
squirrel.Eq{"artist_id": artistID},
|
||||
squirrel.Eq{"album_artist_id": artistID},
|
||||
},
|
||||
squirrel.Like{"order_title": strings.TrimSpace(sanitize.Accents(title))},
|
||||
squirrel.Like{"order_title": utils.SanitizeFieldForSorting(title)},
|
||||
},
|
||||
Sort: "starred desc, rating desc, year asc",
|
||||
Sort: "starred desc, rating desc, year asc, compilation asc ",
|
||||
Max: 1,
|
||||
})
|
||||
if err != nil || len(mfs) == 0 {
|
||||
|
@ -426,16 +426,16 @@ func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, a
|
|||
}
|
||||
|
||||
func (e *externalMetadata) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) {
|
||||
url, err := agent.GetArtistURL(ctx, artist.ID, artist.Name, artist.MbzArtistID)
|
||||
if url == "" || err != nil {
|
||||
artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name, artist.MbzArtistID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
artist.ExternalUrl = url
|
||||
artist.ExternalUrl = artisURL
|
||||
}
|
||||
|
||||
func (e *externalMetadata) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) {
|
||||
bio, err := agent.GetArtistBiography(ctx, artist.ID, clearName(artist.Name), artist.MbzArtistID)
|
||||
if bio == "" || err != nil {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
bio = utils.SanitizeText(bio)
|
||||
|
@ -445,7 +445,7 @@ func (e *externalMetadata) callGetBiography(ctx context.Context, agent agents.Ar
|
|||
|
||||
func (e *externalMetadata) callGetImage(ctx context.Context, agent agents.ArtistImageRetriever, artist *auxArtist) {
|
||||
images, err := agent.GetArtistImages(ctx, artist.ID, artist.Name, artist.MbzArtistID)
|
||||
if len(images) == 0 || err != nil {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
sort.Slice(images, func(i, j int) bool { return images[i].Size > images[j].Size })
|
||||
|
@ -467,7 +467,9 @@ func (e *externalMetadata) callGetSimilar(ctx context.Context, agent agents.Arti
|
|||
if len(similar) == 0 || err != nil {
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
sa, err := e.mapSimilarArtists(ctx, similar, includeNotPresent)
|
||||
log.Debug(ctx, "Mapped Similar Artists", "artist", artist.Name, "numSimilar", len(sa), "elapsed", time.Since(start))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
@ -558,3 +560,29 @@ func (e *externalMetadata) loadSimilar(ctx context.Context, artist *auxArtist, c
|
|||
artist.SimilarArtists = loaded
|
||||
return nil
|
||||
}
|
||||
|
||||
func startRefreshQueue[T any](ctx context.Context, processFn func(context.Context, T) error) chan<- T {
|
||||
queue := make(chan T, refreshQueueLength)
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(refreshDelay)
|
||||
ctx, cancel := context.WithTimeout(ctx, refreshTimeout)
|
||||
select {
|
||||
case a := <-queue:
|
||||
_ = processFn(ctx, a)
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
cancel()
|
||||
break
|
||||
}
|
||||
}
|
||||
}()
|
||||
return queue
|
||||
}
|
||||
|
||||
func enqueueRefresh[T any](queue chan<- T, item T) {
|
||||
select {
|
||||
case queue <- item:
|
||||
default: // It is ok to miss a refresh
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,37 +9,103 @@ import (
|
|||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
type FFmpeg interface {
|
||||
Transcode(ctx context.Context, command, path string, maxBitRate int) (io.ReadCloser, error)
|
||||
Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error)
|
||||
ExtractImage(ctx context.Context, path string) (io.ReadCloser, error)
|
||||
// TODO Move scanner ffmpeg probe to here
|
||||
ConvertToWAV(ctx context.Context, path string) (io.ReadCloser, error)
|
||||
ConvertToFLAC(ctx context.Context, path string) (io.ReadCloser, error)
|
||||
Probe(ctx context.Context, files []string) (string, error)
|
||||
CmdPath() (string, error)
|
||||
IsAvailable() bool
|
||||
Version() string
|
||||
}
|
||||
|
||||
func New() FFmpeg {
|
||||
return &ffmpeg{}
|
||||
}
|
||||
|
||||
const extractImageCmd = "ffmpeg -i %s -an -vcodec copy -f image2pipe -"
|
||||
const (
|
||||
extractImageCmd = "ffmpeg -i %s -an -vcodec copy -f image2pipe -"
|
||||
probeCmd = "ffmpeg %s -f ffmetadata"
|
||||
createWavCmd = "ffmpeg -i %s -c:a pcm_s16le -f wav -"
|
||||
createFLACCmd = "ffmpeg -i %s -f flac -"
|
||||
)
|
||||
|
||||
type ffmpeg struct{}
|
||||
|
||||
func (e *ffmpeg) Transcode(ctx context.Context, command, path string, maxBitRate int) (io.ReadCloser, error) {
|
||||
args := createFFmpegCommand(command, path, maxBitRate)
|
||||
func (e *ffmpeg) Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error) {
|
||||
if _, err := ffmpegCmd(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args := createFFmpegCommand(command, path, maxBitRate, offset)
|
||||
return e.start(ctx, args)
|
||||
}
|
||||
|
||||
func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) {
|
||||
args := createFFmpegCommand(extractImageCmd, path, 0)
|
||||
if _, err := ffmpegCmd(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args := createFFmpegCommand(extractImageCmd, path, 0, 0)
|
||||
return e.start(ctx, args)
|
||||
}
|
||||
|
||||
func (e *ffmpeg) ConvertToWAV(ctx context.Context, path string) (io.ReadCloser, error) {
|
||||
args := createFFmpegCommand(createWavCmd, path, 0, 0)
|
||||
return e.start(ctx, args)
|
||||
}
|
||||
|
||||
func (e *ffmpeg) ConvertToFLAC(ctx context.Context, path string) (io.ReadCloser, error) {
|
||||
args := createFFmpegCommand(createFLACCmd, path, 0, 0)
|
||||
return e.start(ctx, args)
|
||||
}
|
||||
|
||||
func (e *ffmpeg) Probe(ctx context.Context, files []string) (string, error) {
|
||||
if _, err := ffmpegCmd(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
args := createProbeCommand(probeCmd, files)
|
||||
log.Trace(ctx, "Executing ffmpeg command", "args", args)
|
||||
cmd := exec.CommandContext(ctx, args[0], args[1:]...) // #nosec
|
||||
output, _ := cmd.CombinedOutput()
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
func (e *ffmpeg) CmdPath() (string, error) {
|
||||
return ffmpegCmd()
|
||||
}
|
||||
|
||||
func (e *ffmpeg) IsAvailable() bool {
|
||||
_, err := ffmpegCmd()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Version executes ffmpeg -version and extracts the version from the output.
|
||||
// Sample output: ffmpeg version 6.0 Copyright (c) 2000-2023 the FFmpeg developers
|
||||
func (e *ffmpeg) Version() string {
|
||||
cmd, err := ffmpegCmd()
|
||||
if err != nil {
|
||||
return "N/A"
|
||||
}
|
||||
out, err := exec.Command(cmd, "-version").CombinedOutput() // #nosec
|
||||
if err != nil {
|
||||
return "N/A"
|
||||
}
|
||||
parts := strings.Split(string(out), " ")
|
||||
if len(parts) < 3 {
|
||||
return "N/A"
|
||||
}
|
||||
return parts[2]
|
||||
}
|
||||
|
||||
func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error) {
|
||||
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
|
||||
j := &Cmd{ctx: ctx, args: args}
|
||||
j := &ffCmd{args: args}
|
||||
j.PipeReader, j.out = io.Pipe()
|
||||
err := j.start()
|
||||
if err != nil {
|
||||
|
@ -49,18 +115,17 @@ func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error
|
|||
return j, nil
|
||||
}
|
||||
|
||||
type Cmd struct {
|
||||
type ffCmd struct {
|
||||
*io.PipeReader
|
||||
out *io.PipeWriter
|
||||
ctx context.Context
|
||||
args []string
|
||||
cmd *exec.Cmd
|
||||
}
|
||||
|
||||
func (j *Cmd) start() error {
|
||||
cmd := exec.CommandContext(j.ctx, j.args[0], j.args[1:]...) // #nosec
|
||||
func (j *ffCmd) start() error {
|
||||
cmd := exec.Command(j.args[0], j.args[1:]...) // #nosec
|
||||
cmd.Stdout = j.out
|
||||
if log.CurrentLevel() >= log.LevelTrace {
|
||||
if log.IsGreaterOrEqualTo(log.LevelTrace) {
|
||||
cmd.Stderr = os.Stderr
|
||||
} else {
|
||||
cmd.Stderr = io.Discard
|
||||
|
@ -73,7 +138,7 @@ func (j *Cmd) start() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (j *Cmd) wait() {
|
||||
func (j *ffCmd) wait() {
|
||||
if err := j.cmd.Wait(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
|
@ -83,21 +148,83 @@ func (j *Cmd) wait() {
|
|||
}
|
||||
return
|
||||
}
|
||||
if j.ctx.Err() != nil {
|
||||
_ = j.out.CloseWithError(j.ctx.Err())
|
||||
return
|
||||
}
|
||||
_ = j.out.Close()
|
||||
}
|
||||
|
||||
// Path will always be an absolute path
|
||||
func createFFmpegCommand(cmd, path string, maxBitRate int) []string {
|
||||
split := strings.Split(cmd, " ")
|
||||
for i, s := range split {
|
||||
s = strings.ReplaceAll(s, "%s", path)
|
||||
s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate))
|
||||
split[i] = s
|
||||
func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string {
|
||||
split := strings.Split(fixCmd(cmd), " ")
|
||||
var parts []string
|
||||
|
||||
for _, s := range split {
|
||||
if strings.Contains(s, "%s") {
|
||||
s = strings.ReplaceAll(s, "%s", path)
|
||||
parts = append(parts, s)
|
||||
if offset > 0 && !strings.Contains(cmd, "%t") {
|
||||
parts = append(parts, "-ss", strconv.Itoa(offset))
|
||||
}
|
||||
} else {
|
||||
s = strings.ReplaceAll(s, "%t", strconv.Itoa(offset))
|
||||
s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate))
|
||||
parts = append(parts, s)
|
||||
}
|
||||
}
|
||||
|
||||
return split
|
||||
return parts
|
||||
}
|
||||
|
||||
func createProbeCommand(cmd string, inputs []string) []string {
|
||||
split := strings.Split(fixCmd(cmd), " ")
|
||||
var args []string
|
||||
|
||||
for _, s := range split {
|
||||
if s == "%s" {
|
||||
for _, inp := range inputs {
|
||||
args = append(args, "-i", inp)
|
||||
}
|
||||
} else {
|
||||
args = append(args, s)
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func fixCmd(cmd string) string {
|
||||
split := strings.Split(cmd, " ")
|
||||
var result []string
|
||||
cmdPath, _ := ffmpegCmd()
|
||||
for _, s := range split {
|
||||
if s == "ffmpeg" || s == "ffmpeg.exe" {
|
||||
result = append(result, cmdPath)
|
||||
} else {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return strings.Join(result, " ")
|
||||
}
|
||||
|
||||
func ffmpegCmd() (string, error) {
|
||||
ffOnce.Do(func() {
|
||||
if conf.Server.FFmpegPath != "" {
|
||||
ffmpegPath = conf.Server.FFmpegPath
|
||||
ffmpegPath, ffmpegErr = exec.LookPath(ffmpegPath)
|
||||
} else {
|
||||
ffmpegPath, ffmpegErr = exec.LookPath("ffmpeg")
|
||||
if errors.Is(ffmpegErr, exec.ErrDot) {
|
||||
log.Trace("ffmpeg found in current folder '.'")
|
||||
ffmpegPath, ffmpegErr = exec.LookPath("./ffmpeg")
|
||||
}
|
||||
}
|
||||
if ffmpegErr == nil {
|
||||
log.Info("Found ffmpeg", "path", ffmpegPath)
|
||||
return
|
||||
}
|
||||
})
|
||||
return ffmpegPath, ffmpegErr
|
||||
}
|
||||
|
||||
var (
|
||||
ffOnce sync.Once
|
||||
ffmpegPath string
|
||||
ffmpegErr error
|
||||
)
|
||||
|
|
|
@ -16,9 +16,36 @@ func TestFFmpeg(t *testing.T) {
|
|||
RunSpecs(t, "FFmpeg Suite")
|
||||
}
|
||||
|
||||
var _ = Describe("createFFmpegCommand", func() {
|
||||
It("creates a valid command line", func() {
|
||||
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
|
||||
var _ = Describe("ffmpeg", func() {
|
||||
BeforeEach(func() {
|
||||
_, _ = ffmpegCmd()
|
||||
ffmpegPath = "ffmpeg"
|
||||
ffmpegErr = nil
|
||||
})
|
||||
Describe("createFFmpegCommand", func() {
|
||||
It("creates a valid command line", func() {
|
||||
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 0)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
|
||||
})
|
||||
Context("when command has time offset param", func() {
|
||||
It("creates a valid command line with offset", func() {
|
||||
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk -ss %t mp3 -", "/music library/file.mp3", 123, 456)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "-ss", "456", "mp3", "-"}))
|
||||
})
|
||||
|
||||
})
|
||||
Context("when command does not have time offset param", func() {
|
||||
It("adds time offset after the input file name", func() {
|
||||
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-ss", "456", "-b:a", "123k", "mp3", "-"}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("createProbeCommand", func() {
|
||||
It("creates a valid command line", func() {
|
||||
args := createProbeCommand(probeCmd, []string{"/music library/one.mp3", "/music library/two.mp3"})
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/one.mp3", "-i", "/music library/two.mp3", "-f", "ffmetadata"}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -19,8 +19,8 @@ import (
|
|||
)
|
||||
|
||||
type MediaStreamer interface {
|
||||
NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error)
|
||||
DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int) (*Stream, error)
|
||||
NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, offset int) (*Stream, error)
|
||||
DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error)
|
||||
}
|
||||
|
||||
type TranscodingCache cache.FileCache
|
||||
|
@ -40,22 +40,23 @@ type streamJob struct {
|
|||
mf *model.MediaFile
|
||||
format string
|
||||
bitRate int
|
||||
offset int
|
||||
}
|
||||
|
||||
func (j *streamJob) Key() string {
|
||||
return fmt.Sprintf("%s.%s.%d.%s", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.format)
|
||||
return fmt.Sprintf("%s.%s.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.format, j.offset)
|
||||
}
|
||||
|
||||
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error) {
|
||||
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) {
|
||||
mf, err := ms.ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ms.DoStream(ctx, mf, reqFormat, reqBitRate)
|
||||
return ms.DoStream(ctx, mf, reqFormat, reqBitRate, reqOffset)
|
||||
}
|
||||
|
||||
func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int) (*Stream, error) {
|
||||
func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) {
|
||||
var format string
|
||||
var bitRate int
|
||||
var cached bool
|
||||
|
@ -70,7 +71,7 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF
|
|||
|
||||
if format == "raw" {
|
||||
log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", mf.Path,
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
|
||||
"selectedBitrate", bitRate, "selectedFormat", format)
|
||||
f, err := os.Open(mf.Path)
|
||||
|
@ -88,6 +89,7 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF
|
|||
mf: mf,
|
||||
format: format,
|
||||
bitRate: bitRate,
|
||||
offset: reqOffset,
|
||||
}
|
||||
r, err := ms.cache.Get(ctx, job)
|
||||
if err != nil {
|
||||
|
@ -100,7 +102,7 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF
|
|||
s.Seeker = r.Seeker
|
||||
|
||||
log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", mf.Path,
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
|
||||
"selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable())
|
||||
|
||||
|
@ -129,11 +131,11 @@ func (s *Stream) EstimatedContentLength() int {
|
|||
func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (format string, bitRate int) {
|
||||
format = "raw"
|
||||
if reqFormat == "raw" {
|
||||
return
|
||||
return format, 0
|
||||
}
|
||||
if reqFormat == mf.Suffix && reqBitRate == 0 {
|
||||
bitRate = mf.BitRate
|
||||
return
|
||||
return format, bitRate
|
||||
}
|
||||
trc, hasDefault := request.TranscodingFrom(ctx)
|
||||
var cFormat string
|
||||
|
@ -159,7 +161,7 @@ func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model
|
|||
cBitRate = reqBitRate
|
||||
}
|
||||
if cBitRate == 0 && cFormat == "" {
|
||||
return
|
||||
return format, bitRate
|
||||
}
|
||||
t, err := ds.Transcoding(ctx).FindByFormat(cFormat)
|
||||
if err == nil {
|
||||
|
@ -184,22 +186,26 @@ var (
|
|||
|
||||
func GetTranscodingCache() TranscodingCache {
|
||||
onceTranscodingCache.Do(func() {
|
||||
instanceTranscodingCache = cache.NewFileCache("Transcoding", conf.Server.TranscodingCacheSize,
|
||||
consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems,
|
||||
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
|
||||
job := arg.(*streamJob)
|
||||
t, err := job.ms.ds.Transcoding(ctx).FindByFormat(job.format)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading transcoding command", "format", job.format, err)
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
out, err := job.ms.transcoder.Transcode(ctx, t.Command, job.mf.Path, job.bitRate)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
return out, nil
|
||||
})
|
||||
instanceTranscodingCache = NewTranscodingCache()
|
||||
})
|
||||
return instanceTranscodingCache
|
||||
}
|
||||
|
||||
func NewTranscodingCache() TranscodingCache {
|
||||
return cache.NewFileCache("Transcoding", conf.Server.TranscodingCacheSize,
|
||||
consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems,
|
||||
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
|
||||
job := arg.(*streamJob)
|
||||
t, err := job.ms.ds.Transcoding(ctx).FindByFormat(job.format)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading transcoding command", "format", job.format, err)
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
out, err := job.ms.transcoder.Transcode(ctx, t.Command, job.mf.Path, job.bitRate, job.offset)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
return out, nil
|
||||
})
|
||||
}
|
||||
|
|
|
@ -2,10 +2,8 @@ package core
|
|||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
|
@ -16,21 +14,10 @@ import (
|
|||
|
||||
var _ = Describe("MediaStreamer", func() {
|
||||
var ds model.DataStore
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx := log.NewContext(context.Background())
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DataFolder, _ = os.MkdirTemp("", "file_caches")
|
||||
conf.Server.TranscodingCacheSize = "100MB"
|
||||
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()
|
||||
Eventually(func() bool { return testCache.Available(context.TODO()) }).Should(BeTrue())
|
||||
})
|
||||
AfterEach(func() {
|
||||
_ = os.RemoveAll(conf.Server.DataFolder)
|
||||
})
|
||||
|
||||
Context("selectTranscodingOptions", func() {
|
||||
|
|
|
@ -23,50 +23,50 @@ var _ = Describe("MediaStreamer", func() {
|
|||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DataFolder, _ = os.MkdirTemp("", "file_caches")
|
||||
conf.Server.CacheFolder, _ = os.MkdirTemp("", "file_caches")
|
||||
conf.Server.TranscodingCacheSize = "100MB"
|
||||
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 := core.GetTranscodingCache()
|
||||
testCache := core.NewTranscodingCache()
|
||||
Eventually(func() bool { return testCache.Available(context.TODO()) }).Should(BeTrue())
|
||||
streamer = core.NewMediaStreamer(ds, ffmpeg, testCache)
|
||||
})
|
||||
AfterEach(func() {
|
||||
_ = os.RemoveAll(conf.Server.DataFolder)
|
||||
_ = os.RemoveAll(conf.Server.CacheFolder)
|
||||
})
|
||||
|
||||
Context("NewStream", func() {
|
||||
It("returns a seekable stream if format is 'raw'", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "raw", 0)
|
||||
s, err := streamer.NewStream(ctx, "123", "raw", 0, 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
It("returns a seekable stream if maxBitRate is 0", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 0)
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 0, 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
It("returns a seekable stream if maxBitRate is higher than file bitRate", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 320)
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 320, 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
It("returns a NON seekable stream if transcode is required", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 64)
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 64, 0)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(s.Seekable()).To(BeFalse())
|
||||
Expect(s.Duration()).To(Equal(float32(257.0)))
|
||||
})
|
||||
It("returns a seekable stream if the file is complete in the cache", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 32)
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 32, 0)
|
||||
Expect(err).To(BeNil())
|
||||
_, _ = io.ReadAll(s)
|
||||
_ = s.Close()
|
||||
Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue())
|
||||
|
||||
s, err = streamer.NewStream(ctx, "123", "mp3", 32)
|
||||
s, err = streamer.NewStream(ctx, "123", "mp3", 32, 0)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
|
|
|
@ -0,0 +1,299 @@
|
|||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/navidrome/navidrome/core/playback/mpv"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type Track interface {
|
||||
IsPlaying() bool
|
||||
SetVolume(value float32) // Used to control the playback volume. A float value between 0.0 and 1.0.
|
||||
Pause()
|
||||
Unpause()
|
||||
Position() int
|
||||
SetPosition(offset int) error
|
||||
Close()
|
||||
String() string
|
||||
}
|
||||
|
||||
type playbackDevice struct {
|
||||
serviceCtx context.Context
|
||||
ParentPlaybackServer PlaybackServer
|
||||
Default bool
|
||||
User string
|
||||
Name string
|
||||
DeviceName string
|
||||
PlaybackQueue *Queue
|
||||
Gain float32
|
||||
PlaybackDone chan bool
|
||||
ActiveTrack Track
|
||||
startTrackSwitcher sync.Once
|
||||
}
|
||||
|
||||
type DeviceStatus struct {
|
||||
CurrentIndex int
|
||||
Playing bool
|
||||
Gain float32
|
||||
Position int
|
||||
}
|
||||
|
||||
const DefaultGain float32 = 1.0
|
||||
|
||||
func (pd *playbackDevice) getStatus() DeviceStatus {
|
||||
pos := 0
|
||||
if pd.ActiveTrack != nil {
|
||||
pos = pd.ActiveTrack.Position()
|
||||
}
|
||||
return DeviceStatus{
|
||||
CurrentIndex: pd.PlaybackQueue.Index,
|
||||
Playing: pd.isPlaying(),
|
||||
Gain: pd.Gain,
|
||||
Position: pos,
|
||||
}
|
||||
}
|
||||
|
||||
// NewPlaybackDevice creates a new playback device which implements all the basic Jukebox mode commands defined here:
|
||||
// http://www.subsonic.org/pages/api.jsp#jukeboxControl
|
||||
// Starts the trackSwitcher goroutine for the device.
|
||||
func NewPlaybackDevice(ctx context.Context, playbackServer PlaybackServer, name string, deviceName string) *playbackDevice {
|
||||
return &playbackDevice{
|
||||
serviceCtx: ctx,
|
||||
ParentPlaybackServer: playbackServer,
|
||||
User: "",
|
||||
Name: name,
|
||||
DeviceName: deviceName,
|
||||
Gain: DefaultGain,
|
||||
PlaybackQueue: NewQueue(),
|
||||
PlaybackDone: make(chan bool),
|
||||
}
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) String() string {
|
||||
return fmt.Sprintf("Name: %s, Gain: %.4f, Loaded track: %s", pd.Name, pd.Gain, pd.ActiveTrack)
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) Get(ctx context.Context) (model.MediaFiles, DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Get action", "device", pd)
|
||||
return pd.PlaybackQueue.Get(), pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) Status(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, fmt.Sprintf("processing Status action on: %s, queue: %s", pd, pd.PlaybackQueue))
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
// Set is similar to a clear followed by a add, but will not change the currently playing track.
|
||||
func (pd *playbackDevice) Set(ctx context.Context, ids []string) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Set action", "ids", ids, "device", pd)
|
||||
|
||||
_, err := pd.Clear(ctx)
|
||||
if err != nil {
|
||||
log.Error(ctx, "error setting tracks", ids)
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
return pd.Add(ctx, ids)
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) Start(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Start action", "device", pd)
|
||||
|
||||
pd.startTrackSwitcher.Do(func() {
|
||||
log.Info(ctx, "Starting trackSwitcher goroutine")
|
||||
// Start one trackSwitcher goroutine with each device
|
||||
go func() {
|
||||
pd.trackSwitcherGoroutine()
|
||||
}()
|
||||
})
|
||||
|
||||
if pd.ActiveTrack != nil {
|
||||
if pd.isPlaying() {
|
||||
log.Debug("trying to start an already playing track")
|
||||
} else {
|
||||
pd.ActiveTrack.Unpause()
|
||||
}
|
||||
} else {
|
||||
if !pd.PlaybackQueue.IsEmpty() {
|
||||
err := pd.switchActiveTrackByIndex(pd.PlaybackQueue.Index)
|
||||
if err != nil {
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
pd.ActiveTrack.Unpause()
|
||||
}
|
||||
}
|
||||
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) Stop(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Stop action", "device", pd)
|
||||
if pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.Pause()
|
||||
}
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) Skip(ctx context.Context, index int, offset int) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Skip action", "index", index, "offset", offset, "device", pd)
|
||||
|
||||
wasPlaying := pd.isPlaying()
|
||||
|
||||
if pd.ActiveTrack != nil && wasPlaying {
|
||||
pd.ActiveTrack.Pause()
|
||||
}
|
||||
|
||||
if index != pd.PlaybackQueue.Index && pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.Close()
|
||||
pd.ActiveTrack = nil
|
||||
}
|
||||
|
||||
if pd.ActiveTrack == nil {
|
||||
err := pd.switchActiveTrackByIndex(index)
|
||||
if err != nil {
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
}
|
||||
|
||||
err := pd.ActiveTrack.SetPosition(offset)
|
||||
if err != nil {
|
||||
log.Error(ctx, "error setting position", err)
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
|
||||
if wasPlaying {
|
||||
_, err = pd.Start(ctx)
|
||||
if err != nil {
|
||||
log.Error(ctx, "error starting new track after skipping")
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
}
|
||||
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) Add(ctx context.Context, ids []string) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Add action", "ids", ids, "device", pd)
|
||||
if len(ids) < 1 {
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
items := model.MediaFiles{}
|
||||
|
||||
for _, id := range ids {
|
||||
mf, err := pd.ParentPlaybackServer.GetMediaFile(id)
|
||||
if err != nil {
|
||||
return DeviceStatus{}, err
|
||||
}
|
||||
log.Debug(ctx, "Found mediafile: "+mf.Path)
|
||||
items = append(items, *mf)
|
||||
}
|
||||
pd.PlaybackQueue.Add(items)
|
||||
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) Clear(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Clear action", "device", pd)
|
||||
if pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.Pause()
|
||||
pd.ActiveTrack.Close()
|
||||
pd.ActiveTrack = nil
|
||||
}
|
||||
pd.PlaybackQueue.Clear()
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) Remove(ctx context.Context, index int) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Remove action", "index", index, "device", pd)
|
||||
// pausing if attempting to remove running track
|
||||
if pd.isPlaying() && pd.PlaybackQueue.Index == index {
|
||||
_, err := pd.Stop(ctx)
|
||||
if err != nil {
|
||||
log.Error(ctx, "error stopping running track")
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
}
|
||||
|
||||
if index > -1 && index < pd.PlaybackQueue.Size() {
|
||||
pd.PlaybackQueue.Remove(index)
|
||||
} else {
|
||||
log.Error(ctx, "Index to remove out of range: "+fmt.Sprint(index))
|
||||
}
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) Shuffle(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Shuffle action", "device", pd)
|
||||
if pd.PlaybackQueue.Size() > 1 {
|
||||
pd.PlaybackQueue.Shuffle()
|
||||
}
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
// SetGain is used to control the playback volume. A float value between 0.0 and 1.0.
|
||||
func (pd *playbackDevice) SetGain(ctx context.Context, gain float32) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing SetGain action", "newGain", gain, "device", pd)
|
||||
|
||||
if pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.SetVolume(gain)
|
||||
}
|
||||
pd.Gain = gain
|
||||
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) isPlaying() bool {
|
||||
return pd.ActiveTrack != nil && pd.ActiveTrack.IsPlaying()
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) trackSwitcherGoroutine() {
|
||||
log.Debug("Started trackSwitcher goroutine", "device", pd)
|
||||
for {
|
||||
select {
|
||||
case <-pd.PlaybackDone:
|
||||
log.Debug("Track switching detected")
|
||||
if pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.Close()
|
||||
pd.ActiveTrack = nil
|
||||
}
|
||||
|
||||
if !pd.PlaybackQueue.IsAtLastElement() {
|
||||
pd.PlaybackQueue.IncreaseIndex()
|
||||
log.Debug("Switching to next song", "queue", pd.PlaybackQueue.String())
|
||||
err := pd.switchActiveTrackByIndex(pd.PlaybackQueue.Index)
|
||||
if err != nil {
|
||||
log.Error("Error switching track", err)
|
||||
}
|
||||
if pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.Unpause()
|
||||
}
|
||||
} else {
|
||||
log.Debug("There is no song left in the playlist. Finish.")
|
||||
}
|
||||
case <-pd.serviceCtx.Done():
|
||||
log.Debug("Stopping trackSwitcher goroutine", "device", pd.Name)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) switchActiveTrackByIndex(index int) error {
|
||||
pd.PlaybackQueue.SetIndex(index)
|
||||
currentTrack := pd.PlaybackQueue.Current()
|
||||
if currentTrack == nil {
|
||||
return errors.New("could not get current track")
|
||||
}
|
||||
|
||||
track, err := mpv.NewTrack(pd.serviceCtx, pd.PlaybackDone, pd.DeviceName, *currentTrack)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pd.ActiveTrack = track
|
||||
pd.ActiveTrack.SetVolume(pd.Gain)
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,123 @@
|
|||
package mpv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
func start(ctx context.Context, args []string) (Executor, error) {
|
||||
log.Debug("Executing mpv command", "cmd", args)
|
||||
j := Executor{args: args}
|
||||
j.PipeReader, j.out = io.Pipe()
|
||||
err := j.start(ctx)
|
||||
if err != nil {
|
||||
return Executor{}, err
|
||||
}
|
||||
go j.wait()
|
||||
return j, nil
|
||||
}
|
||||
|
||||
func (j *Executor) Cancel() error {
|
||||
if j.cmd != nil {
|
||||
return j.cmd.Cancel()
|
||||
}
|
||||
return fmt.Errorf("there is non command to cancel")
|
||||
}
|
||||
|
||||
type Executor struct {
|
||||
*io.PipeReader
|
||||
out *io.PipeWriter
|
||||
args []string
|
||||
cmd *exec.Cmd
|
||||
}
|
||||
|
||||
func (j *Executor) start(ctx context.Context) error {
|
||||
cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec
|
||||
cmd.Stdout = j.out
|
||||
if log.IsGreaterOrEqualTo(log.LevelTrace) {
|
||||
cmd.Stderr = os.Stderr
|
||||
} else {
|
||||
cmd.Stderr = io.Discard
|
||||
}
|
||||
j.cmd = cmd
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("starting cmd: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *Executor) wait() {
|
||||
if err := j.cmd.Wait(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
_ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode()))
|
||||
} else {
|
||||
_ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err))
|
||||
}
|
||||
return
|
||||
}
|
||||
_ = j.out.Close()
|
||||
}
|
||||
|
||||
// Path will always be an absolute path
|
||||
func createMPVCommand(deviceName string, filename string, socketName string) []string {
|
||||
split := strings.Split(fixCmd(conf.Server.MPVCmdTemplate), " ")
|
||||
for i, s := range split {
|
||||
s = strings.ReplaceAll(s, "%d", deviceName)
|
||||
s = strings.ReplaceAll(s, "%f", filename)
|
||||
s = strings.ReplaceAll(s, "%s", socketName)
|
||||
split[i] = s
|
||||
}
|
||||
return split
|
||||
}
|
||||
|
||||
func fixCmd(cmd string) string {
|
||||
split := strings.Split(cmd, " ")
|
||||
var result []string
|
||||
cmdPath, _ := mpvCommand()
|
||||
for _, s := range split {
|
||||
if s == "mpv" || s == "mpv.exe" {
|
||||
result = append(result, cmdPath)
|
||||
} else {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return strings.Join(result, " ")
|
||||
}
|
||||
|
||||
// This is a 1:1 copy of the stuff in ffmpeg.go, need to be unified.
|
||||
func mpvCommand() (string, error) {
|
||||
mpvOnce.Do(func() {
|
||||
if conf.Server.MPVPath != "" {
|
||||
mpvPath = conf.Server.MPVPath
|
||||
mpvPath, mpvErr = exec.LookPath(mpvPath)
|
||||
} else {
|
||||
mpvPath, mpvErr = exec.LookPath("mpv")
|
||||
if errors.Is(mpvErr, exec.ErrDot) {
|
||||
log.Trace("mpv found in current folder '.'")
|
||||
mpvPath, mpvErr = exec.LookPath("./mpv")
|
||||
}
|
||||
}
|
||||
if mpvErr == nil {
|
||||
log.Info("Found mpv", "path", mpvPath)
|
||||
return
|
||||
}
|
||||
})
|
||||
return mpvPath, mpvErr
|
||||
}
|
||||
|
||||
var (
|
||||
mpvOnce sync.Once
|
||||
mpvPath string
|
||||
mpvErr error
|
||||
)
|
|
@ -0,0 +1,22 @@
|
|||
//go:build !windows
|
||||
|
||||
package mpv
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
)
|
||||
|
||||
func socketName(prefix, suffix string) string {
|
||||
return utils.TempFileName(prefix, suffix)
|
||||
}
|
||||
|
||||
func removeSocket(socketName string) {
|
||||
log.Debug("Removing socketfile", "socketfile", socketName)
|
||||
err := os.Remove(socketName)
|
||||
if err != nil {
|
||||
log.Error("Error cleaning up socketfile", "socketfile", socketName, err)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
//go:build windows
|
||||
|
||||
package mpv
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func socketName(prefix, suffix string) string {
|
||||
// Windows needs to use a named pipe for the socket
|
||||
// see https://mpv.io/manual/master#using-mpv-from-other-programs-or-scripts
|
||||
return filepath.Join(`\\.\pipe\mpvsocket`, prefix+uuid.NewString()+suffix)
|
||||
}
|
||||
|
||||
func removeSocket(string) {
|
||||
// Windows automatically handles cleaning up named pipe
|
||||
}
|
|
@ -0,0 +1,220 @@
|
|||
package mpv
|
||||
|
||||
// Audio-playback using mpv media-server. See mpv.io
|
||||
// https://github.com/dexterlb/mpvipc
|
||||
// https://mpv.io/manual/master/#json-ipc
|
||||
// https://mpv.io/manual/master/#properties
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/dexterlb/mpvipc"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type MpvTrack struct {
|
||||
MediaFile model.MediaFile
|
||||
PlaybackDone chan bool
|
||||
Conn *mpvipc.Connection
|
||||
IPCSocketName string
|
||||
Exe *Executor
|
||||
CloseCalled bool
|
||||
}
|
||||
|
||||
func NewTrack(ctx context.Context, playbackDoneChannel chan bool, deviceName string, mf model.MediaFile) (*MpvTrack, error) {
|
||||
log.Debug("Loading track", "trackPath", mf.Path, "mediaType", mf.ContentType())
|
||||
|
||||
if _, err := mpvCommand(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tmpSocketName := socketName("mpv-ctrl-", ".socket")
|
||||
|
||||
args := createMPVCommand(deviceName, mf.Path, tmpSocketName)
|
||||
exe, err := start(ctx, args)
|
||||
if err != nil {
|
||||
log.Error("Error starting mpv process", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// wait for socket to show up
|
||||
err = waitForSocket(tmpSocketName, 3*time.Second, 100*time.Millisecond)
|
||||
if err != nil {
|
||||
log.Error("Error or timeout waiting for control socket", "socketname", tmpSocketName, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conn := mpvipc.NewConnection(tmpSocketName)
|
||||
err = conn.Open()
|
||||
|
||||
if err != nil {
|
||||
log.Error("Error opening new connection", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
theTrack := &MpvTrack{MediaFile: mf, PlaybackDone: playbackDoneChannel, Conn: conn, IPCSocketName: tmpSocketName, Exe: &exe, CloseCalled: false}
|
||||
|
||||
go func() {
|
||||
conn.WaitUntilClosed()
|
||||
log.Info("Hitting end-of-stream, signalling on channel")
|
||||
if !theTrack.CloseCalled {
|
||||
playbackDoneChannel <- true
|
||||
}
|
||||
}()
|
||||
|
||||
return theTrack, nil
|
||||
}
|
||||
|
||||
func (t *MpvTrack) String() string {
|
||||
return fmt.Sprintf("Name: %s, Socket: %s", t.MediaFile.Path, t.IPCSocketName)
|
||||
}
|
||||
|
||||
// Used to control the playback volume. A float value between 0.0 and 1.0.
|
||||
func (t *MpvTrack) SetVolume(value float32) {
|
||||
// mpv's volume as described in the --volume parameter:
|
||||
// Set the startup volume. 0 means silence, 100 means no volume reduction or amplification.
|
||||
// Negative values can be passed for compatibility, but are treated as 0.
|
||||
log.Debug("Setting volume", "volume", value, "track", t)
|
||||
vol := int(value * 100)
|
||||
|
||||
err := t.Conn.Set("volume", vol)
|
||||
if err != nil {
|
||||
log.Error("Error setting volume", "volume", value, "track", t, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MpvTrack) Unpause() {
|
||||
log.Debug("Unpausing track", "track", t)
|
||||
err := t.Conn.Set("pause", false)
|
||||
if err != nil {
|
||||
log.Error("Error unpausing track", "track", t, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MpvTrack) Pause() {
|
||||
log.Debug("Pausing track", "track", t)
|
||||
err := t.Conn.Set("pause", true)
|
||||
if err != nil {
|
||||
log.Error("Error pausing track", "track", t, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MpvTrack) Close() {
|
||||
log.Debug("Closing resources", "track", t)
|
||||
t.CloseCalled = true
|
||||
// trying to shutdown mpv process using socket
|
||||
if t.isSocketFilePresent() {
|
||||
log.Debug("sending shutdown command")
|
||||
_, err := t.Conn.Call("quit")
|
||||
if err != nil {
|
||||
log.Warn("Error sending quit command to mpv-ipc socket", err)
|
||||
|
||||
if t.Exe != nil {
|
||||
log.Debug("cancelling executor")
|
||||
err = t.Exe.Cancel()
|
||||
if err != nil {
|
||||
log.Warn("Error canceling executor", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if t.isSocketFilePresent() {
|
||||
removeSocket(t.IPCSocketName)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MpvTrack) isSocketFilePresent() bool {
|
||||
if len(t.IPCSocketName) < 1 {
|
||||
return false
|
||||
}
|
||||
|
||||
fileInfo, err := os.Stat(t.IPCSocketName)
|
||||
return err == nil && fileInfo != nil && !fileInfo.IsDir()
|
||||
}
|
||||
|
||||
// Position returns the playback position in seconds.
|
||||
// Every now and then the mpv IPC interface returns "mpv error: property unavailable"
|
||||
// in this case we have to retry
|
||||
func (t *MpvTrack) Position() int {
|
||||
retryCount := 0
|
||||
for {
|
||||
position, err := t.Conn.Get("time-pos")
|
||||
if err != nil && err.Error() == "mpv error: property unavailable" {
|
||||
retryCount += 1
|
||||
log.Debug("Got mpv error, retrying...", "retries", retryCount, err)
|
||||
if retryCount > 5 {
|
||||
return 0
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error("Error getting position in track", "track", t, err)
|
||||
return 0
|
||||
}
|
||||
|
||||
pos, ok := position.(float64)
|
||||
if !ok {
|
||||
log.Error("Could not cast position from mpv into float64", "position", position, "track", t)
|
||||
return 0
|
||||
} else {
|
||||
return int(pos)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (t *MpvTrack) SetPosition(offset int) error {
|
||||
log.Debug("Setting position", "offset", offset, "track", t)
|
||||
pos := t.Position()
|
||||
if pos == offset {
|
||||
log.Debug("No position difference, skipping operation", "track", t)
|
||||
return nil
|
||||
}
|
||||
err := t.Conn.Set("time-pos", float64(offset))
|
||||
if err != nil {
|
||||
log.Error("Could not set the position in track", "track", t, "offset", offset, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *MpvTrack) IsPlaying() bool {
|
||||
log.Debug("Checking if track is playing", "track", t)
|
||||
pausing, err := t.Conn.Get("pause")
|
||||
if err != nil {
|
||||
log.Error("Problem getting paused status", "track", t, err)
|
||||
return false
|
||||
}
|
||||
|
||||
pause, ok := pausing.(bool)
|
||||
if !ok {
|
||||
log.Error("Could not cast pausing to boolean", "track", t, "value", pausing)
|
||||
return false
|
||||
}
|
||||
return !pause
|
||||
}
|
||||
|
||||
func waitForSocket(path string, timeout time.Duration, pause time.Duration) error {
|
||||
start := time.Now()
|
||||
end := start.Add(timeout)
|
||||
var retries int = 0
|
||||
|
||||
for {
|
||||
fileInfo, err := os.Stat(path)
|
||||
if err == nil && fileInfo != nil && !fileInfo.IsDir() {
|
||||
log.Debug("Socket found", "retries", retries, "waitTime", time.Since(start))
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(end) {
|
||||
return fmt.Errorf("timeout reached: %s", timeout)
|
||||
}
|
||||
time.Sleep(pause)
|
||||
retries += 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
package playback
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestPlayback(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Playback Suite")
|
||||
}
|
|
@ -0,0 +1,127 @@
|
|||
// Package playback implements audio playback using PlaybackDevices. It is used to implement the Jukebox mode in turn.
|
||||
// It makes use of the MPV library to do the playback. Major parts are:
|
||||
// - decoder which includes decoding and transcoding of various audio file formats
|
||||
// - device implementing the basic functions to work with audio devices like set, play, stop, skip, ...
|
||||
// - queue a simple playlist
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
)
|
||||
|
||||
type PlaybackServer interface {
|
||||
Run(ctx context.Context) error
|
||||
GetDeviceForUser(user string) (*playbackDevice, error)
|
||||
GetMediaFile(id string) (*model.MediaFile, error)
|
||||
}
|
||||
|
||||
type playbackServer struct {
|
||||
ctx *context.Context
|
||||
datastore model.DataStore
|
||||
playbackDevices []playbackDevice
|
||||
}
|
||||
|
||||
// GetInstance returns the playback-server singleton
|
||||
func GetInstance(ds model.DataStore) PlaybackServer {
|
||||
return singleton.GetInstance(func() *playbackServer {
|
||||
return &playbackServer{datastore: ds}
|
||||
})
|
||||
}
|
||||
|
||||
// Run starts the playback server which serves request until canceled using the given context
|
||||
func (ps *playbackServer) Run(ctx context.Context) error {
|
||||
ps.ctx = &ctx
|
||||
|
||||
devices, err := ps.initDeviceStatus(ctx, conf.Server.Jukebox.Devices, conf.Server.Jukebox.Default)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ps.playbackDevices = devices
|
||||
log.Info(ctx, fmt.Sprintf("%d audio devices found", len(devices)))
|
||||
|
||||
defaultDevice, _ := ps.getDefaultDevice()
|
||||
|
||||
log.Info(ctx, "Using audio device: "+defaultDevice.DeviceName)
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
// Should confirm all subprocess are terminated before returning
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ps *playbackServer) initDeviceStatus(ctx context.Context, devices []conf.AudioDeviceDefinition, defaultDevice string) ([]playbackDevice, error) {
|
||||
pbDevices := make([]playbackDevice, max(1, len(devices)))
|
||||
defaultDeviceFound := false
|
||||
|
||||
if defaultDevice == "" {
|
||||
// if there are no devices given and no default device, we create a synthetic device named "auto"
|
||||
if len(devices) == 0 {
|
||||
pbDevices[0] = *NewPlaybackDevice(ctx, ps, "auto", "auto")
|
||||
}
|
||||
|
||||
// if there is but only one entry and no default given, just use that.
|
||||
if len(devices) == 1 {
|
||||
if len(devices[0]) != 2 {
|
||||
return []playbackDevice{}, fmt.Errorf("audio device definition ought to contain 2 fields, found: %d ", len(devices[0]))
|
||||
}
|
||||
pbDevices[0] = *NewPlaybackDevice(ctx, ps, devices[0][0], devices[0][1])
|
||||
}
|
||||
|
||||
if len(devices) > 1 {
|
||||
return []playbackDevice{}, fmt.Errorf("number of audio device found is %d, but no default device defined. Set Jukebox.Default", len(devices))
|
||||
}
|
||||
|
||||
pbDevices[0].Default = true
|
||||
return pbDevices, nil
|
||||
}
|
||||
|
||||
for idx, audioDevice := range devices {
|
||||
if len(audioDevice) != 2 {
|
||||
return []playbackDevice{}, fmt.Errorf("audio device definition ought to contain 2 fields, found: %d ", len(audioDevice))
|
||||
}
|
||||
|
||||
pbDevices[idx] = *NewPlaybackDevice(ctx, ps, audioDevice[0], audioDevice[1])
|
||||
|
||||
if audioDevice[0] == defaultDevice {
|
||||
pbDevices[idx].Default = true
|
||||
defaultDeviceFound = true
|
||||
}
|
||||
}
|
||||
|
||||
if !defaultDeviceFound {
|
||||
return []playbackDevice{}, fmt.Errorf("default device name not found: %s ", defaultDevice)
|
||||
}
|
||||
return pbDevices, nil
|
||||
}
|
||||
|
||||
func (ps *playbackServer) getDefaultDevice() (*playbackDevice, error) {
|
||||
for idx := range ps.playbackDevices {
|
||||
if ps.playbackDevices[idx].Default {
|
||||
return &ps.playbackDevices[idx], nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no default device found")
|
||||
}
|
||||
|
||||
// GetMediaFile retrieves the MediaFile given by the id parameter
|
||||
func (ps *playbackServer) GetMediaFile(id string) (*model.MediaFile, error) {
|
||||
return ps.datastore.MediaFile(*ps.ctx).Get(id)
|
||||
}
|
||||
|
||||
// GetDeviceForUser returns the audio playback device for the given user. As of now this is but only the default device.
|
||||
func (ps *playbackServer) GetDeviceForUser(user string) (*playbackDevice, error) {
|
||||
log.Debug("Processing GetDevice", "user", user)
|
||||
// README: here we might plug-in the user-device mapping one fine day
|
||||
device, err := ps.getDefaultDevice()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
device.User = user
|
||||
return device, nil
|
||||
}
|
|
@ -0,0 +1,136 @@
|
|||
package playback
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type Queue struct {
|
||||
Index int
|
||||
Items model.MediaFiles
|
||||
}
|
||||
|
||||
func NewQueue() *Queue {
|
||||
return &Queue{
|
||||
Index: -1,
|
||||
Items: model.MediaFiles{},
|
||||
}
|
||||
}
|
||||
|
||||
func (pd *Queue) String() string {
|
||||
filenames := ""
|
||||
for idx, item := range pd.Items {
|
||||
filenames += fmt.Sprint(idx) + ":" + item.Path + " "
|
||||
}
|
||||
return fmt.Sprintf("#Items: %d, idx: %d, files: %s", len(pd.Items), pd.Index, filenames)
|
||||
}
|
||||
|
||||
// returns the current mediafile or nil
|
||||
func (pd *Queue) Current() *model.MediaFile {
|
||||
if pd.Index == -1 {
|
||||
return nil
|
||||
}
|
||||
if pd.Index >= len(pd.Items) {
|
||||
log.Error("internal error: current song index out of bounds", "idx", pd.Index, "length", len(pd.Items))
|
||||
return nil
|
||||
}
|
||||
|
||||
return &pd.Items[pd.Index]
|
||||
}
|
||||
|
||||
// returns the whole queue
|
||||
func (pd *Queue) Get() model.MediaFiles {
|
||||
return pd.Items
|
||||
}
|
||||
|
||||
func (pd *Queue) Size() int {
|
||||
return len(pd.Items)
|
||||
}
|
||||
|
||||
func (pd *Queue) IsEmpty() bool {
|
||||
return len(pd.Items) < 1
|
||||
}
|
||||
|
||||
// set is similar to a clear followed by a add, but will not change the currently playing track.
|
||||
func (pd *Queue) Set(items model.MediaFiles) {
|
||||
pd.Clear()
|
||||
pd.Items = append(pd.Items, items...)
|
||||
}
|
||||
|
||||
// adding mediafiles to the queue
|
||||
func (pd *Queue) Add(items model.MediaFiles) {
|
||||
pd.Items = append(pd.Items, items...)
|
||||
if pd.Index == -1 && len(pd.Items) > 0 {
|
||||
pd.Index = 0
|
||||
}
|
||||
}
|
||||
|
||||
// empties whole queue
|
||||
func (pd *Queue) Clear() {
|
||||
pd.Index = -1
|
||||
pd.Items = nil
|
||||
}
|
||||
|
||||
// idx Zero-based index of the song to skip to or remove.
|
||||
func (pd *Queue) Remove(idx int) {
|
||||
current := pd.Current()
|
||||
backupID := ""
|
||||
if current != nil {
|
||||
backupID = current.ID
|
||||
}
|
||||
|
||||
pd.Items = append(pd.Items[:idx], pd.Items[idx+1:]...)
|
||||
|
||||
var err error
|
||||
pd.Index, err = pd.getMediaFileIndexByID(backupID)
|
||||
if err != nil {
|
||||
// we seem to have deleted the current id, setting to default:
|
||||
pd.Index = -1
|
||||
}
|
||||
}
|
||||
|
||||
func (pd *Queue) Shuffle() {
|
||||
current := pd.Current()
|
||||
backupID := ""
|
||||
if current != nil {
|
||||
backupID = current.ID
|
||||
}
|
||||
|
||||
rand.Shuffle(len(pd.Items), func(i, j int) { pd.Items[i], pd.Items[j] = pd.Items[j], pd.Items[i] })
|
||||
|
||||
var err error
|
||||
pd.Index, err = pd.getMediaFileIndexByID(backupID)
|
||||
if err != nil {
|
||||
log.Error("Could not find ID while shuffling: " + backupID)
|
||||
}
|
||||
}
|
||||
|
||||
func (pd *Queue) getMediaFileIndexByID(id string) (int, error) {
|
||||
for idx, item := range pd.Items {
|
||||
if item.ID == id {
|
||||
return idx, nil
|
||||
}
|
||||
}
|
||||
return -1, fmt.Errorf("ID not found in playlist: " + id)
|
||||
}
|
||||
|
||||
// Sets the index to a new, valid value inside the Items. Values lower than zero are going to be zero,
|
||||
// values above will be limited by number of items.
|
||||
func (pd *Queue) SetIndex(idx int) {
|
||||
pd.Index = max(0, min(idx, len(pd.Items)-1))
|
||||
}
|
||||
|
||||
// Are we at the last track?
|
||||
func (pd *Queue) IsAtLastElement() bool {
|
||||
return (pd.Index + 1) >= len(pd.Items)
|
||||
}
|
||||
|
||||
// Goto next index
|
||||
func (pd *Queue) IncreaseIndex() {
|
||||
if !pd.IsAtLastElement() {
|
||||
pd.SetIndex(pd.Index + 1)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,121 @@
|
|||
package playback
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Queues", func() {
|
||||
var queue *Queue
|
||||
|
||||
BeforeEach(func() {
|
||||
queue = NewQueue()
|
||||
})
|
||||
|
||||
Describe("use empty queue", func() {
|
||||
It("is empty", func() {
|
||||
Expect(queue.Items).To(BeEmpty())
|
||||
Expect(queue.Index).To(Equal(-1))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Operate on small queue", func() {
|
||||
BeforeEach(func() {
|
||||
mfs := model.MediaFiles{
|
||||
{
|
||||
ID: "1", Artist: "Queen", Compilation: false, Path: "/music1/hammer.mp3",
|
||||
},
|
||||
{
|
||||
ID: "2", Artist: "Vinyard Rose", Compilation: false, Path: "/music1/cassidy.mp3",
|
||||
},
|
||||
}
|
||||
queue.Add(mfs)
|
||||
})
|
||||
|
||||
It("contains the preloaded data", func() {
|
||||
Expect(queue.Get).ToNot(BeNil())
|
||||
Expect(queue.Size()).To(Equal(2))
|
||||
})
|
||||
|
||||
It("could read data by ID", func() {
|
||||
idx, err := queue.getMediaFileIndexByID("1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).ToNot(BeNil())
|
||||
Expect(idx).To(Equal(0))
|
||||
|
||||
queue.SetIndex(idx)
|
||||
|
||||
mf := queue.Current()
|
||||
|
||||
Expect(mf).ToNot(BeNil())
|
||||
Expect(mf.ID).To(Equal("1"))
|
||||
Expect(mf.Artist).To(Equal("Queen"))
|
||||
Expect(mf.Path).To(Equal("/music1/hammer.mp3"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Read/Write operations", func() {
|
||||
BeforeEach(func() {
|
||||
mfs := model.MediaFiles{
|
||||
{
|
||||
ID: "1", Artist: "Queen", Compilation: false, Path: "/music1/hammer.mp3",
|
||||
},
|
||||
{
|
||||
ID: "2", Artist: "Vinyard Rose", Compilation: false, Path: "/music1/cassidy.mp3",
|
||||
},
|
||||
{
|
||||
ID: "3", Artist: "Pink Floyd", Compilation: false, Path: "/music1/time.mp3",
|
||||
},
|
||||
{
|
||||
ID: "4", Artist: "Mike Oldfield", Compilation: false, Path: "/music1/moonlight-shadow.mp3",
|
||||
},
|
||||
{
|
||||
ID: "5", Artist: "Red Hot Chili Peppers", Compilation: false, Path: "/music1/californication.mp3",
|
||||
},
|
||||
}
|
||||
queue.Add(mfs)
|
||||
})
|
||||
|
||||
It("contains the preloaded data", func() {
|
||||
Expect(queue.Get).ToNot(BeNil())
|
||||
Expect(queue.Size()).To(Equal(5))
|
||||
})
|
||||
|
||||
It("could read data by ID", func() {
|
||||
idx, err := queue.getMediaFileIndexByID("5")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).ToNot(BeNil())
|
||||
Expect(idx).To(Equal(4))
|
||||
|
||||
queue.SetIndex(idx)
|
||||
|
||||
mf := queue.Current()
|
||||
|
||||
Expect(mf).ToNot(BeNil())
|
||||
Expect(mf.ID).To(Equal("5"))
|
||||
Expect(mf.Artist).To(Equal("Red Hot Chili Peppers"))
|
||||
Expect(mf.Path).To(Equal("/music1/californication.mp3"))
|
||||
})
|
||||
|
||||
It("could shuffle the data correctly", func() {
|
||||
queue.Shuffle()
|
||||
Expect(queue.Size()).To(Equal(5))
|
||||
})
|
||||
|
||||
It("could remove entries correctly", func() {
|
||||
queue.Remove(0)
|
||||
Expect(queue.Size()).To(Equal(4))
|
||||
|
||||
queue.Remove(3)
|
||||
Expect(queue.Size()).To(Equal(3))
|
||||
})
|
||||
|
||||
It("clear the whole thing on request", func() {
|
||||
Expect(queue.Size()).To(Equal(5))
|
||||
queue.Clear()
|
||||
Expect(queue.Size()).To(Equal(0))
|
||||
})
|
||||
})
|
||||
|
||||
})
|
|
@ -14,6 +14,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/RaveNoX/go-jsoncommentstrip"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
|
@ -23,6 +24,7 @@ import (
|
|||
type Playlists interface {
|
||||
ImportFile(ctx context.Context, dir string, fname string) (*model.Playlist, error)
|
||||
Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error
|
||||
ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error)
|
||||
}
|
||||
|
||||
type playlists struct {
|
||||
|
@ -47,6 +49,26 @@ func (s *playlists) ImportFile(ctx context.Context, dir string, fname string) (*
|
|||
return pls, err
|
||||
}
|
||||
|
||||
func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) {
|
||||
owner, _ := request.UserFrom(ctx)
|
||||
pls := &model.Playlist{
|
||||
OwnerID: owner.ID,
|
||||
Public: false,
|
||||
Sync: true,
|
||||
}
|
||||
pls, err := s.parseM3U(ctx, pls, "", reader)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error parsing playlist", err)
|
||||
return nil, err
|
||||
}
|
||||
err = s.ds.Playlist(ctx).Put(pls)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error saving playlist", err)
|
||||
return nil, err
|
||||
}
|
||||
return pls, nil
|
||||
}
|
||||
|
||||
func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, baseDir string) (*model.Playlist, error) {
|
||||
pls, err := s.newSyncedPlaylist(baseDir, playlistFile)
|
||||
if err != nil {
|
||||
|
@ -91,7 +113,8 @@ func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*mod
|
|||
|
||||
func (s *playlists) parseNSP(ctx context.Context, pls *model.Playlist, file io.Reader) (*model.Playlist, error) {
|
||||
nsp := &nspFile{}
|
||||
dec := json.NewDecoder(file)
|
||||
reader := jsoncommentstrip.NewReader(file)
|
||||
dec := json.NewDecoder(reader)
|
||||
err := dec.Decode(nsp)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error parsing SmartPlaylist", "playlist", pls.Name, err)
|
||||
|
@ -107,31 +130,40 @@ func (s *playlists) parseNSP(ctx context.Context, pls *model.Playlist, file io.R
|
|||
return pls, nil
|
||||
}
|
||||
|
||||
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, baseDir string, file io.Reader) (*model.Playlist, error) {
|
||||
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, baseDir string, reader io.Reader) (*model.Playlist, error) {
|
||||
mediaFileRepository := s.ds.MediaFile(ctx)
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner := bufio.NewScanner(reader)
|
||||
scanner.Split(scanLines)
|
||||
var mfs model.MediaFiles
|
||||
for scanner.Scan() {
|
||||
path := strings.TrimSpace(scanner.Text())
|
||||
// Skip empty lines and extended info
|
||||
if path == "" || strings.HasPrefix(path, "#") {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if strings.HasPrefix(line, "#PLAYLIST:") {
|
||||
if split := strings.Split(line, ":"); len(split) >= 2 {
|
||||
pls.Name = split[1]
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(path, "file://") {
|
||||
path = strings.TrimPrefix(path, "file://")
|
||||
path, _ = url.QueryUnescape(path)
|
||||
// Skip empty lines and extended info
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(baseDir, path)
|
||||
if strings.HasPrefix(line, "file://") {
|
||||
line = strings.TrimPrefix(line, "file://")
|
||||
line, _ = url.QueryUnescape(line)
|
||||
}
|
||||
mf, err := mediaFileRepository.FindByPath(path)
|
||||
if baseDir != "" && !filepath.IsAbs(line) {
|
||||
line = filepath.Join(baseDir, line)
|
||||
}
|
||||
mf, err := mediaFileRepository.FindByPath(line)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", path, err)
|
||||
log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", line, err)
|
||||
continue
|
||||
}
|
||||
mfs = append(mfs, *mf)
|
||||
}
|
||||
if pls.Name == "" {
|
||||
pls.Name = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
pls.Tracks = nil
|
||||
pls.AddMediaFiles(mfs)
|
||||
|
||||
|
@ -157,7 +189,7 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist)
|
|||
newPls.Comment = pls.Comment
|
||||
newPls.OwnerID = pls.OwnerID
|
||||
newPls.Public = pls.Public
|
||||
newPls.EvaluatedAt = time.Time{}
|
||||
newPls.EvaluatedAt = &time.Time{}
|
||||
} else {
|
||||
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
|
||||
newPls.OwnerID = owner.ID
|
||||
|
@ -199,13 +231,17 @@ func (s *playlists) Update(ctx context.Context, playlistID string,
|
|||
var pls *model.Playlist
|
||||
var err error
|
||||
repo := tx.Playlist(ctx)
|
||||
tracks := repo.Tracks(playlistID, true)
|
||||
if tracks == nil {
|
||||
return fmt.Errorf("%w: playlist '%s'", model.ErrNotFound, playlistID)
|
||||
}
|
||||
if needsTrackRefresh {
|
||||
pls, err = repo.GetWithTracks(playlistID, true)
|
||||
pls.RemoveTracks(idxToRemove)
|
||||
pls.AddTracks(idsToAdd)
|
||||
} else {
|
||||
if len(idsToAdd) > 0 {
|
||||
_, err = repo.Tracks(playlistID, true).Add(idsToAdd)
|
||||
_, err = tracks.Add(idsToAdd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -232,7 +268,7 @@ func (s *playlists) Update(ctx context.Context, playlistID string,
|
|||
}
|
||||
// Special case: The playlist is now empty
|
||||
if len(idxToRemove) > 0 && len(pls.Tracks) == 0 {
|
||||
if err = repo.Tracks(playlistID, true).DeleteAll(); err != nil {
|
||||
if err = tracks.DeleteAll(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,11 @@ package core
|
|||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
|
@ -12,13 +17,16 @@ import (
|
|||
var _ = Describe("Playlists", func() {
|
||||
var ds model.DataStore
|
||||
var ps Playlists
|
||||
var mp mockedPlaylist
|
||||
ctx := context.Background()
|
||||
|
||||
BeforeEach(func() {
|
||||
mp = mockedPlaylist{}
|
||||
ds = &tests.MockDataStore{
|
||||
MockedMediaFile: &mockedMediaFile{},
|
||||
MockedPlaylist: &mockedPlaylist{},
|
||||
MockedPlaylist: &mp,
|
||||
}
|
||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||
})
|
||||
|
||||
Describe("ImportFile", func() {
|
||||
|
@ -26,27 +34,76 @@ var _ = Describe("Playlists", func() {
|
|||
ps = NewPlaylists(ds)
|
||||
})
|
||||
|
||||
Describe("M3U", func() {
|
||||
It("parses well-formed playlists", func() {
|
||||
pls, err := ps.ImportFile(ctx, "tests/fixtures", "playlists/pls1.m3u")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(pls.OwnerID).To(Equal("123"))
|
||||
Expect(pls.Tracks).To(HaveLen(3))
|
||||
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/test.mp3"))
|
||||
Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/test.ogg"))
|
||||
Expect(pls.Tracks[2].Path).To(Equal("/tests/fixtures/01 Invisible (RED) Edit Version.mp3"))
|
||||
Expect(mp.last).To(Equal(pls))
|
||||
})
|
||||
|
||||
It("parses playlists using LF ending", func() {
|
||||
pls, err := ps.ImportFile(ctx, "tests/fixtures/playlists", "lf-ended.m3u")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(pls.Tracks).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("parses playlists using CR ending (old Mac format)", func() {
|
||||
pls, err := ps.ImportFile(ctx, "tests/fixtures/playlists", "cr-ended.m3u")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(pls.Tracks).To(HaveLen(2))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("NSP", func() {
|
||||
It("parses well-formed playlists", func() {
|
||||
pls, err := ps.ImportFile(ctx, "tests/fixtures", "playlists/recently_played.nsp")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(mp.last).To(Equal(pls))
|
||||
Expect(pls.OwnerID).To(Equal("123"))
|
||||
Expect(pls.Name).To(Equal("Recently Played"))
|
||||
Expect(pls.Comment).To(Equal("Recently played tracks"))
|
||||
Expect(pls.Rules.Sort).To(Equal("lastPlayed"))
|
||||
Expect(pls.Rules.Order).To(Equal("desc"))
|
||||
Expect(pls.Rules.Limit).To(Equal(100))
|
||||
Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ImportM3U", func() {
|
||||
BeforeEach(func() {
|
||||
ps = NewPlaylists(ds)
|
||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||
})
|
||||
|
||||
It("parses well-formed playlists", func() {
|
||||
pls, err := ps.ImportFile(ctx, "tests/fixtures", "playlists/pls1.m3u")
|
||||
f, _ := os.Open("tests/fixtures/playlists/pls-post-with-name.m3u")
|
||||
defer f.Close()
|
||||
pls, err := ps.ImportM3U(ctx, f)
|
||||
Expect(pls.OwnerID).To(Equal("123"))
|
||||
Expect(pls.Name).To(Equal("playlist 1"))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(pls.Tracks).To(HaveLen(3))
|
||||
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/test.mp3"))
|
||||
Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/test.ogg"))
|
||||
Expect(pls.Tracks[2].Path).To(Equal("/tests/fixtures/01 Invisible (RED) Edit Version.mp3"))
|
||||
Expect(mp.last).To(Equal(pls))
|
||||
f.Close()
|
||||
|
||||
})
|
||||
|
||||
It("parses playlists using LF ending", func() {
|
||||
pls, err := ps.ImportFile(ctx, "tests/fixtures/playlists", "lf-ended.m3u")
|
||||
It("sets the playlist name as a timestamp if the #PLAYLIST directive is not present", func() {
|
||||
f, _ := os.Open("tests/fixtures/playlists/pls-post.m3u")
|
||||
defer f.Close()
|
||||
pls, err := ps.ImportM3U(ctx, f)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(pls.Tracks).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("parses playlists using CR ending (old Mac format)", func() {
|
||||
pls, err := ps.ImportFile(ctx, "tests/fixtures/playlists", "cr-ended.m3u")
|
||||
_, err = time.Parse(time.RFC3339, pls.Name)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(pls.Tracks).To(HaveLen(2))
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -62,6 +119,7 @@ func (r *mockedMediaFile) FindByPath(s string) (*model.MediaFile, error) {
|
|||
}
|
||||
|
||||
type mockedPlaylist struct {
|
||||
last *model.Playlist
|
||||
model.PlaylistRepository
|
||||
}
|
||||
|
||||
|
@ -69,6 +127,7 @@ func (r *mockedPlaylist) FindByPath(string) (*model.Playlist, error) {
|
|||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (r *mockedPlaylist) Put(*model.Playlist) error {
|
||||
func (r *mockedPlaylist) Put(pls *model.Playlist) error {
|
||||
r.last = pls
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -7,7 +7,6 @@ import (
|
|||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/pl"
|
||||
)
|
||||
|
||||
func newBufferedScrobbler(ds model.DataStore, s Scrobbler, service string) *bufferedScrobbler {
|
||||
|
@ -57,7 +56,12 @@ func (b *bufferedScrobbler) run(ctx context.Context) {
|
|||
b.sendWakeSignal()
|
||||
})
|
||||
}
|
||||
<-pl.ReadOrDone(ctx, b.wakeSignal)
|
||||
select {
|
||||
case <-b.wakeSignal:
|
||||
continue
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -5,10 +5,9 @@ import (
|
|||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/jellydator/ttlcache/v2"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
|
||||
"github.com/ReneKroon/ttlcache/v2"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
|
@ -16,10 +15,10 @@ import (
|
|||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
)
|
||||
|
||||
const nowPlayingExpire = 60 * time.Minute
|
||||
const maxNowPlayingExpire = 60 * time.Minute
|
||||
|
||||
type NowPlayingInfo struct {
|
||||
TrackID string
|
||||
MediaFile model.MediaFile
|
||||
Start time.Time
|
||||
Username string
|
||||
PlayerId string
|
||||
|
@ -46,50 +45,58 @@ type playTracker struct {
|
|||
|
||||
func GetPlayTracker(ds model.DataStore, broker events.Broker) PlayTracker {
|
||||
return singleton.GetInstance(func() *playTracker {
|
||||
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 newPlayTracker(ds, broker)
|
||||
})
|
||||
}
|
||||
|
||||
// This constructor only exists for testing. For normal usage, the PlayTracker has to be a singleton, returned by
|
||||
// the GetPlayTracker function above
|
||||
func newPlayTracker(ds model.DataStore, broker events.Broker) *playTracker {
|
||||
m := ttlcache.NewCache()
|
||||
m.SkipTTLExtensionOnHit(true)
|
||||
_ = m.SetTTL(maxNowPlayingExpire)
|
||||
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
|
||||
}
|
||||
|
||||
func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerName string, trackId string) error {
|
||||
mf, err := p.ds.MediaFile(ctx).Get(trackId)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error retrieving mediaFile", "id", trackId, err)
|
||||
return err
|
||||
}
|
||||
|
||||
user, _ := request.UserFrom(ctx)
|
||||
info := NowPlayingInfo{
|
||||
TrackID: trackId,
|
||||
MediaFile: *mf,
|
||||
Start: time.Now(),
|
||||
Username: user.UserName,
|
||||
PlayerId: playerId,
|
||||
PlayerName: playerName,
|
||||
}
|
||||
_ = p.playMap.Set(playerId, info)
|
||||
|
||||
ttl := time.Duration(int(mf.Duration)+5) * time.Second
|
||||
_ = p.playMap.SetWithTTL(playerId, info, ttl)
|
||||
player, _ := request.PlayerFrom(ctx)
|
||||
if player.ScrobbleEnabled {
|
||||
p.dispatchNowPlaying(ctx, user.ID, trackId)
|
||||
p.dispatchNowPlaying(ctx, user.ID, mf)
|
||||
}
|
||||
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
|
||||
}
|
||||
func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile) {
|
||||
if t.Artist == consts.UnknownArtist {
|
||||
log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist)
|
||||
return
|
||||
}
|
||||
// TODO Parallelize
|
||||
for name, s := range p.scrobblers {
|
||||
if !s.IsAuthorized(ctx, userId) {
|
||||
continue
|
||||
|
@ -103,7 +110,7 @@ func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, tra
|
|||
}
|
||||
}
|
||||
|
||||
func (p *playTracker) GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error) {
|
||||
func (p *playTracker) GetNowPlaying(_ context.Context) ([]NowPlayingInfo, error) {
|
||||
var res []NowPlayingInfo
|
||||
for _, playerId := range p.playMap.GetKeys() {
|
||||
value, err := p.playMap.Get(playerId)
|
||||
|
|
|
@ -37,19 +37,19 @@ var _ = Describe("PlayTracker", func() {
|
|||
Register("fake", func(ds model.DataStore) Scrobbler {
|
||||
return &fake
|
||||
})
|
||||
tracker = GetPlayTracker(ds, events.GetBroker())
|
||||
tracker = newPlayTracker(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",
|
||||
ID: "123",
|
||||
Title: "Track Title",
|
||||
Album: "Track Album",
|
||||
AlbumID: "al-1",
|
||||
Artist: "Track Artist",
|
||||
ArtistID: "ar-1",
|
||||
AlbumArtist: "Track AlbumArtist",
|
||||
TrackNumber: 1,
|
||||
Duration: 180,
|
||||
MbzRecordingID: "mbz-123",
|
||||
}
|
||||
_ = ds.MediaFile(ctx).Put(&track)
|
||||
artist = model.Artist{ID: "ar-1"}
|
||||
|
@ -93,16 +93,13 @@ var _ = Describe("PlayTracker", func() {
|
|||
})
|
||||
|
||||
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"})
|
||||
_ = ds.MediaFile(ctx).Put(&track2)
|
||||
ctx = request.WithUser(context.Background(), model.User{UserName: "user-1"})
|
||||
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123")
|
||||
ctx = request.WithUser(ctx, model.User{UserName: "user-2"})
|
||||
ctx = request.WithUser(context.Background(), model.User{UserName: "user-2"})
|
||||
_ = tracker.NowPlaying(ctx, "player-2", "player-two", "456")
|
||||
|
||||
playing, err := tracker.GetNowPlaying(ctx)
|
||||
|
@ -112,12 +109,12 @@ var _ = Describe("PlayTracker", func() {
|
|||
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[0].MediaFile.ID).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"))
|
||||
Expect(playing[1].MediaFile.ID).To(Equal("123"))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ import (
|
|||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
|
@ -34,10 +35,11 @@ func (s *shareService) Load(ctx context.Context, id string) (*model.Share, error
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !share.ExpiresAt.IsZero() && share.ExpiresAt.Before(time.Now()) {
|
||||
return nil, model.ErrNotAvailable
|
||||
expiresAt := V(share.ExpiresAt)
|
||||
if !expiresAt.IsZero() && expiresAt.Before(time.Now()) {
|
||||
return nil, model.ErrExpired
|
||||
}
|
||||
share.LastVisitedAt = time.Now()
|
||||
share.LastVisitedAt = P(time.Now())
|
||||
share.VisitCount++
|
||||
|
||||
err = repo.(rest.Persistable).Update(id, share, "last_visited_at", "visit_count")
|
||||
|
@ -90,8 +92,8 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
|
|||
return "", err
|
||||
}
|
||||
s.ID = id
|
||||
if s.ExpiresAt.IsZero() {
|
||||
s.ExpiresAt = time.Now().Add(365 * 24 * time.Hour)
|
||||
if V(s.ExpiresAt).IsZero() {
|
||||
s.ExpiresAt = P(time.Now().Add(365 * 24 * time.Hour))
|
||||
}
|
||||
|
||||
firstId := strings.SplitN(s.ResourceIDs, ",", 2)[0]
|
||||
|
@ -125,10 +127,10 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
|
|||
}
|
||||
|
||||
func (r *shareRepositoryWrapper) Update(id string, entity interface{}, _ ...string) error {
|
||||
cols := []string{"description"}
|
||||
cols := []string{"description", "downloadable"}
|
||||
|
||||
// TODO Better handling of Share expiration
|
||||
if !entity.(*model.Share).ExpiresAt.IsZero() {
|
||||
if !V(entity.(*model.Share).ExpiresAt).IsZero() {
|
||||
cols = append(cols, "expires_at")
|
||||
}
|
||||
return r.Persistable.Update(id, entity, cols...)
|
||||
|
|
|
@ -45,7 +45,7 @@ var _ = Describe("Share", func() {
|
|||
entity := &model.Share{}
|
||||
err := repo.Update("id", entity)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockedRepo.(*tests.MockShareRepo).Cols).To(ConsistOf("description"))
|
||||
Expect(mockedRepo.(*tests.MockShareRepo).Cols).To(ConsistOf("description", "downloadable"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"github.com/google/wire"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
)
|
||||
|
||||
|
@ -18,4 +19,5 @@ var Set = wire.NewSet(
|
|||
agents.New,
|
||||
ffmpeg.New,
|
||||
scrobbler.GetPlayTracker,
|
||||
playback.GetInstance,
|
||||
)
|
||||
|
|
52
db/db.go
52
db/db.go
|
@ -2,14 +2,15 @@ package db
|
|||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
_ "github.com/navidrome/navidrome/db/migration"
|
||||
_ "github.com/navidrome/navidrome/db/migrations"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
var (
|
||||
|
@ -17,6 +18,11 @@ var (
|
|||
Path string
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var embedMigrations embed.FS
|
||||
|
||||
const migrationsFolder = "migrations"
|
||||
|
||||
func Db() *sql.DB {
|
||||
return singleton.GetInstance(func() *sql.DB {
|
||||
Path = conf.Server.DbPath
|
||||
|
@ -38,7 +44,7 @@ func Close() error {
|
|||
return Db().Close()
|
||||
}
|
||||
|
||||
func EnsureLatestVersion() {
|
||||
func Init() func() {
|
||||
db := Db()
|
||||
|
||||
// Disable foreign_keys to allow re-creating tables in migrations
|
||||
|
@ -54,19 +60,53 @@ func EnsureLatestVersion() {
|
|||
}
|
||||
|
||||
gooseLogger := &logAdapter{silent: isSchemaEmpty(db)}
|
||||
goose.SetLogger(gooseLogger)
|
||||
goose.SetBaseFS(embedMigrations)
|
||||
|
||||
err = goose.SetDialect(Driver)
|
||||
if err != nil {
|
||||
log.Fatal("Invalid DB driver", "driver", Driver, err)
|
||||
}
|
||||
err = goose.Run("up", db, "./")
|
||||
if !isSchemaEmpty(db) && hasPendingMigrations(db, migrationsFolder) {
|
||||
log.Info("Upgrading DB Schema to latest version")
|
||||
}
|
||||
goose.SetLogger(gooseLogger)
|
||||
err = goose.Up(db, migrationsFolder)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to apply new migrations", err)
|
||||
}
|
||||
|
||||
return func() {
|
||||
if err := Close(); err != nil {
|
||||
log.Error("Error closing DB", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isSchemaEmpty(db *sql.DB) bool { // nolint:interfacer
|
||||
type statusLogger struct{ numPending int }
|
||||
|
||||
func (*statusLogger) Fatalf(format string, v ...interface{}) { log.Fatal(fmt.Sprintf(format, v...)) }
|
||||
func (l *statusLogger) Printf(format string, v ...interface{}) {
|
||||
if len(v) < 1 {
|
||||
return
|
||||
}
|
||||
if v0, ok := v[0].(string); !ok {
|
||||
return
|
||||
} else if v0 == "Pending" {
|
||||
l.numPending++
|
||||
}
|
||||
}
|
||||
|
||||
func hasPendingMigrations(db *sql.DB, folder string) bool {
|
||||
l := &statusLogger{}
|
||||
goose.SetLogger(l)
|
||||
err := goose.Status(db, folder)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to check for pending migrations", err)
|
||||
}
|
||||
return l.numPending > 0
|
||||
}
|
||||
|
||||
func isSchemaEmpty(db *sql.DB) bool {
|
||||
rows, err := db.Query("SELECT name FROM sqlite_master WHERE type='table' AND name='goose_db_version';") // nolint:rowserrcheck
|
||||
if err != nil {
|
||||
log.Fatal("Database could not be opened!", err)
|
||||
|
|
|
@ -1,20 +0,0 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200310171621, Down20200310171621)
|
||||
}
|
||||
|
||||
func Up20200310171621(tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed to enable search by Album Artist!")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200310171621(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200326090707, Down20200326090707)
|
||||
}
|
||||
|
||||
func Up20200326090707(tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed!")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200326090707(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200409002249, Down20200409002249)
|
||||
}
|
||||
|
||||
func Up20200409002249(tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed to enable search by individual Artist in an Album!")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200409002249(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200418110522, Down20200418110522)
|
||||
}
|
||||
|
||||
func Up20200418110522(tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed to fix search Albums by year")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200418110522(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200419222708, Down20200419222708)
|
||||
}
|
||||
|
||||
func Up20200419222708(tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed to change the search behaviour")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200419222708(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20201003111749, Down20201003111749)
|
||||
}
|
||||
|
||||
func Up20201003111749(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create index if not exists annotation_starred_at
|
||||
on annotation (starred_at);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20201003111749(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20201021135455, Down20201021135455)
|
||||
}
|
||||
|
||||
func Up20201021135455(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create index if not exists media_file_artist_id
|
||||
on media_file (artist_id);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20201021135455(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20201128100726, Down20201128100726)
|
||||
}
|
||||
|
||||
func Up20201128100726(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table player
|
||||
add report_real_path bool default FALSE not null;
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20201128100726(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upAddAlphabeticalByArtistIndex, downAddAlphabeticalByArtistIndex)
|
||||
}
|
||||
|
||||
func upAddAlphabeticalByArtistIndex(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create index album_alphabetical_by_artist
|
||||
ON album(compilation, order_album_artist_name, order_album_name)
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddAlphabeticalByArtistIndex(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upRemoveInvalidArtistIds, downRemoveInvalidArtistIds)
|
||||
}
|
||||
|
||||
func upRemoveInvalidArtistIds(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
update media_file set artist_id = '' where not exists(select 1 from artist where id = artist_id)
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downRemoveInvalidArtistIds(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upTouchPlaylists, downTouchPlaylists)
|
||||
}
|
||||
|
||||
func upTouchPlaylists(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`update playlist set updated_at = datetime('now');`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downTouchPlaylists(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,17 +1,18 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200130083147, Down20200130083147)
|
||||
goose.AddMigrationContext(Up20200130083147, Down20200130083147)
|
||||
}
|
||||
|
||||
func Up20200130083147(tx *sql.Tx) error {
|
||||
func Up20200130083147(_ context.Context, tx *sql.Tx) error {
|
||||
log.Info("Creating DB Schema")
|
||||
_, err := tx.Exec(`
|
||||
create table if not exists album
|
||||
|
@ -178,6 +179,6 @@ create table if not exists user
|
|||
return err
|
||||
}
|
||||
|
||||
func Down20200130083147(tx *sql.Tx) error {
|
||||
func Down20200130083147(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue