mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-01 00:10:40 +02:00
Compare commits
707 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e70e4b9d40 | |||
| aacdcc47ec | |||
| 499b52b4dd | |||
| 1bad2d9072 | |||
| c009db9f28 | |||
| 325469c5a5 | |||
| c97b36e11c | |||
| e944b2a2f5 | |||
| 2d0a5462d2 | |||
| 72dc75482f | |||
| cac74f3477 | |||
| 1ad11b2b9e | |||
| 50eeca2e0f | |||
| 4f21fc023c | |||
| 52a485d135 | |||
| 3b025076e8 | |||
| 6d5d89429d | |||
| c010f0e1eb | |||
| eee377e081 | |||
| b0aaa24660 | |||
| 47ea6b5092 | |||
| 4b060febc2 | |||
| 40869bcf39 | |||
| 3942805129 | |||
| dc446862c1 | |||
| 379f6c716a | |||
| 47457ee1e7 | |||
| cb6ff9eedf | |||
| 5dc01261c1 | |||
| cbc103cf05 | |||
| e79256d0fb | |||
| f8ef56c6bc | |||
| 62d7097e23 | |||
| 92df92ec99 | |||
| 1c229e0627 | |||
| f8a71cc514 | |||
| 63de5bb2d5 | |||
| 2c3108a1fa | |||
| 928051744a | |||
| 3ccdcaec1a | |||
| f47bbc7886 | |||
| 7c0ca44727 | |||
| d6a2e5596b | |||
| a5362de9cc | |||
| 9ab35ef418 | |||
| 79cc9765cf | |||
| 5b2a788cfc | |||
| 80b39abaa2 | |||
| b41db23994 | |||
| 125f265f55 | |||
| aa4a191567 | |||
| e431ea0472 | |||
| e3388d4446 | |||
| 88879f1409 | |||
| 3e0099e8d9 | |||
| f558182d94 | |||
| a30fe15b10 | |||
| 0bbf8bde5c | |||
| 0e2cdde731 | |||
| bc6bfbe804 | |||
| 2755204168 | |||
| 2d4df273f0 | |||
| d73b64a19c | |||
| b7e8a0474a | |||
| 39adefb632 | |||
| 24cab79c66 | |||
| b27f21fd95 | |||
| 09fa0b38f5 | |||
| 455e605162 | |||
| 88667d00a1 | |||
| 94c426bd97 | |||
| 522b9735e2 | |||
| 5a6b3d8e61 | |||
| 64cbf59609 | |||
| fda1a6ea9b | |||
| c4c8b8d0f2 | |||
| ab3bd6f4a1 | |||
| 093124aac6 | |||
| 5de92d08f9 | |||
| 8b89b27654 | |||
| 3faa6f3e7d | |||
| 9821c31f8e | |||
| efe2a22674 | |||
| 9634c46bc5 | |||
| 5f8db24b96 | |||
| e781ff5eae | |||
| 32a17c0044 | |||
| f84831d6f1 | |||
| dc54d42dcf | |||
| 15af7407ff | |||
| 5d9682410a | |||
| 4bdd76d94c | |||
| 7c0d9efe91 | |||
| 874e9e1856 | |||
| 6d3773a0b8 | |||
| a47c869d0b | |||
| eb0383d37a | |||
| e66ffb9c23 | |||
| 972193b193 | |||
| 690a7e0da9 | |||
| d9355ac3aa | |||
| fbe1d1eed6 | |||
| e83aca572e | |||
| 367826ce64 | |||
| 6e6c43c53c | |||
| 6479cdb66d | |||
| 635e132325 | |||
| 45dd843ce1 | |||
| 7c956b1582 | |||
| 9afa39e29e | |||
| 5f8450602e | |||
| fa6dae1a53 | |||
| a1d439b8d5 | |||
| 2f32673991 | |||
| c1a6b51d78 | |||
| 6c2e13fb4e | |||
| 210fa55b6a | |||
| fb8ca043ad | |||
| 69d7c399b8 | |||
| a35ba05600 | |||
| 40f42b2ab6 | |||
| cf4b9e938d | |||
| 31120ad111 | |||
| cd8640f00e | |||
| d0ba455ed6 | |||
| e5b7aea46c | |||
| 2f2d026b06 | |||
| c156b063f5 | |||
| e6d49a2d53 | |||
| 6d3404272c | |||
| a9e12657f5 | |||
| c0319ebbac | |||
| 1d0b7e383a | |||
| 9f5d8386f3 | |||
| 6e0da3bf7a | |||
| ee6016f70e | |||
| f1a2e56054 | |||
| d2915e689f | |||
| 05d9ab81f9 | |||
| 75eed9d09a | |||
| e5af2f336b | |||
| ade1752e97 | |||
| fa5fa7b788 | |||
| b01facc034 | |||
| dd4fc09909 | |||
| c15cb48def | |||
| fe13456a2b | |||
| 2ee893062f | |||
| 31630f50a5 | |||
| edfce46058 | |||
| cc5244c596 | |||
| b8942c5931 | |||
| 6e5feee78a | |||
| e7cb0466e6 | |||
| 6c7221d37d | |||
| 1f3fa80ddd | |||
| 87f3766299 | |||
| d08cef11ed | |||
| 7201cced42 | |||
| 4f8fbbc979 | |||
| e55fed4a33 | |||
| dcbeecff7a | |||
| 32276aacd9 | |||
| b921a08809 | |||
| c089336e41 | |||
| 5107b0307c | |||
| 8498378bca | |||
| b61e2c30f2 | |||
| cfeb6bd502 | |||
| 077b523bd6 | |||
| b8a2d113f0 | |||
| e1ae4f2d31 | |||
| 7aa2f84daa | |||
| da0a64daed | |||
| 3e4225bced | |||
| e6d99d07f0 | |||
| 122fc34a75 | |||
| e5c0a9d22c | |||
| 3bf136a20b | |||
| b387d9484a | |||
| e8668d9f22 | |||
| f3e90bd420 | |||
| 4bf15bbffd | |||
| 04eb3bc437 | |||
| 81e96df9c5 | |||
| 44aff23e1b | |||
| cc48d9f26d | |||
| ac08e897ee | |||
| 3c2eec8279 | |||
| 7b37c98e88 | |||
| 088353ae26 | |||
| e003544edd | |||
| 076ece6fe7 | |||
| 14f72ab7d4 | |||
| ebcb122eb8 | |||
| 626596b192 | |||
| 10a4777ddf | |||
| 0ecbb1c3f4 | |||
| dc2398a072 | |||
| c1e21d31ee | |||
| 70e6efc3d0 | |||
| 092c504eb1 | |||
| f7d7c9a4f5 | |||
| 8bdcabf973 | |||
| 646c861bcc | |||
| ee60169995 | |||
| afb4108c30 | |||
| 2e2d857ce0 | |||
| ed5766b4ab | |||
| a33e87db99 | |||
| 5de942aefb | |||
| bcfe1e9647 | |||
| 503f4611b2 | |||
| 648983708e | |||
| 991d25f628 | |||
| d2a7c3c381 | |||
| 219a9fc6d5 | |||
| ba2259d174 | |||
| d7bfccdc4a | |||
| 5f1edcb609 | |||
| 329e3c7179 | |||
| 919ea32416 | |||
| 3b6419bc1b | |||
| d4fdb47c7f | |||
| cee9b9d8e3 | |||
| 9441346b0a | |||
| 6b8464c270 | |||
| d12f727603 | |||
| 1552c250df | |||
| 623c2fba12 | |||
| be27908d44 | |||
| 7a39d581a1 | |||
| 53a416fd28 | |||
| 7393c03218 | |||
| 594589da3d | |||
| 44d7deae99 | |||
| ff9e87c4d5 | |||
| c2fd87d55c | |||
| 27843c3f9b | |||
| 0ec2ced011 | |||
| 552ed43243 | |||
| 0606738b38 | |||
| a5d2c1bd64 | |||
| d8e272e091 | |||
| 3e9ca51088 | |||
| 8758c62ae2 | |||
| db9019a94f | |||
| 39b8b9df4f | |||
| a36f097095 | |||
| ae0ccb1b47 | |||
| f178841e57 | |||
| 568b154e8a | |||
| 8c5678b573 | |||
| e51c7b2be1 | |||
| d460757df4 | |||
| cd295c03ca | |||
| 38dd1beff7 | |||
| 61b72aff9d | |||
| 9eda4e36fa | |||
| a05cb170a2 | |||
| c75d976320 | |||
| ab9b798bfa | |||
| 40783c8644 | |||
| a0137fcb42 | |||
| 9fdda6be62 | |||
| b0c073dd7e | |||
| b83e2836f6 | |||
| f91be18527 | |||
| c1f4e4120e | |||
| b42c7421b0 | |||
| 82f512d405 | |||
| 01a833ea59 | |||
| f1c39e8587 | |||
| 5e68936c20 | |||
| a4c9b062c1 | |||
| 763d8810e3 | |||
| 3316505d1c | |||
| 2cf6e8a5fe | |||
| 961d066bdd | |||
| 372c9a5322 | |||
| f77de1743e | |||
| a5750deaaf | |||
| 0c7b738b7c | |||
| c3c9e7731d | |||
| d3b5612fc0 | |||
| 96ef0129ed | |||
| 85546b7dd7 | |||
| d59714d804 | |||
| 96693659bf | |||
| ee2d8d1f71 | |||
| f03b0915eb | |||
| a92ba564bd | |||
| e684a8dc43 | |||
| 6db6b862e6 | |||
| 57c7b123f0 | |||
| fd593caafc | |||
| d0a3f74710 | |||
| b1921e7034 | |||
| 538a5065a4 | |||
| 166e0442a0 | |||
| 816a47a4ba | |||
| 141211590f | |||
| b01e7570d3 | |||
| 0a8662d198 | |||
| 0a4de61eff | |||
| 0a82d6a41b | |||
| 3f6162f53c | |||
| 888190a6be | |||
| ce4ff4f894 | |||
| 1da3ab7fdc | |||
| 4f30cbf2f6 | |||
| a87ea32715 | |||
| feed827223 | |||
| 797dba2448 | |||
| f0acbb2e81 | |||
| fc06aa2c78 | |||
| 4224f44259 | |||
| 2592467d09 | |||
| 37beb7b37c | |||
| cafd92e206 | |||
| 3e876e3383 | |||
| 29752798f3 | |||
| 8c86ca4ea5 | |||
| 00c62fa494 | |||
| 6c7f3c7e77 | |||
| aec8acbdd7 | |||
| 6e19ad7777 | |||
| 3aa95fec11 | |||
| 37dd46d31f | |||
| 54a996634e | |||
| 54a5e368c2 | |||
| 2d313851d2 | |||
| eb00b19457 | |||
| bbae9acc2d | |||
| a4e8f01f0e | |||
| 6bdf402da8 | |||
| 80b0e3546e | |||
| 161f3cb177 | |||
| 4a4d4a8f17 | |||
| b21046027c | |||
| 3a163e1746 | |||
| 3c4e80f1c1 | |||
| 2f3036faba | |||
| 3934461c46 | |||
| 123351e08a | |||
| 1280ddfe74 | |||
| 7e89b97a6d | |||
| 20de2ea388 | |||
| dbb5ee79ac | |||
| c6dabd2620 | |||
| 26f949b9ba | |||
| 7630dbdcb7 | |||
| a164c17d38 | |||
| 03da194953 | |||
| 9ce6de3100 | |||
| e040396b20 | |||
| bcbec67fec | |||
| 1543021685 | |||
| 577e6aaec9 | |||
| 77579acfd4 | |||
| 9ca98ca750 | |||
| feb225d3a6 | |||
| e501aa4f1e | |||
| 104f6e6c58 | |||
| 552d8ae3b8 | |||
| a41e9bae5d | |||
| a456865ec0 | |||
| 85d5531bc1 | |||
| 4b840f9c97 | |||
| b9510a69fe | |||
| d737a66af2 | |||
| 576d18d8d6 | |||
| d238b02bd2 | |||
| c6cb13ed39 | |||
| 44c5dce8aa | |||
| b726bee4e5 | |||
| b07e449043 | |||
| 9273e61f1e | |||
| 1b4a7acf13 | |||
| 68c1395bdf | |||
| a007a9ec98 | |||
| 8b33b5e383 | |||
| c81b762d52 | |||
| c53a5c5a0b | |||
| 83af75a582 | |||
| 60389a3bf3 | |||
| 20cceb3a8f | |||
| 7562fb2c21 | |||
| c7647aafd7 | |||
| 4a73247e5c | |||
| 326086c197 | |||
| 5ff5245476 | |||
| 856cf180a5 | |||
| fbe9971a8b | |||
| 6ea70608a1 | |||
| ba7160c305 | |||
| 7d048b7a50 | |||
| afab429c75 | |||
| 50e2fe7fd2 | |||
| c7c21cc137 | |||
| 7e4c7a7e3b | |||
| 40babc9650 | |||
| 7a94f014ea | |||
| 32adb1bafd | |||
| f9a6239049 | |||
| 8dee1ec942 | |||
| 58e43cc6a7 | |||
| b8999fbc37 | |||
| 0dda4b6b27 | |||
| 817f2f6915 | |||
| 77fc6bba1a | |||
| c66d652a53 | |||
| 86bddba5c3 | |||
| 7779fd2972 | |||
| 05a4577792 | |||
| 56dc042282 | |||
| 95973243a6 | |||
| 18ad23d016 | |||
| e258f122f1 | |||
| 18200a8f01 | |||
| 9c47f404c9 | |||
| 2f6de71a3a | |||
| deb121c523 | |||
| 320e4dfb47 | |||
| 6194c48549 | |||
| 6aa9ecaaba | |||
| b3d020b89f | |||
| e196a6e5ca | |||
| 73cf22b499 | |||
| ac7464ce7e | |||
| 84e742f2a5 | |||
| a1e882cbf1 | |||
| 09121acbd5 | |||
| 5b9df84ba3 | |||
| 266db491aa | |||
| c7a317a87b | |||
| b027f3bda1 | |||
| cea991b82f | |||
| 7e2b51e6d2 | |||
| 8f310b6bf0 | |||
| b2a5fb46f1 | |||
| 6d7639853b | |||
| 3a16acbba4 | |||
| 027e1efaca | |||
| d1fabba86b | |||
| b290a4ada3 | |||
| bb477c617e | |||
| 9238c38842 | |||
| d268516fcb | |||
| d353cff1ae | |||
| 604f17f60b | |||
| 3911a7273b | |||
| 138bb563b8 | |||
| 3801ef062a | |||
| e4b9ac5446 | |||
| 9987d219f8 | |||
| dc7045c562 | |||
| 2cc6e56bd1 | |||
| a89a24e48e | |||
| a968aca304 | |||
| 8d1f460640 | |||
| 553ffd1934 | |||
| fd4932cdbb | |||
| dcaca43817 | |||
| 0eed4e82f9 | |||
| 2ed2328401 | |||
| 8b260c8bc6 | |||
| 7dcb9b98a0 | |||
| 311ac7104e | |||
| 2c45b28d48 | |||
| b53613f82c | |||
| 751371abb8 | |||
| 6365c02875 | |||
| fb3834156b | |||
| c03f3f722d | |||
| a06f48ca29 | |||
| 9d79552dda | |||
| ed98614b6f | |||
| 09dd2cc79c | |||
| e87237048a | |||
| d71968fd80 | |||
| f83c605ae1 | |||
| 4325f470dd | |||
| 800ecf8e82 | |||
| 5cb143d50b | |||
| 798c73c66c | |||
| 0fa7c46274 | |||
| c2d420ec70 | |||
| 152daf7bf3 | |||
| 8d99249e50 | |||
| c6724ba353 | |||
| a519d44666 | |||
| 7e8bf977cc | |||
| 4018be6330 | |||
| 99a3867ce9 | |||
| 2116f60133 | |||
| 794f0ef42a | |||
| 3e423839a1 | |||
| 2773c8c4a9 | |||
| e510174f12 | |||
| 08c9e8d47d | |||
| 1908ec3df5 | |||
| df3878d4ca | |||
| 1097de6f1f | |||
| e408070b19 | |||
| af67c2e86f | |||
| 6a52d2a968 | |||
| 3337b3af18 | |||
| 835d2c7f36 | |||
| 03f91099e0 | |||
| c04afd0787 | |||
| b03bd79f5d | |||
| 5ef632a7eb | |||
| 79b4042e8e | |||
| 8f718ef91c | |||
| 4053b20623 | |||
| c4d654635f | |||
| ef5d0ffa48 | |||
| 6a826cdb36 | |||
| 1d837f5f21 | |||
| 80873b379c | |||
| 82a8f8f126 | |||
| 4725a466da | |||
| 031edc870c | |||
| 625e2445b5 | |||
| 1640af2f1c | |||
| c76f76cc27 | |||
| 74af212293 | |||
| e04efb9c6a | |||
| ee17e7a555 | |||
| 694a852c07 | |||
| 18068bb261 | |||
| 71257f6c6c | |||
| 4d70929d2e | |||
| 578e9559e4 | |||
| 894ea0b80a | |||
| e54571f011 | |||
| 77d7a50b99 | |||
| 32da0f1224 | |||
| 2054accdc9 | |||
| 7d8b857c77 | |||
| 0107cb4782 | |||
| f273eee807 | |||
| 4af21b079a | |||
| c9eaf2db2d | |||
| cae1560344 | |||
| a5fb0d9cdb | |||
| 53c80d9798 | |||
| 832165716b | |||
| d9f2d8bf1d | |||
| a7a3a56509 | |||
| 4082fadf90 | |||
| 93160b83bf | |||
| 472240f994 | |||
| c3f0fb8e5e | |||
| b156ebeb9f | |||
| e4c775c847 | |||
| 45e8e72759 | |||
| 0ae7340889 | |||
| 8c38987d92 | |||
| 878f0787ba | |||
| 880d85eaef | |||
| f7aaebc1de | |||
| d96ebbe82d | |||
| 70d67156f0 | |||
| f293b317be | |||
| 1f23794f88 | |||
| e6bfd118f6 | |||
| 1166400ab1 | |||
| 55f0ac871b | |||
| 3584f6e24f | |||
| 23bf2594c8 | |||
| 8fb460ce05 | |||
| 8c4bbfd6a2 | |||
| 742961e0b8 | |||
| 5b6807892f | |||
| b911a25c57 | |||
| 53110674e4 | |||
| f963cd4753 | |||
| 0dccc3bcae | |||
| 5b4fd5b254 | |||
| bdb9d3caeb | |||
| 9aca824b59 | |||
| 8e891805eb | |||
| 2760517445 | |||
| 889ee33320 | |||
| 4f65801713 | |||
| 3e75acd4ef | |||
| 3e8fe2ef60 | |||
| 0bc441de20 | |||
| a8c2f0d4c8 | |||
| b59da8bd0c | |||
| 77cb4f75c6 | |||
| 9cf1711fae | |||
| f472116dc3 | |||
| c7eb9d7799 | |||
| c66380eaeb | |||
| 1bebb22705 | |||
| 4e96649fe3 | |||
| a21cec806e | |||
| 8a3b8d2249 | |||
| 581277914c | |||
| e678fe6e2f | |||
| 3845940245 | |||
| 6c63e2131c | |||
| e25e2b238f | |||
| 99110f587a | |||
| b553e959e2 | |||
| f7b94a4b6d | |||
| e9a705587a | |||
| 264ae928a9 | |||
| f5248a9f00 | |||
| 3473ff594a | |||
| 20bb6e13b5 | |||
| a05d32b1d7 | |||
| c6b3521cb6 | |||
| 2444504c6a | |||
| d38532c07a | |||
| 4f7831611f | |||
| d09db19cd5 | |||
| 030e43f382 | |||
| f081a7fdc1 | |||
| f0d5f46199 | |||
| 0b8f6db45e | |||
| 806c0a2991 | |||
| 7d6d3e6687 | |||
| ad07ed7e25 | |||
| d3402e30c2 | |||
| 25fe4dee3a | |||
| 3c21c82ce1 | |||
| 3c8876a37d | |||
| fba70c9831 | |||
| 27e40d16fd | |||
| 448cbf8530 | |||
| f1153f9da5 | |||
| d09a21d922 | |||
| 62afa3c3ee | |||
| 85446be0e5 | |||
| 018ca8e7ee | |||
| f02453ac92 | |||
| 84b77f4c7f | |||
| d41276ba8c | |||
| 576d7dc024 | |||
| 6d2b1df560 | |||
| 8255e4308c | |||
| 794adf0292 | |||
| f2e0b9762c | |||
| 7d0def0edb | |||
| 0653572396 | |||
| d9a3750667 | |||
| 9c0c7b6b08 | |||
| df1391d93f | |||
| bf6d81b333 | |||
| 8775e55762 | |||
| d0d152c20d | |||
| 4ff7355262 | |||
| 6cc7a44a22 | |||
| ad092ef8f8 | |||
| 4102ed8be4 | |||
| 691f291843 | |||
| ac381854e5 | |||
| 9c8900560c | |||
| d9cfcc86e7 | |||
| ce803dd6de | |||
| 97afd22f81 | |||
| e24eaab3f1 | |||
| e201247d69 | |||
| a24dae5262 | |||
| e59babdf24 | |||
| 8dbe1e4e5d | |||
| cdc37ddb0f | |||
| f127a7beb5 | |||
| df60aeb456 | |||
| 30c327d92a | |||
| 596bddf791 | |||
| 44ff90a6f2 | |||
| 293851d931 | |||
| 8b995a179d | |||
| 4d32a22de9 | |||
| af1ff12dbb | |||
| d96ed01ce4 | |||
| 7610e97f0f | |||
| 4f5123e842 | |||
| d102065d02 | |||
| 34315d4c10 | |||
| 276a179446 | |||
| 4462d32e98 | |||
| 9722674072 | |||
| 35bb77c9c2 | |||
| cf6f49ce75 | |||
| d614373c64 | |||
| b9969c78a6 | |||
| fbf482d6b6 | |||
| dd74d0a726 | |||
| b13b80e011 | |||
| e384863148 | |||
| 9c44fc0d01 | |||
| d21fe49ce2 | |||
| 5017e7ce9e | |||
| 9da0be6d36 | |||
| c41bdb951c | |||
| 54815ea9c7 | |||
| 679ffed0ea | |||
| 09397cf3de | |||
| de25763a74 | |||
| a894ceb9cf | |||
| 387e58a714 |
@@ -47,7 +47,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@@ -73,6 +73,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
@@ -22,7 +22,7 @@ add_user() {
|
||||
declare -r descr="${4:-No description}"
|
||||
declare -r shell="${5:-/bin/false}"
|
||||
|
||||
if ! getent passwd | grep -q "^$user:"; then
|
||||
if ! getent passwd "$user" 2>&1 >/dev/null; then
|
||||
echo "Creating system user: $user in $group with $descr and shell $shell"
|
||||
useradd $uid_flags --gid $group --no-create-home --system --shell $shell -c "$descr" $user
|
||||
fi
|
||||
@@ -39,7 +39,7 @@ add_group() {
|
||||
declare -r gid_flags="--gid $gid"
|
||||
fi
|
||||
|
||||
if ! getent group | grep -q "^$group:" ; then
|
||||
if ! getent group "$group" 2>&1 >/dev/null; then
|
||||
echo "Creating system group: $group"
|
||||
groupadd $gid_flags --system $group
|
||||
fi
|
||||
|
||||
@@ -196,6 +196,7 @@ export default {
|
||||
requestBatchQuickEmbed() {
|
||||
const payload = {
|
||||
message: this.$strings.MessageConfirmQuickEmbed,
|
||||
allowHtml: true,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.$axios
|
||||
|
||||
@@ -300,6 +300,8 @@ export default {
|
||||
})
|
||||
},
|
||||
userUpdated(user) {
|
||||
if (user.id !== this.$store.state.user.user.id) return
|
||||
|
||||
if (user.seriesHideFromContinueListening && user.seriesHideFromContinueListening.length) {
|
||||
this.removeAllSeriesFromContinueSeries(user.seriesHideFromContinueListening)
|
||||
}
|
||||
|
||||
@@ -93,10 +93,10 @@ export default {
|
||||
editAuthor(author) {
|
||||
this.$store.commit('globals/showEditAuthorModal', author)
|
||||
},
|
||||
editItem(libraryItem) {
|
||||
editItem(libraryItem, tab = 'details') {
|
||||
var itemIds = this.shelf.entities.map((e) => e.id)
|
||||
this.$store.commit('setBookshelfBookIds', itemIds)
|
||||
this.$store.commit('showEditModal', libraryItem)
|
||||
this.$store.commit('showEditModalOnTab', { libraryItem, tab: tab || 'details' })
|
||||
},
|
||||
editEpisode({ libraryItem, episode }) {
|
||||
this.$store.commit('setEpisodeTableEpisodeIds', [episode.id])
|
||||
|
||||
@@ -3,24 +3,18 @@
|
||||
<div class="flex md:hidden h-10 items-center">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}`" class="grow h-full flex justify-center items-center" :class="isHomePage ? 'bg-primary/80' : 'bg-primary/40'">
|
||||
<p v-if="isHomePage || isPodcastLibrary" class="text-sm">{{ $strings.ButtonHome }}</p>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
<span v-else class="material-symbols text-lg">home</span>
|
||||
</nuxt-link>
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="grow h-full flex justify-center items-center" :class="isLibraryPage ? 'bg-primary/80' : 'bg-primary/40'">
|
||||
<p v-if="isLibraryPage || isPodcastLibrary" class="text-sm">{{ $strings.ButtonLibrary }}</p>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
<span v-else class="material-symbols text-lg">import_contacts</span>
|
||||
</nuxt-link>
|
||||
<nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" class="grow h-full flex justify-center items-center" :class="isPodcastLatestPage ? 'bg-primary/80' : 'bg-primary/40'">
|
||||
<p class="text-sm">{{ $strings.ButtonLatest }}</p>
|
||||
</nuxt-link>
|
||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="grow h-full flex justify-center items-center" :class="isSeriesPage ? 'bg-primary/80' : 'bg-primary/40'">
|
||||
<p v-if="isSeriesPage" class="text-sm">{{ $strings.ButtonSeries }}</p>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
||||
</svg>
|
||||
<span v-else class="material-symbols text-lg">view_column</span>
|
||||
</nuxt-link>
|
||||
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="grow h-full flex justify-center items-center" :class="isPlaylistsPage ? 'bg-primary/80' : 'bg-primary/40'">
|
||||
<p v-if="isPlaylistsPage || isPodcastLibrary" class="text-sm">{{ $strings.ButtonPlaylists }}</p>
|
||||
@@ -32,12 +26,7 @@
|
||||
</nuxt-link>
|
||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/authors`" class="grow h-full flex justify-center items-center" :class="isAuthorsPage ? 'bg-primary/80' : 'bg-primary/40'">
|
||||
<p v-if="isAuthorsPage" class="text-sm">{{ $strings.ButtonAuthors }}</p>
|
||||
<svg v-else class="w-5 h-5" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z"
|
||||
/>
|
||||
</svg>
|
||||
<span v-else class="material-symbols text-lg">groups</span>
|
||||
</nuxt-link>
|
||||
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="grow h-full flex justify-center items-center" :class="isPodcastSearchPage ? 'bg-primary/80' : 'bg-primary/40'">
|
||||
<p class="text-sm">{{ $strings.ButtonAdd }}</p>
|
||||
|
||||
@@ -70,6 +70,11 @@ export default {
|
||||
title: this.$strings.HeaderUsers,
|
||||
path: '/config/users'
|
||||
},
|
||||
{
|
||||
id: 'config-api-keys',
|
||||
title: this.$strings.HeaderApiKeys,
|
||||
path: '/config/api-keys'
|
||||
},
|
||||
{
|
||||
id: 'config-sessions',
|
||||
title: this.$strings.HeaderListeningSessions,
|
||||
|
||||
@@ -232,11 +232,11 @@ export default {
|
||||
clearFilter() {
|
||||
this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' })
|
||||
},
|
||||
editEntity(entity) {
|
||||
editEntity(entity, tab = 'details') {
|
||||
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
||||
const bookIds = this.entities.map((e) => e.id)
|
||||
this.$store.commit('setBookshelfBookIds', bookIds)
|
||||
this.$store.commit('showEditModal', entity)
|
||||
this.$store.commit('showEditModalOnTab', { libraryItem: entity, tab: tab || 'details' })
|
||||
} else if (this.entityName === 'collections') {
|
||||
this.$store.commit('globals/setEditCollection', entity)
|
||||
} else if (this.entityName === 'playlists') {
|
||||
@@ -778,10 +778,6 @@ export default {
|
||||
windowResize() {
|
||||
this.executeRebuild()
|
||||
},
|
||||
socketInit() {
|
||||
// Server settings are set on socket init
|
||||
this.executeRebuild()
|
||||
},
|
||||
initListeners() {
|
||||
window.addEventListener('resize', this.windowResize)
|
||||
|
||||
@@ -794,7 +790,6 @@ export default {
|
||||
})
|
||||
|
||||
this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)
|
||||
this.$eventBus.$on('socket_init', this.socketInit)
|
||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||
|
||||
if (this.$root.socket) {
|
||||
@@ -826,7 +821,6 @@ export default {
|
||||
}
|
||||
|
||||
this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)
|
||||
this.$eventBus.$off('socket_init', this.socketInit)
|
||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||
|
||||
if (this.$root.socket) {
|
||||
|
||||
@@ -5,9 +5,7 @@
|
||||
|
||||
<div id="siderail-buttons-container" role="navigation" aria-label="Library Navigation" :class="{ 'player-open': streamLibraryItem }" class="w-full overflow-y-auto overflow-x-hidden">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary/80' : 'bg-bg/60'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
<span class="material-symbols text-2xl">home</span>
|
||||
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonHome }}</p>
|
||||
|
||||
@@ -23,9 +21,7 @@
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="showLibrary ? 'bg-primary/80' : 'bg-bg/60'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
<span class="material-symbols text-2xl">import_contacts</span>
|
||||
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLibrary }}</p>
|
||||
|
||||
@@ -33,9 +29,7 @@
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="isSeriesPage ? 'bg-primary/80' : 'bg-bg/60'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
||||
</svg>
|
||||
<span class="material-symbols text-2xl">view_column</span>
|
||||
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSeries }}</p>
|
||||
|
||||
@@ -59,12 +53,7 @@
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary/80' : 'bg-bg/60'">
|
||||
<svg class="w-6 h-6" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="material-symbols text-2xl">groups</span>
|
||||
|
||||
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAuthors }}</p>
|
||||
|
||||
@@ -116,7 +105,7 @@
|
||||
</div>
|
||||
|
||||
<div class="w-full h-12 px-1 py-2 border-t border-black/20 bg-bg absolute left-0" :style="{ bottom: streamLibraryItem ? '224px' : '65px' }">
|
||||
<p class="underline font-mono text-xs text-center text-gray-300 leading-3 mb-1" @click="clickChangelog">v{{ $config.version }}</p>
|
||||
<p class="underline font-mono text-xs text-center text-gray-300 leading-3 mb-1 cursor-pointer" @click="clickChangelog">v{{ $config.version }}</p>
|
||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xxs text-center block leading-3">Update</a>
|
||||
<p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p>
|
||||
</div>
|
||||
|
||||
@@ -71,9 +71,6 @@ export default {
|
||||
coverHeight() {
|
||||
return this.cardHeight
|
||||
},
|
||||
userToken() {
|
||||
return this.store.getters['user/getToken']
|
||||
},
|
||||
_author() {
|
||||
return this.author || {}
|
||||
},
|
||||
|
||||
@@ -13,9 +13,17 @@
|
||||
<div class="grow" />
|
||||
<p class="text-sm md:text-base">{{ book.publishedYear }}</p>
|
||||
</div>
|
||||
<p v-if="book.author" class="text-gray-300 text-xs md:text-sm">{{ $getString('LabelByAuthor', [book.author]) }}</p>
|
||||
<p v-if="book.narrator" class="text-gray-400 text-xs">{{ $strings.LabelNarrators }}: {{ book.narrator }}</p>
|
||||
<p v-if="book.duration" class="text-gray-400 text-xs">{{ $strings.LabelDuration }}: {{ $elapsedPrettyExtended(bookDuration, false) }} {{ bookDurationComparison }}</p>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<p v-if="book.author" class="text-gray-300 text-xs md:text-sm">{{ $getString('LabelByAuthor', [book.author]) }}</p>
|
||||
<p v-if="book.narrator" class="text-gray-400 text-xs">{{ $strings.LabelNarrators }}: {{ book.narrator }}</p>
|
||||
<p v-if="book.duration" class="text-gray-400 text-xs">{{ $strings.LabelDuration }}: {{ $elapsedPrettyExtended(bookDuration, false) }} {{ bookDurationComparison }}</p>
|
||||
</div>
|
||||
<div class="grow" />
|
||||
<div v-if="book.matchConfidence" class="rounded-full px-2 py-1 text-xs whitespace-nowrap text-white" :class="book.matchConfidence > 0.95 ? 'bg-success/80' : 'bg-info/80'">{{ $strings.LabelMatchConfidence }}: {{ (book.matchConfidence * 100).toFixed(0) }}%</div>
|
||||
</div>
|
||||
|
||||
<div v-if="book.series?.length" class="flex py-1 -mx-1">
|
||||
<div v-for="(series, index) in book.series" :key="index" class="bg-white/10 rounded-full px-1 py-0.5 mx-1">
|
||||
<p class="leading-3 text-xs text-gray-400">
|
||||
|
||||
@@ -62,7 +62,24 @@
|
||||
</widgets-alert>
|
||||
|
||||
<div v-if="isNonInteractable" class="absolute top-0 left-0 w-full h-full bg-black/50 flex items-center justify-center z-20">
|
||||
<ui-loading-indicator :text="nonInteractionLabel" />
|
||||
<ui-loading-indicator>
|
||||
<div class="mb-4">
|
||||
<span class="text-lg font-medium text-white">
|
||||
{{ nonInteractionLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="isUploading" class="w-64 mx-auto mb-2">
|
||||
<div class="flex items-center justify-center mb-2">
|
||||
<span class="text-sm font-medium text-white/60 text-center w-full">
|
||||
{{ uploadProgressText }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-primary/20 rounded-full h-2.5">
|
||||
<div class="bg-green-500 h-2.5 rounded-full transition-all duration-300 ease-out" :style="{ width: uploadProgressPercent + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</ui-loading-indicator>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -91,7 +108,11 @@ export default {
|
||||
isUploading: false,
|
||||
uploadFailed: false,
|
||||
uploadSuccess: false,
|
||||
isFetchingMetadata: false
|
||||
isFetchingMetadata: false,
|
||||
uploadProgress: {
|
||||
loaded: 0,
|
||||
total: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -116,6 +137,15 @@ export default {
|
||||
} else if (this.isFetchingMetadata) {
|
||||
return this.$strings.LabelFetchingMetadata
|
||||
}
|
||||
},
|
||||
uploadProgressPercent() {
|
||||
if (this.uploadProgress.total === 0) return 0
|
||||
return Math.min(100, Math.round((this.uploadProgress.loaded / this.uploadProgress.total) * 100))
|
||||
},
|
||||
uploadProgressText() {
|
||||
const loaded = this.$bytesPretty(this.uploadProgress.loaded)
|
||||
const total = this.$bytesPretty(this.uploadProgress.total)
|
||||
return `${this.uploadProgressPercent}% (${loaded} / ${total})`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -123,6 +153,21 @@ export default {
|
||||
this.isUploading = status === 'uploading'
|
||||
this.uploadFailed = status === 'failed'
|
||||
this.uploadSuccess = status === 'success'
|
||||
|
||||
if (status !== 'uploading') {
|
||||
this.uploadProgress = {
|
||||
loaded: 0,
|
||||
total: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
setUploadProgress(progress) {
|
||||
if (this.isUploading && progress) {
|
||||
this.uploadProgress = {
|
||||
loaded: progress.loaded || 0,
|
||||
total: progress.total || 0
|
||||
}
|
||||
}
|
||||
},
|
||||
titleUpdated() {
|
||||
this.error = ''
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Error widget -->
|
||||
<ui-tooltip cy-id="ErrorTooltip" v-if="showError" :text="errorText" class="absolute bottom-4e left-0 z-10">
|
||||
<ui-tooltip cy-id="ErrorTooltip" v-if="showError" :text="errorText" plaintext class="absolute bottom-4e left-0 z-10">
|
||||
<div :style="{ height: 1.5 + 'em', width: 2.5 + 'em' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
|
||||
<span class="material-symbols text-red-100 pr-1e" :style="{ fontSize: 0.875 + 'em' }">priority_high</span>
|
||||
</div>
|
||||
@@ -101,7 +101,8 @@
|
||||
<!-- Podcast Episode # -->
|
||||
<div cy-id="podcastEpisodeNumber" v-if="recentEpisodeNumber !== null && !isHovering && !isSelectionMode && !processing" class="absolute rounded-lg bg-black/90 box-shadow-md z-10" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }">
|
||||
<p :style="{ fontSize: 0.8 + 'em' }">
|
||||
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
|
||||
Episode
|
||||
<span v-if="recentEpisodeNumber">#{{ recentEpisodeNumber }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -120,12 +121,12 @@
|
||||
<!-- Alternative bookshelf title/author/sort -->
|
||||
<div cy-id="detailBottom" :id="`description-area-${index}`" v-if="isAlternativeBookshelfView || isAuthorBookshelfView" dir="auto" class="relative mt-2e mb-2e left-0 z-50 w-full">
|
||||
<div :style="{ fontSize: 0.9 + 'em' }">
|
||||
<ui-tooltip v-if="displayTitle" :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
|
||||
<ui-tooltip v-if="displayTitle" :text="displayTitle" plaintext :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
|
||||
<p cy-id="title" ref="displayTitle" class="truncate">{{ displayTitle }}</p>
|
||||
<widgets-explicit-indicator cy-id="explicitIndicator" v-if="isExplicit" />
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<ui-tooltip v-if="showSubtitles" :text="displaySubtitle" :disabled="!displaySubtitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
|
||||
<ui-tooltip v-if="showSubtitles" :text="displaySubtitle" plaintext :disabled="!displaySubtitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
|
||||
<p cy-id="subtitle" class="truncate" ref="displaySubtitle" :style="{ fontSize: 0.6 + 'em' }">{{ displaySubtitle }}</p>
|
||||
</ui-tooltip>
|
||||
<p cy-id="line2" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displayLineTwo || ' ' }}</p>
|
||||
@@ -198,7 +199,10 @@ export default {
|
||||
return this.store.getters['user/getSizeMultiplier']
|
||||
},
|
||||
dateFormat() {
|
||||
return this.store.state.serverSettings.dateFormat
|
||||
return this.store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
timeFormat() {
|
||||
return this.store.getters['getServerSetting']('timeFormat')
|
||||
},
|
||||
_libraryItem() {
|
||||
return this.libraryItem || {}
|
||||
@@ -345,6 +349,18 @@ export default {
|
||||
if (this.mediaMetadata.publishedYear) return this.$getString('LabelPublishedDate', [this.mediaMetadata.publishedYear])
|
||||
return '\u00A0'
|
||||
}
|
||||
if (this.orderBy === 'progress') {
|
||||
if (!this.userProgressLastUpdated) return '\u00A0'
|
||||
return this.$getString('LabelLastProgressDate', [this.$formatDatetime(this.userProgressLastUpdated, this.dateFormat, this.timeFormat)])
|
||||
}
|
||||
if (this.orderBy === 'progress.createdAt') {
|
||||
if (!this.userProgressStartedDate) return '\u00A0'
|
||||
return this.$getString('LabelStartedDate', [this.$formatDatetime(this.userProgressStartedDate, this.dateFormat, this.timeFormat)])
|
||||
}
|
||||
if (this.orderBy === 'progress.finishedAt') {
|
||||
if (!this.userProgressFinishedDate) return '\u00A0'
|
||||
return this.$getString('LabelFinishedDate', [this.$formatDatetime(this.userProgressFinishedDate, this.dateFormat, this.timeFormat)])
|
||||
}
|
||||
return null
|
||||
},
|
||||
episodeProgress() {
|
||||
@@ -377,6 +393,18 @@ export default {
|
||||
let progressPercent = this.itemIsFinished ? 1 : this.booksInSeries ? this.seriesProgressPercent : this.useEBookProgress ? this.userProgress?.ebookProgress || 0 : this.userProgress?.progress || 0
|
||||
return Math.max(Math.min(1, progressPercent), 0)
|
||||
},
|
||||
userProgressLastUpdated() {
|
||||
if (!this.userProgress) return null
|
||||
return this.userProgress.lastUpdate
|
||||
},
|
||||
userProgressStartedDate() {
|
||||
if (!this.userProgress) return null
|
||||
return this.userProgress.startedAt
|
||||
},
|
||||
userProgressFinishedDate() {
|
||||
if (!this.userProgress) return null
|
||||
return this.userProgress.finishedAt
|
||||
},
|
||||
itemIsFinished() {
|
||||
if (this.booksInSeries) return this.seriesIsFinished
|
||||
return this.userProgress ? !!this.userProgress.isFinished : false
|
||||
@@ -760,11 +788,11 @@ export default {
|
||||
},
|
||||
showEditModalFiles() {
|
||||
// More menu func
|
||||
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'files' })
|
||||
this.$emit('edit', this.libraryItem, 'files')
|
||||
},
|
||||
showEditModalMatch() {
|
||||
// More menu func
|
||||
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' })
|
||||
this.$emit('edit', this.libraryItem, 'match')
|
||||
},
|
||||
sendToDevice(deviceName) {
|
||||
// More menu func
|
||||
|
||||
@@ -71,7 +71,7 @@ export default {
|
||||
return this.height * this.sizeMultiplier
|
||||
},
|
||||
dateFormat() {
|
||||
return this.store.state.serverSettings.dateFormat
|
||||
return this.store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
labelFontSize() {
|
||||
if (this.width < 160) return 0.75
|
||||
|
||||
@@ -338,6 +338,18 @@ export default {
|
||||
const series = this.series.find((se) => se.id == decoded)
|
||||
if (series) filterValue = series.name
|
||||
}
|
||||
} else if (parts[0] === 'progress') {
|
||||
const item = this.progress.find((p) => p.id == decoded)
|
||||
if (item) filterValue = item.name
|
||||
} else if (parts[0] === 'tracks') {
|
||||
const item = this.tracks.find((t) => t.id == decoded)
|
||||
if (item) filterValue = item.name
|
||||
} else if (parts[0] === 'ebooks') {
|
||||
const item = this.ebooks.find((e) => e.id == decoded)
|
||||
if (item) filterValue = item.name
|
||||
} else if (parts[0] === 'missing') {
|
||||
const item = this.missing.find((m) => m.id == decoded)
|
||||
if (item) filterValue = item.name
|
||||
} else {
|
||||
filterValue = decoded
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black/5 overflow-auto focus:outline-hidden text-sm" role="menu">
|
||||
<ul v-show="showMenu" class="librarySortMenu absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black/5 overflow-auto focus:outline-hidden text-sm" role="menu">
|
||||
<template v-for="item in selectItems">
|
||||
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
@@ -130,6 +130,18 @@ export default {
|
||||
text: this.$strings.LabelFileModified,
|
||||
value: 'mtimeMs'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLibrarySortByProgress,
|
||||
value: 'progress'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLibrarySortByProgressStarted,
|
||||
value: 'progress.createdAt'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLibrarySortByProgressFinished,
|
||||
value: 'progress.finishedAt'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelRandomly,
|
||||
value: 'random'
|
||||
@@ -191,3 +203,9 @@ export default {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.librarySortMenu {
|
||||
max-height: calc(100vh - 125px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -39,9 +39,6 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
_author() {
|
||||
return this.author || {}
|
||||
},
|
||||
|
||||
@@ -309,9 +309,9 @@ export default {
|
||||
} else {
|
||||
console.log('Account updated', data.user)
|
||||
|
||||
if (data.user.id === this.user.id && data.user.token !== this.user.token) {
|
||||
console.log('Current user token was updated')
|
||||
this.$store.commit('user/setUserToken', data.user.token)
|
||||
if (data.user.id === this.user.id && data.user.accessToken !== this.user.accessToken) {
|
||||
console.log('Current user access token was updated')
|
||||
this.$store.commit('user/setAccessToken', data.user.accessToken)
|
||||
}
|
||||
|
||||
this.$toast.success(this.$strings.ToastAccountUpdateSuccess)
|
||||
@@ -351,9 +351,6 @@ export default {
|
||||
this.$toast.error(errMsg || 'Failed to create account')
|
||||
})
|
||||
},
|
||||
toggleActive() {
|
||||
this.newUser.isActive = !this.newUser.isActive
|
||||
},
|
||||
userTypeUpdated(type) {
|
||||
this.newUser.permissions = {
|
||||
download: type !== 'guest',
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<modals-modal ref="modal" v-model="show" name="api-key-created" :width="800" :height="'unset'" persistent>
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="min-height: 200px; max-height: 80vh">
|
||||
<div class="w-full p-8">
|
||||
<p class="text-lg text-white mb-4">{{ $getString('LabelApiKeyCreated', [apiKeyName]) }}</p>
|
||||
|
||||
<p class="text-lg text-white mb-4">{{ $strings.LabelApiKeyCreatedDescription }}</p>
|
||||
|
||||
<ui-text-input label="API Key" :value="apiKeyKey" readonly show-copy />
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<ui-btn color="bg-primary" @click="show = false">{{ $strings.ButtonClose }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
apiKey: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return this.$strings.HeaderNewApiKey
|
||||
},
|
||||
apiKeyName() {
|
||||
return this.apiKey?.name || ''
|
||||
},
|
||||
apiKeyKey() {
|
||||
return this.apiKey?.apiKey || ''
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<modals-modal ref="modal" v-model="show" name="api-key" :width="800" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="min-height: 400px; max-height: 80vh">
|
||||
<div class="w-full p-8">
|
||||
<div class="flex py-2">
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model.trim="newApiKey.name" :readonly="!isNew" :label="$strings.LabelName" />
|
||||
</div>
|
||||
<div v-if="isNew" class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model.trim="newApiKey.expiresIn" :label="$strings.LabelExpiresInSeconds" type="number" :min="0" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center pt-4 pb-2 gap-2">
|
||||
<div class="flex items-center px-2">
|
||||
<p class="px-3 font-semibold" id="user-enabled-toggle">{{ $strings.LabelEnable }}</p>
|
||||
<ui-toggle-switch :disabled="isExpired && !apiKey.isActive" labeledBy="user-enabled-toggle" v-model="newApiKey.isActive" />
|
||||
</div>
|
||||
<div v-if="isExpired" class="px-2">
|
||||
<p class="text-sm text-error">{{ $strings.LabelExpired }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full border-t border-b border-black-200 py-4 px-3 mt-4">
|
||||
<p class="text-lg mb-2 font-semibold">{{ $strings.LabelApiKeyUser }}</p>
|
||||
<p class="text-sm mb-2 text-gray-400">{{ $strings.LabelApiKeyUserDescription }}</p>
|
||||
<ui-select-input v-model="newApiKey.userId" :disabled="isExpired && !apiKey.isActive" :items="userItems" :placeholder="$strings.LabelSelectUser" :label="$strings.LabelApiKeyUser" label-hidden />
|
||||
</div>
|
||||
|
||||
<div class="flex pt-4 px-2">
|
||||
<div class="grow" />
|
||||
<ui-btn color="bg-success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
apiKey: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
users: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
newApiKey: {},
|
||||
isNew: true
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return this.isNew ? this.$strings.HeaderNewApiKey : this.$strings.HeaderUpdateApiKey
|
||||
},
|
||||
userItems() {
|
||||
return this.users
|
||||
.filter((u) => {
|
||||
// Only show root user if the current user is root
|
||||
return u.type !== 'root' || this.$store.getters['user/getIsRoot']
|
||||
})
|
||||
.map((u) => ({ text: u.username, value: u.id, subtext: u.type }))
|
||||
},
|
||||
isExpired() {
|
||||
if (!this.apiKey || !this.apiKey.expiresAt) return false
|
||||
|
||||
return new Date(this.apiKey.expiresAt).getTime() < Date.now()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submitForm() {
|
||||
if (!this.newApiKey.name) {
|
||||
this.$toast.error(this.$strings.ToastNameRequired)
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.newApiKey.userId) {
|
||||
this.$toast.error(this.$strings.ToastNewApiKeyUserError)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.isNew) {
|
||||
this.submitCreateApiKey()
|
||||
} else {
|
||||
this.submitUpdateApiKey()
|
||||
}
|
||||
},
|
||||
submitUpdateApiKey() {
|
||||
if (this.newApiKey.isActive === this.apiKey.isActive && this.newApiKey.userId === this.apiKey.userId) {
|
||||
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
|
||||
this.show = false
|
||||
return
|
||||
}
|
||||
|
||||
const apiKey = {
|
||||
isActive: this.newApiKey.isActive,
|
||||
userId: this.newApiKey.userId
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$patch(`/api/api-keys/${this.apiKey.id}`, apiKey)
|
||||
.then((data) => {
|
||||
this.processing = false
|
||||
if (data.error) {
|
||||
this.$toast.error(`${this.$strings.ToastFailedToUpdate}: ${data.error}`)
|
||||
} else {
|
||||
this.show = false
|
||||
this.$emit('updated', data.apiKey)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.processing = false
|
||||
console.error('Failed to update apiKey', error)
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
this.$toast.error(errMsg || this.$strings.ToastFailedToUpdate)
|
||||
})
|
||||
},
|
||||
submitCreateApiKey() {
|
||||
const apiKey = { ...this.newApiKey }
|
||||
|
||||
if (this.newApiKey.expiresIn) {
|
||||
apiKey.expiresIn = parseInt(this.newApiKey.expiresIn)
|
||||
} else {
|
||||
delete apiKey.expiresIn
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post('/api/api-keys', apiKey)
|
||||
.then((data) => {
|
||||
this.processing = false
|
||||
if (data.error) {
|
||||
this.$toast.error(this.$strings.ToastFailedToCreate + ': ' + data.error)
|
||||
} else {
|
||||
this.show = false
|
||||
this.$emit('created', data.apiKey)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.processing = false
|
||||
console.error('Failed to create apiKey', error)
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
this.$toast.error(errMsg || this.$strings.ToastFailedToCreate)
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.isNew = !this.apiKey
|
||||
|
||||
if (this.apiKey) {
|
||||
this.newApiKey = {
|
||||
name: this.apiKey.name,
|
||||
isActive: this.apiKey.isActive,
|
||||
userId: this.apiKey.userId
|
||||
}
|
||||
} else {
|
||||
this.newApiKey = {
|
||||
name: null,
|
||||
expiresIn: null,
|
||||
isActive: true,
|
||||
userId: null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -88,7 +88,7 @@ export default {
|
||||
},
|
||||
providers() {
|
||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||
return this.$store.state.scanners.providers
|
||||
return this.$store.state.scanners.bookProviders
|
||||
},
|
||||
libraryProvider() {
|
||||
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||
@@ -96,6 +96,9 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
// Fetch providers when modal is shown
|
||||
this.$store.dispatch('scanners/fetchProviders')
|
||||
|
||||
// If we don't have a set provider (first open of dialog) or we've switched library, set
|
||||
// the selected provider to the current library default provider
|
||||
if (!this.options.provider || this.lastUsedLibrary != this.currentLibraryId) {
|
||||
@@ -127,8 +130,7 @@ export default {
|
||||
this.show = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -79,10 +79,10 @@ export default {
|
||||
return !this.bookmarks.find((bm) => Math.abs(this.currentTime - bm.time) < 1)
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
return this.$store.getters['getServerSetting']('timeFormat')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
</div>
|
||||
<div class="w-full md:w-1/3">
|
||||
<p v-if="!isMediaItemShareSession" class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2 mt-6 md:mt-0">{{ $strings.LabelUser }}</p>
|
||||
<p v-if="!isMediaItemShareSession" class="mb-1 text-xs">{{ _session.userId }}</p>
|
||||
<p v-if="!isMediaItemShareSession" class="mb-1 text-xs">{{ username }}</p>
|
||||
|
||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelMediaPlayer }}</p>
|
||||
<p class="mb-1">{{ playMethodName }}</p>
|
||||
@@ -132,6 +132,9 @@ export default {
|
||||
_session() {
|
||||
return this.session || {}
|
||||
},
|
||||
username() {
|
||||
return this._session.user?.username || this._session.userId || ''
|
||||
},
|
||||
deviceInfo() {
|
||||
return this._session.deviceInfo || {}
|
||||
},
|
||||
@@ -159,10 +162,10 @@ export default {
|
||||
return 'Unknown'
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
return this.$store.getters['getServerSetting']('timeFormat')
|
||||
},
|
||||
isOpenSession() {
|
||||
return !!this._session.open
|
||||
|
||||
@@ -23,7 +23,7 @@ export default {
|
||||
processing: Boolean,
|
||||
persistent: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
default: false
|
||||
},
|
||||
width: {
|
||||
type: [String, Number],
|
||||
@@ -99,7 +99,7 @@ export default {
|
||||
this.preventClickoutside = false
|
||||
return
|
||||
}
|
||||
if (this.processing && this.persistent) return
|
||||
if (this.processing || this.persistent) return
|
||||
if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) {
|
||||
this.show = false
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ export default {
|
||||
expirationDateString() {
|
||||
if (!this.expireDurationSeconds) return this.$strings.LabelPermanent
|
||||
const dateMs = Date.now() + this.expireDurationSeconds * 1000
|
||||
return this.$formatDatetime(dateMs, this.$store.state.serverSettings.dateFormat, this.$store.state.serverSettings.timeFormat)
|
||||
return this.$formatDatetime(dateMs, this.$store.getters['getServerSetting']('dateFormat'), this.$store.getters['getServerSetting']('timeFormat'))
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -40,7 +40,7 @@ export default {
|
||||
}
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
releasesToShow() {
|
||||
return this.versionData?.releasesToShow || []
|
||||
|
||||
@@ -227,7 +227,7 @@ export default {
|
||||
.catch((error) => {
|
||||
console.error('Failed to create collection', error)
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
this.$toast.error(this.$strings.ToastCollectionCreateFailed + ': ' + errMsg)
|
||||
this.$toast.error(errMsg)
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -51,19 +51,21 @@
|
||||
<form @submit.prevent="submitSearchForm">
|
||||
<div class="flex flex-wrap sm:flex-nowrap items-center justify-start -mx-1">
|
||||
<div class="w-48 grow p-1">
|
||||
<ui-dropdown v-model="provider" :items="providers" :label="$strings.LabelProvider" small />
|
||||
<ui-dropdown v-model="provider" :items="providers" :disabled="searchInProgress" :label="$strings.LabelProvider" small />
|
||||
</div>
|
||||
<div class="w-72 grow p-1">
|
||||
<ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" :placeholder="$strings.PlaceholderSearch" />
|
||||
<ui-text-input-with-label v-model="searchTitle" :disabled="searchInProgress" :label="searchTitleLabel" :placeholder="$strings.PlaceholderSearch" />
|
||||
</div>
|
||||
<div v-show="provider != 'itunes' && provider != 'audiobookcovers'" class="w-72 grow p-1">
|
||||
<ui-text-input-with-label v-model="searchAuthor" :label="$strings.LabelAuthor" />
|
||||
<ui-text-input-with-label v-model="searchAuthor" :disabled="searchInProgress" :label="$strings.LabelAuthor" />
|
||||
</div>
|
||||
<ui-btn class="mt-5 ml-1 md:min-w-24" :padding-x="4" type="submit">{{ $strings.ButtonSearch }}</ui-btn>
|
||||
<ui-btn v-if="!searchInProgress" class="mt-5 ml-1 md:min-w-24" :padding-x="4" type="submit">{{ $strings.ButtonSearch }}</ui-btn>
|
||||
<ui-btn v-else class="mt-5 ml-1 md:min-w-24" :padding-x="4" type="button" color="bg-error" @click.prevent="cancelCurrentSearch">{{ $strings.ButtonCancel }}</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center sm:max-h-80 sm:overflow-y-scroll mt-2 max-w-full">
|
||||
<p v-if="!coversFound.length">{{ $strings.MessageNoCoversFound }}</p>
|
||||
<p v-if="searchInProgress && !coversFound.length" class="text-gray-300 py-4">{{ $strings.MessageLoading }}</p>
|
||||
<p v-else-if="!searchInProgress && !coversFound.length" class="text-gray-300 py-4">{{ $strings.MessageNoCoversFound }}</p>
|
||||
<template v-for="cover in coversFound">
|
||||
<div :key="cover" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === coverPath ? 'border-yellow-300' : ''" @click="updateCover(cover)">
|
||||
<covers-preview-cover :src="cover" :width="80" show-open-new-tab :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
@@ -105,7 +107,10 @@ export default {
|
||||
showLocalCovers: false,
|
||||
previewUpload: null,
|
||||
selectedFile: null,
|
||||
provider: 'google'
|
||||
provider: 'google',
|
||||
currentSearchRequestId: null,
|
||||
searchInProgress: false,
|
||||
socketListenersActive: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -128,8 +133,8 @@ export default {
|
||||
}
|
||||
},
|
||||
providers() {
|
||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||
return [{ text: 'All', value: 'all' }, ...this.$store.state.scanners.providers, ...this.$store.state.scanners.coverOnlyProviders]
|
||||
if (this.isPodcast) return this.$store.state.scanners.podcastCoverProviders
|
||||
return this.$store.state.scanners.bookCoverProviders
|
||||
},
|
||||
searchTitleLabel() {
|
||||
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
|
||||
@@ -186,6 +191,9 @@ export default {
|
||||
_file.localPath = `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${file.ino}?token=${this.userToken}`
|
||||
return _file
|
||||
})
|
||||
},
|
||||
socket() {
|
||||
return this.$root.socket
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -235,7 +243,19 @@ export default {
|
||||
this.searchTitle = this.mediaMetadata.title || ''
|
||||
this.searchAuthor = this.mediaMetadata.authorName || ''
|
||||
if (this.isPodcast) this.provider = 'itunes'
|
||||
else this.provider = localStorage.getItem('book-cover-provider') || localStorage.getItem('book-provider') || 'google'
|
||||
else {
|
||||
// Migrate from 'all' to 'best' (only once)
|
||||
const migrationKey = 'book-cover-provider-migrated'
|
||||
const currentProvider = localStorage.getItem('book-cover-provider') || localStorage.getItem('book-provider') || 'google'
|
||||
|
||||
if (!localStorage.getItem(migrationKey) && currentProvider === 'all') {
|
||||
localStorage.setItem('book-cover-provider', 'best')
|
||||
localStorage.setItem(migrationKey, 'true')
|
||||
this.provider = 'best'
|
||||
} else {
|
||||
this.provider = currentProvider
|
||||
}
|
||||
}
|
||||
},
|
||||
removeCover() {
|
||||
if (!this.coverPath) {
|
||||
@@ -291,22 +311,116 @@ export default {
|
||||
console.error('PersistProvider', error)
|
||||
}
|
||||
},
|
||||
generateRequestId() {
|
||||
return `cover-search-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
},
|
||||
addSocketListeners() {
|
||||
if (!this.socket || this.socketListenersActive) return
|
||||
|
||||
this.socket.on('cover_search_result', this.handleSearchResult)
|
||||
this.socket.on('cover_search_complete', this.handleSearchComplete)
|
||||
this.socket.on('cover_search_error', this.handleSearchError)
|
||||
this.socket.on('cover_search_provider_error', this.handleProviderError)
|
||||
this.socket.on('cover_search_cancelled', this.handleSearchCancelled)
|
||||
this.socket.on('disconnect', this.handleSocketDisconnect)
|
||||
this.socketListenersActive = true
|
||||
},
|
||||
removeSocketListeners() {
|
||||
if (!this.socket || !this.socketListenersActive) return
|
||||
|
||||
this.socket.off('cover_search_result', this.handleSearchResult)
|
||||
this.socket.off('cover_search_complete', this.handleSearchComplete)
|
||||
this.socket.off('cover_search_error', this.handleSearchError)
|
||||
this.socket.off('cover_search_provider_error', this.handleProviderError)
|
||||
this.socket.off('cover_search_cancelled', this.handleSearchCancelled)
|
||||
this.socket.off('disconnect', this.handleSocketDisconnect)
|
||||
this.socketListenersActive = false
|
||||
},
|
||||
handleSearchResult(data) {
|
||||
if (data.requestId !== this.currentSearchRequestId) return
|
||||
|
||||
// Add new covers to the list (avoiding duplicates)
|
||||
const newCovers = data.covers.filter((cover) => !this.coversFound.includes(cover))
|
||||
this.coversFound.push(...newCovers)
|
||||
},
|
||||
handleSearchComplete(data) {
|
||||
if (data.requestId !== this.currentSearchRequestId) return
|
||||
|
||||
this.searchInProgress = false
|
||||
this.currentSearchRequestId = null
|
||||
},
|
||||
handleSearchError(data) {
|
||||
if (data.requestId !== this.currentSearchRequestId) return
|
||||
|
||||
console.error('[Cover Search] Search error:', data.error)
|
||||
this.$toast.error(this.$strings.ToastCoverSearchFailed)
|
||||
this.searchInProgress = false
|
||||
this.currentSearchRequestId = null
|
||||
},
|
||||
handleProviderError(data) {
|
||||
if (data.requestId !== this.currentSearchRequestId) return
|
||||
|
||||
console.warn(`[Cover Search] Provider ${data.provider} failed:`, data.error)
|
||||
},
|
||||
handleSearchCancelled(data) {
|
||||
if (data.requestId !== this.currentSearchRequestId) return
|
||||
|
||||
this.searchInProgress = false
|
||||
this.currentSearchRequestId = null
|
||||
},
|
||||
handleSocketDisconnect() {
|
||||
// If we were in the middle of a search, cancel it (server can't send results anymore)
|
||||
if (this.searchInProgress && this.currentSearchRequestId) {
|
||||
this.searchInProgress = false
|
||||
this.currentSearchRequestId = null
|
||||
}
|
||||
},
|
||||
cancelCurrentSearch() {
|
||||
if (!this.currentSearchRequestId || !this.socket?.connected) {
|
||||
console.error('[Cover Search] Socket not connected')
|
||||
this.$toast.error(this.$strings.ToastConnectionNotAvailable)
|
||||
return
|
||||
}
|
||||
|
||||
this.socket.emit('cancel_cover_search', this.currentSearchRequestId)
|
||||
this.currentSearchRequestId = null
|
||||
this.searchInProgress = false
|
||||
},
|
||||
async submitSearchForm() {
|
||||
if (!this.socket?.connected) {
|
||||
console.error('[Cover Search] Socket not connected')
|
||||
this.$toast.error(this.$strings.ToastConnectionNotAvailable)
|
||||
return
|
||||
}
|
||||
|
||||
// Cancel any existing search
|
||||
if (this.searchInProgress) {
|
||||
this.cancelCurrentSearch()
|
||||
}
|
||||
|
||||
// Store provider in local storage
|
||||
this.persistProvider()
|
||||
|
||||
this.isProcessing = true
|
||||
const searchQuery = this.getSearchQuery()
|
||||
const results = await this.$axios
|
||||
.$get(`/api/search/covers?${searchQuery}`)
|
||||
.then((res) => res.results)
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return []
|
||||
})
|
||||
this.coversFound = results
|
||||
this.isProcessing = false
|
||||
// Setup socket listeners if not already done
|
||||
this.addSocketListeners()
|
||||
|
||||
// Clear previous results
|
||||
this.coversFound = []
|
||||
this.hasSearched = true
|
||||
this.searchInProgress = true
|
||||
|
||||
// Generate unique request ID
|
||||
const requestId = this.generateRequestId()
|
||||
this.currentSearchRequestId = requestId
|
||||
|
||||
// Emit search request via WebSocket
|
||||
this.socket.emit('search_covers', {
|
||||
requestId,
|
||||
title: this.searchTitle,
|
||||
author: this.searchAuthor || '',
|
||||
provider: this.provider,
|
||||
podcast: this.isPodcast
|
||||
})
|
||||
},
|
||||
setCover(coverFile) {
|
||||
this.isProcessing = true
|
||||
@@ -320,6 +434,20 @@ export default {
|
||||
this.isProcessing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// Setup socket listeners when component is mounted
|
||||
this.addSocketListeners()
|
||||
// Fetch providers if not already loaded
|
||||
this.$store.dispatch('scanners/fetchProviders')
|
||||
},
|
||||
beforeDestroy() {
|
||||
// Cancel any ongoing search when component is destroyed
|
||||
if (this.searchInProgress) {
|
||||
this.cancelCurrentSearch()
|
||||
}
|
||||
// Remove socket listeners
|
||||
this.removeSocketListeners()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -29,9 +29,6 @@ export default {
|
||||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div id="match-wrapper" class="w-full h-full overflow-hidden px-2 md:px-4 py-4 md:py-6 relative">
|
||||
<form @submit.prevent="submitSearch">
|
||||
<div class="flex flex-wrap md:flex-nowrap items-center justify-start -mx-1">
|
||||
<div class="w-36 px-1">
|
||||
<div v-if="providersLoaded" class="w-36 px-1">
|
||||
<ui-dropdown v-model="provider" :items="providers" :label="$strings.LabelProvider" small />
|
||||
</div>
|
||||
<div class="grow md:w-72 px-1">
|
||||
@@ -77,8 +77,8 @@
|
||||
<ui-checkbox v-model="selectedMatchUsage.author" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.author" :disabled="!selectedMatchUsage.author" :label="$strings.LabelAuthor" />
|
||||
<p v-if="mediaMetadata.authorName" class="text-xs ml-1 text-white/60">
|
||||
{{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('author', mediaMetadata.authorName)">{{ mediaMetadata.authorName }}</a>
|
||||
<p v-if="mediaMetadata.authorName || (isPodcast && mediaMetadata.author)" class="text-xs ml-1 text-white/60">
|
||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('author', isPodcast ? mediaMetadata.author : mediaMetadata.authorName)">{{ isPodcast ? mediaMetadata.author : mediaMetadata.authorName }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,7 +87,7 @@
|
||||
<div class="grow ml-4">
|
||||
<ui-multi-select v-model="selectedMatch.narrator" :items="narrators" :disabled="!selectedMatchUsage.narrator" :label="$strings.LabelNarrators" />
|
||||
<p v-if="mediaMetadata.narratorName" class="text-xs ml-1 text-white/60">
|
||||
{{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('narrator', mediaMetadata.narrators)">{{ mediaMetadata.narratorName }}</a>
|
||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('narrator', mediaMetadata.narrators)">{{ mediaMetadata.narratorName }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,7 +96,7 @@
|
||||
<div class="grow ml-4">
|
||||
<ui-rich-text-editor v-model="selectedMatch.description" :disabled="!selectedMatchUsage.description" :label="$strings.LabelDescription" />
|
||||
<p v-if="mediaMetadata.description" class="text-xs ml-1 text-white/60">
|
||||
{{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('description', mediaMetadata.description)">{{ mediaMetadata.descriptionPlain.substr(0, 100) + (mediaMetadata.descriptionPlain.length > 100 ? '...' : '') }}</a>
|
||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('description', mediaMetadata.description)">{{ mediaMetadata.descriptionPlain.substr(0, 100) + (mediaMetadata.descriptionPlain.length > 100 ? '...' : '') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,7 +105,7 @@
|
||||
<div class="grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.publisher" :disabled="!selectedMatchUsage.publisher" :label="$strings.LabelPublisher" />
|
||||
<p v-if="mediaMetadata.publisher" class="text-xs ml-1 text-white/60">
|
||||
{{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('publisher', mediaMetadata.publisher)">{{ mediaMetadata.publisher }}</a>
|
||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('publisher', mediaMetadata.publisher)">{{ mediaMetadata.publisher }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,7 +114,7 @@
|
||||
<div class="grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.publishedYear" :disabled="!selectedMatchUsage.publishedYear" :label="$strings.LabelPublishYear" />
|
||||
<p v-if="mediaMetadata.publishedYear" class="text-xs ml-1 text-white/60">
|
||||
{{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('publishedYear', mediaMetadata.publishedYear)">{{ mediaMetadata.publishedYear }}</a>
|
||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('publishedYear', mediaMetadata.publishedYear)">{{ mediaMetadata.publishedYear }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -253,6 +253,7 @@ export default {
|
||||
hasSearched: false,
|
||||
selectedMatch: null,
|
||||
selectedMatchOrig: null,
|
||||
waitingForProviders: false,
|
||||
selectedMatchUsage: {
|
||||
title: true,
|
||||
subtitle: true,
|
||||
@@ -285,9 +286,19 @@ export default {
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
},
|
||||
providersLoaded(isLoaded) {
|
||||
// Complete initialization once providers are loaded
|
||||
if (isLoaded && this.waitingForProviders) {
|
||||
this.waitingForProviders = false
|
||||
this.initProviderAndSearch()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
providersLoaded() {
|
||||
return this.$store.getters['scanners/areProvidersLoaded']
|
||||
},
|
||||
isProcessing: {
|
||||
get() {
|
||||
return this.processing
|
||||
@@ -319,7 +330,7 @@ export default {
|
||||
},
|
||||
providers() {
|
||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||
return this.$store.state.scanners.providers
|
||||
return this.$store.state.scanners.bookProviders
|
||||
},
|
||||
searchTitleLabel() {
|
||||
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
|
||||
@@ -400,7 +411,9 @@ export default {
|
||||
this.$toast.warning(this.$strings.ToastTitleRequired)
|
||||
return
|
||||
}
|
||||
this.persistProvider()
|
||||
if (!this.isPodcast) {
|
||||
this.persistProvider()
|
||||
}
|
||||
this.runSearch()
|
||||
},
|
||||
async runSearch() {
|
||||
@@ -476,6 +489,24 @@ export default {
|
||||
|
||||
this.checkboxToggled()
|
||||
},
|
||||
initProviderAndSearch() {
|
||||
// Set provider based on media type
|
||||
if (this.isPodcast) {
|
||||
this.provider = 'itunes'
|
||||
} else {
|
||||
this.provider = this.getDefaultBookProvider()
|
||||
}
|
||||
|
||||
// Prefer using ASIN if set and using audible provider
|
||||
if (this.provider.startsWith('audible') && this.libraryItem.media.metadata.asin) {
|
||||
this.searchTitle = this.libraryItem.media.metadata.asin
|
||||
this.searchAuthor = ''
|
||||
}
|
||||
|
||||
if (this.searchTitle) {
|
||||
this.submitSearch()
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.clearSelectedMatch()
|
||||
this.initSelectedMatchUsage()
|
||||
@@ -493,19 +524,13 @@ export default {
|
||||
}
|
||||
this.searchTitle = this.libraryItem.media.metadata.title
|
||||
this.searchAuthor = this.libraryItem.media.metadata.authorName || ''
|
||||
if (this.isPodcast) this.provider = 'itunes'
|
||||
else {
|
||||
this.provider = this.getDefaultBookProvider()
|
||||
}
|
||||
|
||||
// Prefer using ASIN if set and using audible provider
|
||||
if (this.provider.startsWith('audible') && this.libraryItem.media.metadata.asin) {
|
||||
this.searchTitle = this.libraryItem.media.metadata.asin
|
||||
this.searchAuthor = ''
|
||||
}
|
||||
|
||||
if (this.searchTitle) {
|
||||
this.submitSearch()
|
||||
// Wait for providers to be loaded before setting provider and searching
|
||||
if (this.providersLoaded || this.isPodcast) {
|
||||
this.waitingForProviders = false
|
||||
this.initProviderAndSearch()
|
||||
} else {
|
||||
this.waitingForProviders = true
|
||||
}
|
||||
},
|
||||
selectMatch(match) {
|
||||
@@ -635,6 +660,10 @@ export default {
|
||||
this.selectedMatch = null
|
||||
this.selectedMatchOrig = null
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// Fetch providers if not already loaded
|
||||
this.$store.dispatch('scanners/fetchProviders')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -158,6 +158,8 @@ export default {
|
||||
this.isProcessing = true
|
||||
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
const errorMessage = typeof error?.response?.data === 'string' ? error?.response?.data : null
|
||||
this.$toast.error(errorMessage || this.$strings.ToastFailedToUpdate)
|
||||
return false
|
||||
})
|
||||
this.isProcessing = false
|
||||
|
||||
@@ -107,6 +107,7 @@ export default {
|
||||
quickEmbed() {
|
||||
const payload = {
|
||||
message: this.$strings.MessageConfirmQuickEmbed,
|
||||
allowHtml: true,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.$axios
|
||||
|
||||
@@ -74,7 +74,7 @@ export default {
|
||||
},
|
||||
providers() {
|
||||
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
|
||||
return this.$store.state.scanners.providers
|
||||
return this.$store.state.scanners.bookProviders
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -156,6 +156,8 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
// Fetch providers if not already loaded
|
||||
this.$store.dispatch('scanners/fetchProviders')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -104,7 +104,6 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
provider: null,
|
||||
useSquareBookCovers: false,
|
||||
enableWatcher: false,
|
||||
skipMatchingMediaWithAsin: false,
|
||||
@@ -134,10 +133,6 @@ export default {
|
||||
isPodcastLibrary() {
|
||||
return this.mediaType === 'podcast'
|
||||
},
|
||||
providers() {
|
||||
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
|
||||
return this.$store.state.scanners.providers
|
||||
},
|
||||
maskAsFinishedWhenItems() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -97,7 +97,10 @@ export default {
|
||||
...playlist
|
||||
}
|
||||
})
|
||||
.sort((a, b) => (a.isItemIncluded ? -1 : 1))
|
||||
.sort((a, b) => {
|
||||
if (a.isItemIncluded !== b.isItemIncluded) return a.isItemIncluded ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
},
|
||||
isBatch() {
|
||||
return this.selectedPlaylistItems.length > 1
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
{{ $getString('MessageConfirmRemoveEpisode', [episodeTitle]) }}
|
||||
</p>
|
||||
<p v-else class="text-lg text-gray-200 mb-4">{{ $getString('MessageConfirmRemoveEpisodes', [episodes.length]) }}</p>
|
||||
<p class="text-xs font-semibold text-warning/90">Note: This does not delete the audio file unless toggling "Hard delete file"</p>
|
||||
<p class="text-xs font-semibold text-warning/90">{{ $strings.MessageConfirmRemoveEpisodeNote }}</p>
|
||||
</div>
|
||||
<div class="flex justify-between items-center pt-4">
|
||||
<ui-checkbox v-model="hardDeleteFile" :label="$strings.LabelHardDeleteFile" check-color="error" checkbox-bg="bg" small label-class="text-base text-gray-200 pl-3" />
|
||||
@@ -94,7 +94,6 @@ export default {
|
||||
}
|
||||
|
||||
this.processing = false
|
||||
this.$toast.success(`${this.episodes.length} episode${this.episodes.length > 1 ? 's' : ''} removed`)
|
||||
this.show = false
|
||||
this.$emit('clearSelected')
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ export default {
|
||||
.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, updatePayload)
|
||||
.then(() => {
|
||||
this.isProcessing = false
|
||||
this.$toast.success('Podcast episode updated')
|
||||
this.$toast.success(this.$strings.ToastPodcastEpisodeUpdated)
|
||||
this.$emit('selectTab', 'details')
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
<ui-tooltip direction="top" :text="jumpBackwardText">
|
||||
<button :aria-label="jumpForwardText" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
|
||||
<button :aria-label="jumpBackwardText" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
|
||||
<span class="material-symbols text-2xl sm:text-3xl">replay</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
|
||||
@@ -129,9 +129,6 @@ export default {
|
||||
return `${hoursRounded}h`
|
||||
}
|
||||
},
|
||||
token() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
timeRemaining() {
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
var currChapTime = this.currentTime - this.currentChapter.start
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-linear-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
||||
<div ref="content" class="relative text-white" :style="{ height: modalHeight, width: modalWidth }" v-click-outside="clickedOutside">
|
||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
||||
<p id="confirm-prompt-message" class="text-lg mb-6 mt-2 px-1" v-html="message" />
|
||||
<p v-if="allowHtmlMessage" id="confirm-prompt-message" class="text-lg mb-6 mt-2 px-1" v-html="sanitizedMessage" />
|
||||
<p v-else id="confirm-prompt-message" class="text-lg mb-6 mt-2 px-1">{{ message }}</p>
|
||||
|
||||
<ui-checkbox v-if="checkboxLabel" v-model="checkboxValue" checkbox-bg="bg" :label="checkboxLabel" label-class="pl-2 text-base" class="mb-6 px-1" />
|
||||
|
||||
@@ -52,6 +53,17 @@ export default {
|
||||
message() {
|
||||
return this.confirmPromptOptions.message || ''
|
||||
},
|
||||
allowHtmlMessage() {
|
||||
return !!this.confirmPromptOptions.allowHtml
|
||||
},
|
||||
sanitizedMessage() {
|
||||
if (!this.allowHtmlMessage) return this.message
|
||||
|
||||
return this.escapeHtml(this.message)
|
||||
.replace(/<br\s*\/?>/gi, '<br>')
|
||||
.replace(/<code>/gi, '<code>')
|
||||
.replace(/<\/code>/gi, '</code>')
|
||||
},
|
||||
callback() {
|
||||
return this.confirmPromptOptions.callback
|
||||
},
|
||||
@@ -103,6 +115,14 @@ export default {
|
||||
if (this.callback) this.callback(true, this.checkboxValue)
|
||||
this.show = false
|
||||
},
|
||||
escapeHtml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
},
|
||||
setShow() {
|
||||
this.checkboxValue = this.checkboxDefaultValue
|
||||
this.$eventBus.$emit('showing-prompt', true)
|
||||
|
||||
@@ -104,9 +104,6 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem?.id
|
||||
},
|
||||
@@ -234,10 +231,7 @@ export default {
|
||||
async extract() {
|
||||
this.loading = true
|
||||
var buff = await this.$axios.$get(this.ebookUrl, {
|
||||
responseType: 'blob',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.userToken}`
|
||||
}
|
||||
responseType: 'blob'
|
||||
})
|
||||
const archive = await Archive.open(buff)
|
||||
const originalFilesObject = await archive.getFilesObject()
|
||||
|
||||
@@ -57,9 +57,6 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
/** @returns {string} */
|
||||
libraryItemId() {
|
||||
return this.libraryItem?.id
|
||||
@@ -97,27 +94,37 @@ export default {
|
||||
},
|
||||
ebookUrl() {
|
||||
if (this.fileId) {
|
||||
return `${this.$config.routerBasePath}/api/items/${this.libraryItemId}/ebook/${this.fileId}`
|
||||
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
|
||||
}
|
||||
return `${this.$config.routerBasePath}/api/items/${this.libraryItemId}/ebook`
|
||||
return `/api/items/${this.libraryItemId}/ebook`
|
||||
},
|
||||
themeRules() {
|
||||
const isDark = this.ereaderSettings.theme === 'dark'
|
||||
const fontColor = isDark ? '#fff' : '#000'
|
||||
const backgroundColor = isDark ? 'rgb(35 35 35)' : 'rgb(255, 255, 255)'
|
||||
const theme = this.ereaderSettings.theme
|
||||
const isDark = theme === 'dark'
|
||||
const isSepia = theme === 'sepia'
|
||||
|
||||
const fontColor = isDark
|
||||
? '#fff'
|
||||
: isSepia
|
||||
? '#5b4636'
|
||||
: '#000'
|
||||
|
||||
const backgroundColor = isDark
|
||||
? 'rgb(35 35 35)'
|
||||
: isSepia
|
||||
? 'rgb(244, 236, 216)'
|
||||
: 'rgb(255, 255, 255)'
|
||||
|
||||
const lineSpacing = this.ereaderSettings.lineSpacing / 100
|
||||
|
||||
const fontScale = this.ereaderSettings.fontScale / 100
|
||||
|
||||
const textStroke = this.ereaderSettings.textStroke / 100
|
||||
const fontScale = this.ereaderSettings.fontScale / 100
|
||||
const textStroke = this.ereaderSettings.textStroke / 100
|
||||
|
||||
return {
|
||||
'*': {
|
||||
color: `${fontColor}!important`,
|
||||
'background-color': `${backgroundColor}!important`,
|
||||
'line-height': lineSpacing * fontScale + 'rem!important',
|
||||
'-webkit-text-stroke': textStroke + 'px ' + fontColor + '!important'
|
||||
'line-height': `${lineSpacing * fontScale}rem!important`,
|
||||
'-webkit-text-stroke': `${textStroke}px ${fontColor}!important`
|
||||
},
|
||||
a: {
|
||||
color: `${fontColor}!important`
|
||||
@@ -309,14 +316,24 @@ export default {
|
||||
/** @type {EpubReader} */
|
||||
const reader = this
|
||||
|
||||
// Use axios to make request because we have token refresh logic in interceptor
|
||||
const customRequest = async (url) => {
|
||||
try {
|
||||
return this.$axios.$get(url, {
|
||||
responseType: 'arraybuffer'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('EpubReader.initEpub customRequest failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {ePub.Book} */
|
||||
reader.book = new ePub(reader.ebookUrl, {
|
||||
width: this.readerWidth,
|
||||
height: this.readerHeight - 50,
|
||||
openAs: 'epub',
|
||||
requestHeaders: {
|
||||
Authorization: `Bearer ${this.userToken}`
|
||||
}
|
||||
requestMethod: customRequest
|
||||
})
|
||||
|
||||
/** @type {ePub.Rendition} */
|
||||
@@ -337,29 +354,33 @@ export default {
|
||||
this.applyTheme()
|
||||
})
|
||||
|
||||
reader.book.ready.then(() => {
|
||||
// set up event listeners
|
||||
reader.rendition.on('relocated', reader.relocated)
|
||||
reader.rendition.on('keydown', reader.keyUp)
|
||||
reader.book.ready
|
||||
.then(() => {
|
||||
// set up event listeners
|
||||
reader.rendition.on('relocated', reader.relocated)
|
||||
reader.rendition.on('keydown', reader.keyUp)
|
||||
|
||||
reader.rendition.on('touchstart', (event) => {
|
||||
this.$emit('touchstart', event)
|
||||
})
|
||||
reader.rendition.on('touchend', (event) => {
|
||||
this.$emit('touchend', event)
|
||||
})
|
||||
|
||||
// load ebook cfi locations
|
||||
const savedLocations = this.loadLocations()
|
||||
if (savedLocations) {
|
||||
reader.book.locations.load(savedLocations)
|
||||
} else {
|
||||
reader.book.locations.generate().then(() => {
|
||||
this.checkSaveLocations(reader.book.locations.save())
|
||||
reader.rendition.on('touchstart', (event) => {
|
||||
this.$emit('touchstart', event)
|
||||
})
|
||||
}
|
||||
this.getChapters()
|
||||
})
|
||||
reader.rendition.on('touchend', (event) => {
|
||||
this.$emit('touchend', event)
|
||||
})
|
||||
|
||||
// load ebook cfi locations
|
||||
const savedLocations = this.loadLocations()
|
||||
if (savedLocations) {
|
||||
reader.book.locations.load(savedLocations)
|
||||
} else {
|
||||
reader.book.locations.generate().then(() => {
|
||||
this.checkSaveLocations(reader.book.locations.save())
|
||||
})
|
||||
}
|
||||
this.getChapters()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('EpubReader.initEpub failed:', error)
|
||||
})
|
||||
},
|
||||
getChapters() {
|
||||
// Load the list of chapters in the book. See https://github.com/futurepress/epub.js/issues/759
|
||||
|
||||
@@ -26,9 +26,6 @@ export default {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem?.id
|
||||
},
|
||||
@@ -96,11 +93,8 @@ export default {
|
||||
},
|
||||
async initMobi() {
|
||||
// Fetch mobi file as blob
|
||||
var buff = await this.$axios.$get(this.ebookUrl, {
|
||||
responseType: 'blob',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.userToken}`
|
||||
}
|
||||
const buff = await this.$axios.$get(this.ebookUrl, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
var reader = new FileReader()
|
||||
reader.onload = async (event) => {
|
||||
|
||||
@@ -55,7 +55,8 @@ export default {
|
||||
loadedRatio: 0,
|
||||
page: 1,
|
||||
numPages: 0,
|
||||
pdfDocInitParams: null
|
||||
pdfDocInitParams: null,
|
||||
isRefreshing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -152,7 +153,34 @@ export default {
|
||||
this.page++
|
||||
this.updateProgress()
|
||||
},
|
||||
error(err) {
|
||||
async refreshToken() {
|
||||
if (this.isRefreshing) return
|
||||
this.isRefreshing = true
|
||||
const newAccessToken = await this.$store.dispatch('user/refreshToken').catch((error) => {
|
||||
console.error('Failed to refresh token', error)
|
||||
return null
|
||||
})
|
||||
if (!newAccessToken) {
|
||||
// Redirect to login on failed refresh
|
||||
this.$router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
// Force Vue to re-render the PDF component by creating a new object
|
||||
this.pdfDocInitParams = {
|
||||
url: this.ebookUrl,
|
||||
httpHeaders: {
|
||||
Authorization: `Bearer ${newAccessToken}`
|
||||
}
|
||||
}
|
||||
this.isRefreshing = false
|
||||
},
|
||||
async error(err) {
|
||||
if (err && err.status === 401) {
|
||||
console.log('Received 401 error, refreshing token')
|
||||
await this.refreshToken()
|
||||
return
|
||||
}
|
||||
console.error(err)
|
||||
},
|
||||
resize() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div v-if="show" id="reader" :data-theme="ereaderTheme" class="group absolute top-0 left-0 w-full z-60 data-[theme=dark]:bg-primary data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black" :class="{ 'reader-player-open': !!streamLibraryItem }">
|
||||
<div v-if="show" id="reader" :data-theme="ereaderTheme" class="group absolute top-0 left-0 w-full z-60 data-[theme=dark]:bg-primary data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black data-[theme=sepia]:bg-[rgb(244,236,216)] data-[theme=sepia]:text-[#5b4636]" :class="{ 'reader-player-open': !!streamLibraryItem }">
|
||||
<div class="absolute top-4 left-4 z-20 flex items-center">
|
||||
<button v-if="isEpub" @click="toggleToC" type="button" aria-label="Table of contents menu" class="inline-flex opacity-80 hover:opacity-100">
|
||||
<span class="material-symbols text-2xl">menu</span>
|
||||
@@ -27,7 +27,12 @@
|
||||
|
||||
<!-- TOC side nav -->
|
||||
<div v-if="tocOpen" class="w-full h-full overflow-y-scroll absolute inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div>
|
||||
<div v-if="isEpub" class="w-96 h-full max-h-full absolute top-0 left-0 shadow-xl transition-transform z-30 group-data-[theme=dark]:bg-primary group-data-[theme=dark]:text-white group-data-[theme=light]:bg-white group-data-[theme=light]:text-black" :class="tocOpen ? 'translate-x-0' : '-translate-x-96'" @click.stop.prevent>
|
||||
<div
|
||||
v-if="isEpub"
|
||||
class="w-96 h-full max-h-full absolute top-0 left-0 shadow-xl transition-transform z-30 group-data-[theme=dark]:bg-primary group-data-[theme=dark]:text-white group-data-[theme=light]:bg-white group-data-[theme=light]:text-black group-data-[theme=sepia]:bg-[rgb(244,236,216)] group-data-[theme=sepia]:text-[#5b4636]"
|
||||
:class="tocOpen ? 'translate-x-0' : '-translate-x-96'"
|
||||
@click.stop.prevent
|
||||
>
|
||||
<div class="flex flex-col p-4 h-full">
|
||||
<div class="flex items-center mb-2">
|
||||
<button @click.stop.prevent="toggleToC" type="button" aria-label="Close table of contents" class="inline-flex opacity-80 hover:opacity-100">
|
||||
@@ -37,7 +42,7 @@
|
||||
<p class="text-lg font-semibold ml-2">{{ $strings.HeaderTableOfContents }}</p>
|
||||
</div>
|
||||
<form @submit.prevent="searchBook" @click.stop.prevent>
|
||||
<ui-text-input clearable ref="input" @clear="searchBook" v-model="searchQuery" :placeholder="$strings.PlaceholderSearch" class="h-8 w-full text-sm flex mb-2" />
|
||||
<ui-text-input clearable ref="input" @clear="searchBook" v-model="searchQuery" :placeholder="$strings.PlaceholderSearch" custom-input-class="text-inherit !bg-inherit" class="h-8 w-full text-sm flex mb-2" />
|
||||
</form>
|
||||
|
||||
<div class="overflow-y-auto">
|
||||
@@ -181,6 +186,10 @@ export default {
|
||||
text: this.$strings.LabelThemeDark,
|
||||
value: 'dark'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelThemeSepia,
|
||||
value: 'sepia'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelThemeLight,
|
||||
value: 'light'
|
||||
@@ -266,9 +275,6 @@ export default {
|
||||
isComic() {
|
||||
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
keepProgress() {
|
||||
return this.$store.state.ereaderKeepProgress
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div :key="n" class="absolute pointer-events-none left-0 h-px bg-white/10" :style="{ top: n * lineSpacing - lineSpacing / 2 + 'px', width: '360px', marginLeft: '24px' }" />
|
||||
|
||||
<div :key="`dot-${n}`" class="absolute z-10" :style="{ left: points[n - 1].x + 'px', bottom: points[n - 1].y + 'px' }">
|
||||
<ui-tooltip :text="last7DaysOfListening[n - 1].minutesListening" direction="top">
|
||||
<ui-tooltip :text="last7DaysOfListening[n - 1].minutesListening" plaintext direction="top">
|
||||
<div class="h-2 w-2 bg-yellow-400 hover:bg-yellow-300 rounded-full transform duration-150 transition-transform hover:scale-125" />
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
@@ -186,10 +186,16 @@ export default {
|
||||
daysInARow() {
|
||||
var count = 0
|
||||
while (true) {
|
||||
var _date = this.$addDaysToToday(count * -1)
|
||||
var datestr = this.$formatJsDate(_date, 'yyyy-MM-dd')
|
||||
const _date = this.$addDaysToToday(count * -1 - 1)
|
||||
const datestr = this.$formatJsDate(_date, 'yyyy-MM-dd')
|
||||
|
||||
if (!this.listeningStatsDays[datestr] || this.listeningStatsDays[datestr] === 0) {
|
||||
// don't require listening today to count towards days in a row, but do count it if already listened today
|
||||
const today = this.$formatJsDate(new Date(), 'yyyy-MM-dd')
|
||||
if (this.listeningStatsDays[today]) {
|
||||
count++
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
count++
|
||||
|
||||
@@ -152,7 +152,7 @@ export default {
|
||||
|
||||
this.showingTooltipIndex = index
|
||||
this.tooltipEl.style.display = 'block'
|
||||
this.tooltipTextEl.innerHTML = block.value ? `<strong>${this.$elapsedPretty(block.value, true)} listening</strong> on ${block.datePretty}` : `No listening sessions on ${block.datePretty}`
|
||||
this.tooltipTextEl.innerHTML = block.value ? this.$getString('MessageHeatmapListeningTimeTooltip', [this.$elapsedPrettyLocalized(block.value, true), block.datePretty]) : this.$getString('MessageHeatmapNoListeningSessions', [block.datePretty])
|
||||
|
||||
const calculateRect = this.tooltipEl.getBoundingClientRect()
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap justify-center mt-6">
|
||||
<div class="flex p-2">
|
||||
<svg class="h-14 w-14" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M9 3V18H12V3H9M12 5L16 18L19 17L15 4L12 5M5 5V18H8V5H5M3 19V21H21V19H3Z" />
|
||||
</svg>
|
||||
<span class="material-symbols text-5xl py-1">newsstand</span>
|
||||
<div class="px-1">
|
||||
<p class="text-4.5xl leading-none font-bold">{{ $formatNumber(totalItems) }}</p>
|
||||
<p class="text-xs md:text-sm text-white/80">{{ $strings.LabelStatsItemsInLibrary }}</p>
|
||||
@@ -19,9 +17,7 @@
|
||||
</div>
|
||||
|
||||
<div v-if="isBookLibrary" class="flex p-2">
|
||||
<svg class="h-14 w-14" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,6A2,2 0 0,0 10,8A2,2 0 0,0 12,10A2,2 0 0,0 14,8A2,2 0 0,0 12,6M12,13C14.67,13 20,14.33 20,17V20H4V17C4,14.33 9.33,13 12,13M12,14.9C9.03,14.9 5.9,16.36 5.9,17V18.1H18.1V17C18.1,16.36 14.97,14.9 12,14.9Z" />
|
||||
</svg>
|
||||
<span class="material-symbols text-5xl py-1">person</span>
|
||||
<div class="px-1">
|
||||
<p class="text-4.5xl leading-none font-bold">{{ $formatNumber(totalAuthors) }}</p>
|
||||
<p class="text-xs md:text-sm text-white/80">{{ $strings.LabelStatsAuthors }}</p>
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="text-center">
|
||||
<table v-if="apiKeys.length > 0" id="api-keys">
|
||||
<tr>
|
||||
<th>{{ $strings.LabelName }}</th>
|
||||
<th class="w-44">{{ $strings.LabelApiKeyUser }}</th>
|
||||
<th class="w-32">{{ $strings.LabelExpiresAt }}</th>
|
||||
<th class="w-32">{{ $strings.LabelCreatedAt }}</th>
|
||||
<th class="w-32"></th>
|
||||
</tr>
|
||||
<tr v-for="apiKey in apiKeys" :key="apiKey.id" :class="apiKey.isActive ? '' : 'bg-error/10!'">
|
||||
<td>
|
||||
<div class="flex items-center">
|
||||
<p class="pl-2 truncate">{{ apiKey.name }}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
<nuxt-link v-if="apiKey.user" :to="`/config/users/${apiKey.user.id}`" class="text-xs hover:underline">
|
||||
{{ apiKey.user.username }}
|
||||
</nuxt-link>
|
||||
<p v-else class="text-xs">Error</p>
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
<p v-if="apiKey.expiresAt" class="text-xs" :title="apiKey.expiresAt">{{ getExpiresAtText(apiKey) }}</p>
|
||||
<p v-else class="text-xs">{{ $strings.LabelExpiresNever }}</p>
|
||||
</td>
|
||||
<td class="text-xs font-mono">
|
||||
<ui-tooltip direction="top" :text="$formatJsDatetime(new Date(apiKey.createdAt), dateFormat, timeFormat)">
|
||||
{{ $formatJsDate(new Date(apiKey.createdAt), dateFormat) }}
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
<td class="py-0">
|
||||
<div class="w-full flex justify-left">
|
||||
<div class="h-8 w-8 flex items-center justify-center text-white/50 hover:text-white/100 cursor-pointer" @click.stop="editApiKey(apiKey)">
|
||||
<button type="button" :aria-label="$strings.ButtonEdit" class="material-symbols text-base">edit</button>
|
||||
</div>
|
||||
<div class="h-8 w-8 flex items-center justify-center text-white/50 hover:text-error cursor-pointer" @click.stop="deleteApiKeyClick(apiKey)">
|
||||
<button type="button" :aria-label="$strings.ButtonDelete" class="material-symbols text-base">delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p v-else class="text-base text-gray-300 py-4">{{ $strings.LabelNoApiKeys }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
apiKeys: [],
|
||||
isDeletingApiKey: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getExpiresAtText(apiKey) {
|
||||
if (new Date(apiKey.expiresAt).getTime() < Date.now()) {
|
||||
return this.$strings.LabelExpired
|
||||
}
|
||||
return this.$formatJsDatetime(new Date(apiKey.expiresAt), this.dateFormat, this.timeFormat)
|
||||
},
|
||||
deleteApiKeyClick(apiKey) {
|
||||
if (this.isDeletingApiKey) return
|
||||
|
||||
const payload = {
|
||||
message: this.$getString('MessageConfirmDeleteApiKey', [apiKey.name]),
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.deleteApiKey(apiKey)
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
deleteApiKey(apiKey) {
|
||||
this.isDeletingApiKey = true
|
||||
this.$axios
|
||||
.$delete(`/api/api-keys/${apiKey.id}`)
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
this.$toast.error(data.error)
|
||||
} else {
|
||||
this.removeApiKey(apiKey.id)
|
||||
this.$emit('numApiKeys', this.apiKeys.length)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete apiKey', error)
|
||||
this.$toast.error(this.$strings.ToastFailedToDelete)
|
||||
})
|
||||
.finally(() => {
|
||||
this.isDeletingApiKey = false
|
||||
})
|
||||
},
|
||||
editApiKey(apiKey) {
|
||||
this.$emit('edit', apiKey)
|
||||
},
|
||||
addApiKey(apiKey) {
|
||||
this.apiKeys.push(apiKey)
|
||||
},
|
||||
removeApiKey(apiKeyId) {
|
||||
this.apiKeys = this.apiKeys.filter((a) => a.id !== apiKeyId)
|
||||
},
|
||||
updateApiKey(apiKey) {
|
||||
this.apiKeys = this.apiKeys.map((a) => (a.id === apiKey.id ? apiKey : a))
|
||||
},
|
||||
loadApiKeys() {
|
||||
this.$axios
|
||||
.$get('/api/api-keys')
|
||||
.then((res) => {
|
||||
this.apiKeys = res.apiKeys.sort((a, b) => {
|
||||
return a.createdAt - b.createdAt
|
||||
})
|
||||
this.$emit('numApiKeys', this.apiKeys.length)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load apiKeys', error)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadApiKeys()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#api-keys {
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid #474747;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#api-keys td,
|
||||
#api-keys th {
|
||||
/* border: 1px solid #2e2e2e; */
|
||||
padding: 8px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#api-keys td.py-0 {
|
||||
padding: 0px 8px;
|
||||
}
|
||||
|
||||
#api-keys tr:nth-child(even) {
|
||||
background-color: #373838;
|
||||
}
|
||||
|
||||
#api-keys tr:nth-child(odd) {
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
|
||||
#api-keys tr:hover {
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
#api-keys th {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
background-color: #272727;
|
||||
}
|
||||
</style>
|
||||
@@ -78,10 +78,10 @@ export default {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
return this.$store.getters['getServerSetting']('timeFormat')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -49,9 +49,6 @@ export default {
|
||||
libraryItemId() {
|
||||
return this.libraryItem.id
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
|
||||
@@ -53,9 +53,6 @@ export default {
|
||||
libraryItemId() {
|
||||
return this.libraryItem.id
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
|
||||
@@ -76,10 +76,10 @@ export default {
|
||||
return usermap
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
return this.$store.getters['getServerSetting']('timeFormat')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -112,7 +112,7 @@ export default {
|
||||
return this.episode?.publishedAt
|
||||
},
|
||||
dateFormat() {
|
||||
return this.store.state.serverSettings.dateFormat
|
||||
return this.store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
itemProgress() {
|
||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episodeId)
|
||||
|
||||
@@ -239,10 +239,10 @@ export default {
|
||||
})
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
return this.$store.getters['getServerSetting']('timeFormat')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="w-40">
|
||||
<div :class="hasSlotContent ? 'w-auto' : 'w-40'">
|
||||
<div class="bg-bg border border-gray-500 py-2 px-5 rounded-lg flex items-center flex-col box-shadow-md">
|
||||
<div class="loader-dots block relative w-20 h-5 mt-2">
|
||||
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
|
||||
@@ -7,7 +7,9 @@
|
||||
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
|
||||
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
|
||||
</div>
|
||||
<div class="text-gray-200 text-xs font-light mt-2 text-center">{{ message }}</div>
|
||||
<slot>
|
||||
<div class="text-gray-200 text-xs font-light mt-2 text-center">{{ message }}</div>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -23,6 +25,9 @@ export default {
|
||||
computed: {
|
||||
message() {
|
||||
return this.text || this.$strings.MessagePleaseWait
|
||||
},
|
||||
hasSlotContent() {
|
||||
return this.$slots.default && this.$slots.default.length > 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
<div ref="wrapper" class="relative">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div ref="inputWrapper" role="list" style="min-height: 36px" class="flex-wrap relative w-full shadow-xs flex items-center border border-gray-600 rounded-sm px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div v-for="item in selected" :key="item" role="listitem" class="rounded-full px-2 py-1 mx-0.5 my-0.5 text-xs bg-bg flex flex-nowrap break-all items-center relative">
|
||||
<!-- Use index in v-for and key in case the same key exists multiple times -->
|
||||
<div v-for="(item, idx) in selected" :key="item + '-' + idx" role="listitem" class="rounded-full px-2 py-1 mx-0.5 my-0.5 text-xs bg-bg flex flex-nowrap break-all items-center relative">
|
||||
<div v-if="!disabled" class="w-full h-full rounded-full absolute top-0 left-0 px-1 bg-bg/75 flex items-center justify-end opacity-0 hover:opacity-100" :class="{ 'opacity-100': inputFocused }">
|
||||
<button v-if="showEdit" type="button" :aria-label="$strings.ButtonEdit" class="material-symbols text-white hover:text-warning cursor-pointer" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</button>
|
||||
<button type="button" :aria-label="$strings.ButtonRemove" class="material-symbols text-white hover:text-error focus:text-error cursor-pointer" style="font-size: 1.1rem" @click.stop="removeItem(item)" @keydown.enter.stop.prevent="removeItem(item)" @focus="setInputFocused(true)" @blur="setInputFocused(false)" tabindex="0">close</button>
|
||||
<button type="button" :aria-label="$strings.ButtonRemove" class="material-symbols text-white hover:text-error focus:text-error cursor-pointer" style="font-size: 1.1rem" @click.stop="removeItem(item, idx)" @keydown.enter.stop.prevent="removeItem(item, idx)" @focus="setInputFocused(true)" @blur="setInputFocused(false)" tabindex="0">close</button>
|
||||
</div>
|
||||
{{ item }}
|
||||
</div>
|
||||
@@ -259,8 +260,9 @@ export default {
|
||||
}
|
||||
this.focus()
|
||||
},
|
||||
removeItem(item) {
|
||||
var remaining = this.selected.filter((i) => i !== item)
|
||||
removeItem(item, idx) {
|
||||
var remaining = this.selected.slice()
|
||||
remaining.splice(idx, 1)
|
||||
this.$emit('input', remaining)
|
||||
this.$emit('removedItem', item)
|
||||
this.$nextTick(() => {
|
||||
@@ -276,7 +278,7 @@ export default {
|
||||
})
|
||||
},
|
||||
insertNewItem(item) {
|
||||
this.selected.push(item)
|
||||
if (!this.selected.includes(item)) this.selected.push(item)
|
||||
this.$emit('input', this.selected)
|
||||
this.$emit('newItem', item)
|
||||
this.textInput = null
|
||||
|
||||
@@ -85,9 +85,6 @@ export default {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
wrapperClass() {
|
||||
var classes = []
|
||||
if (this.disabled) classes.push('bg-black-300')
|
||||
@@ -290,7 +287,7 @@ export default {
|
||||
})
|
||||
},
|
||||
insertNewItem(item) {
|
||||
this.selected.push(item)
|
||||
if (!this.selected.find((i) => i.name === item.name)) this.selected.push(item)
|
||||
this.$emit('input', this.selected)
|
||||
this.$emit('newItem', item)
|
||||
this.textInput = null
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
<template>
|
||||
<button :aria-label="isRead ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" :class="borderless ? '' : 'bg-primary border border-gray-600'" @click="clickBtn">
|
||||
<div class="w-5 h-5 text-white relative">
|
||||
<svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)">
|
||||
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-7 19.6l-7-4.66V3h14v12.93l-7 4.67zm-2.01-7.42l-2.58-2.59L6 12l4 4 8-8-1.42-1.42z" />
|
||||
</svg>
|
||||
<div class="w-5 h-5 relative">
|
||||
<span v-if="isRead" class="material-symbols fill text-xl text-success">beenhere</span>
|
||||
<span v-else class="material-symbols text-xl text-white">beenhere</span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="relative w-full">
|
||||
<p v-if="label" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||
<p v-if="label && !labelHidden" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||
<button ref="buttonWrapper" type="button" :aria-label="longLabel" :disabled="disabled" class="relative w-full border rounded-sm shadow-xs pl-3 pr-8 py-2 text-left sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||
<span class="flex items-center">
|
||||
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span>
|
||||
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small, 'text-gray-400': !selectedText }">{{ selectedText || placeholder }}</span>
|
||||
<span v-if="selectedSubtext">: </span>
|
||||
<span v-if="selectedSubtext" class="font-normal block truncate font-sans text-sm text-gray-400">{{ selectedSubtext }}</span>
|
||||
</span>
|
||||
@@ -36,10 +36,15 @@ export default {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
labelHidden: Boolean,
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
disabled: Boolean,
|
||||
small: Boolean,
|
||||
menuMaxHeight: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative">
|
||||
<input :id="inputId" :name="inputName" ref="input" v-model="inputValue" :type="actualType" :step="step" :min="min" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" dir="auto" class="rounded-sm bg-primary text-gray-200 focus:bg-bg focus:outline-hidden border h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||
<input :id="inputId" :name="inputName" ref="input" v-model="inputValue" :type="actualType" :step="step" :min="min" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" :autocomplete="autocomplete" dir="auto" class="rounded-sm bg-primary text-gray-200 focus:bg-bg focus:outline-hidden border h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
|
||||
<span class="material-symbols text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
|
||||
</div>
|
||||
@@ -41,7 +41,8 @@ export default {
|
||||
step: [String, Number],
|
||||
min: [String, Number],
|
||||
customInputClass: String,
|
||||
trimWhitespace: Boolean
|
||||
trimWhitespace: Boolean,
|
||||
autocomplete: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
||||
</label>
|
||||
</slot>
|
||||
<ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" :show-copy="showCopy" class="w-full" :class="inputClass" :trim-whitespace="trimWhitespace" @blur="inputBlurred" />
|
||||
<ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" :min="min" :show-copy="showCopy" :autocomplete="autocomplete" class="w-full" :class="inputClass" :trim-whitespace="trimWhitespace" @blur="inputBlurred" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -21,11 +21,13 @@ export default {
|
||||
type: String,
|
||||
default: 'text'
|
||||
},
|
||||
min: [String, Number],
|
||||
readonly: Boolean,
|
||||
disabled: Boolean,
|
||||
inputClass: String,
|
||||
showCopy: Boolean,
|
||||
trimWhitespace: Boolean
|
||||
trimWhitespace: Boolean,
|
||||
autocomplete: String
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
|
||||
@@ -22,7 +22,8 @@ export default {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
disabled: Boolean
|
||||
disabled: Boolean,
|
||||
plaintext: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -46,7 +47,11 @@ export default {
|
||||
methods: {
|
||||
updateText() {
|
||||
if (this.tooltip) {
|
||||
this.tooltip.innerHTML = this.text
|
||||
if (this.plaintext) {
|
||||
this.tooltip.textContent = this.text
|
||||
} else {
|
||||
this.tooltip.innerHTML = this.text
|
||||
}
|
||||
this.setTooltipPosition(this.tooltip)
|
||||
}
|
||||
},
|
||||
@@ -58,7 +63,11 @@ export default {
|
||||
tooltip.className = 'tooltip-wrapper absolute px-2 py-1 text-white text-xs rounded-sm shadow-lg max-w-xs text-center hidden sm:block'
|
||||
tooltip.style.zIndex = 100
|
||||
tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)'
|
||||
tooltip.innerHTML = this.text
|
||||
if (this.plaintext) {
|
||||
tooltip.textContent = this.text
|
||||
} else {
|
||||
tooltip.innerHTML = this.text
|
||||
}
|
||||
tooltip.addEventListener('mouseover', this.cancelHide)
|
||||
tooltip.addEventListener('mouseleave', this.hideTooltip)
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ export default {
|
||||
nextRun() {
|
||||
if (!this.cronExpression) return ''
|
||||
const parsed = this.$getNextScheduledDate(this.cronExpression)
|
||||
return this.$formatJsDatetime(parsed, this.$store.state.serverSettings.dateFormat, this.$store.state.serverSettings.timeFormat) || ''
|
||||
return this.$formatJsDatetime(parsed, this.$store.getters['getServerSetting']('dateFormat'), this.$store.getters['getServerSetting']('timeFormat')) || ''
|
||||
},
|
||||
description() {
|
||||
if ((this.selectedInterval !== 'custom' || !this.selectedWeekdays.length) && this.selectedInterval !== 'daily') return ''
|
||||
|
||||
@@ -1,40 +1,6 @@
|
||||
<template>
|
||||
<ui-tooltip :text="$strings.LabelExplicit" direction="top">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12px" height="12px" viewBox="0 0 512 512" class="ml-1">
|
||||
<path
|
||||
fill="white"
|
||||
d="M 89.00,40.12
|
||||
C 89.00,40.12 127.00,40.12 127.00,40.12
|
||||
127.00,40.12 198.00,40.12 198.00,40.12
|
||||
198.00,40.12 416.00,40.12 416.00,40.12
|
||||
446.58,40.05 472.95,66.42 473.00,97.00
|
||||
473.00,97.00 473.00,303.00 473.00,303.00
|
||||
473.00,303.00 473.00,418.00 473.00,418.00
|
||||
472.65,447.55 445.06,472.95 416.00,473.00
|
||||
416.00,473.00 210.00,473.00 210.00,473.00
|
||||
210.00,473.00 95.00,473.00 95.00,473.00
|
||||
65.45,472.65 40.05,445.06 40.00,416.00
|
||||
40.00,416.00 40.00,136.00 40.00,136.00
|
||||
40.00,136.00 40.00,109.00 40.00,109.00
|
||||
40.00,109.00 40.00,96.00 40.00,96.00
|
||||
40.07,81.58 46.89,67.14 57.01,57.01
|
||||
61.17,52.86 64.86,50.13 70.00,47.31
|
||||
77.25,43.33 81.02,42.18 89.00,40.12 Z
|
||||
M 337.00,121.00
|
||||
C 337.00,121.00 175.00,121.00 175.00,121.00
|
||||
175.00,121.00 175.00,392.00 175.00,392.00
|
||||
175.00,392.00 337.00,392.00 337.00,392.00
|
||||
337.00,392.00 337.00,349.00 337.00,349.00
|
||||
337.00,349.00 226.00,349.00 226.00,349.00
|
||||
226.00,349.00 226.00,274.00 226.00,274.00
|
||||
226.00,274.00 332.00,274.00 332.00,274.00
|
||||
332.00,274.00 332.00,232.00 332.00,232.00
|
||||
332.00,232.00 226.00,232.00 226.00,232.00
|
||||
226.00,232.00 226.00,164.00 226.00,164.00
|
||||
226.00,164.00 337.00,164.00 337.00,164.00
|
||||
337.00,164.00 337.00,121.00 337.00,121.00 Z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="material-symbols fill text-sm ml-1 !block">explicit</span>
|
||||
</ui-tooltip>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -132,10 +132,10 @@ export default {
|
||||
editAuthor(author) {
|
||||
this.$store.commit('globals/showEditAuthorModal', author)
|
||||
},
|
||||
editItem(libraryItem) {
|
||||
editItem(libraryItem, tab = 'details') {
|
||||
var itemIds = this.items.map((e) => e.id)
|
||||
this.$store.commit('setBookshelfBookIds', itemIds)
|
||||
this.$store.commit('showEditModal', libraryItem)
|
||||
this.$store.commit('showEditModalOnTab', { libraryItem, tab: tab || 'details' })
|
||||
},
|
||||
selectItem(payload) {
|
||||
this.$emit('selectEntity', payload)
|
||||
|
||||
@@ -40,6 +40,7 @@ describe('LazySeriesCard', () => {
|
||||
},
|
||||
$store: {
|
||||
getters: {
|
||||
getServerSetting: () => 'MM/dd/yyyy',
|
||||
'user/getUserCanUpdate': true,
|
||||
'user/getUserMediaProgress': (id) => null,
|
||||
'user/getSizeMultiplier': 1,
|
||||
|
||||
@@ -33,6 +33,7 @@ export default {
|
||||
return {
|
||||
socket: null,
|
||||
isSocketConnected: false,
|
||||
isSocketAuthenticated: false,
|
||||
isFirstSocketConnection: true,
|
||||
socketConnectionToastId: null,
|
||||
currentLang: null,
|
||||
@@ -81,9 +82,28 @@ export default {
|
||||
document.body.classList.add('app-bar')
|
||||
}
|
||||
},
|
||||
tokenRefreshed(newAccessToken) {
|
||||
if (this.isSocketConnected && !this.isSocketAuthenticated) {
|
||||
console.log('[SOCKET] Re-authenticating socket after token refresh')
|
||||
this.socket.emit('auth', newAccessToken)
|
||||
}
|
||||
},
|
||||
updateSocketConnectionToast(content, type, timeout) {
|
||||
if (this.socketConnectionToastId !== null && this.socketConnectionToastId !== undefined) {
|
||||
this.$toast.update(this.socketConnectionToastId, { content: content, options: { timeout: timeout, type: type, closeButton: false, position: 'bottom-center', onClose: () => null, closeOnClick: timeout !== null } }, false)
|
||||
const toastUpdateOptions = {
|
||||
content: content,
|
||||
options: {
|
||||
timeout: timeout,
|
||||
type: type,
|
||||
closeButton: false,
|
||||
position: 'bottom-center',
|
||||
onClose: () => {
|
||||
this.socketConnectionToastId = null
|
||||
},
|
||||
closeOnClick: timeout !== null
|
||||
}
|
||||
}
|
||||
this.$toast.update(this.socketConnectionToastId, toastUpdateOptions, false)
|
||||
} else {
|
||||
this.socketConnectionToastId = this.$toast[type](content, { position: 'bottom-center', timeout: timeout, closeButton: false, closeOnClick: timeout !== null })
|
||||
}
|
||||
@@ -109,7 +129,7 @@ export default {
|
||||
this.updateSocketConnectionToast(this.$strings.ToastSocketDisconnected, 'error', null)
|
||||
},
|
||||
reconnect() {
|
||||
console.error('[SOCKET] reconnected')
|
||||
console.log('[SOCKET] reconnected')
|
||||
},
|
||||
reconnectAttempt(val) {
|
||||
console.log(`[SOCKET] reconnect attempt ${val}`)
|
||||
@@ -120,6 +140,10 @@ export default {
|
||||
reconnectFailed() {
|
||||
console.error('[SOCKET] reconnect failed')
|
||||
},
|
||||
authFailed(payload) {
|
||||
console.error('[SOCKET] auth failed', payload.message)
|
||||
this.isSocketAuthenticated = false
|
||||
},
|
||||
init(payload) {
|
||||
console.log('Init Payload', payload)
|
||||
|
||||
@@ -127,7 +151,7 @@ export default {
|
||||
this.$store.commit('users/setUsersOnline', payload.usersOnline)
|
||||
}
|
||||
|
||||
this.$eventBus.$emit('socket_init')
|
||||
this.isSocketAuthenticated = true
|
||||
},
|
||||
streamOpen(stream) {
|
||||
if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamOpen(stream)
|
||||
@@ -175,7 +199,7 @@ export default {
|
||||
}
|
||||
} else {
|
||||
console.error('User has no more accessible libraries')
|
||||
this.$store.commit('libraries/setCurrentLibrary', null)
|
||||
this.$store.commit('libraries/setCurrentLibrary', { id: null })
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -347,13 +371,24 @@ export default {
|
||||
},
|
||||
customMetadataProviderAdded(provider) {
|
||||
if (!provider?.id) return
|
||||
this.$store.commit('scanners/addCustomMetadataProvider', provider)
|
||||
// Refresh providers cache
|
||||
this.$store.dispatch('scanners/refreshProviders')
|
||||
},
|
||||
customMetadataProviderRemoved(provider) {
|
||||
if (!provider?.id) return
|
||||
this.$store.commit('scanners/removeCustomMetadataProvider', provider)
|
||||
// Refresh providers cache
|
||||
this.$store.dispatch('scanners/refreshProviders')
|
||||
},
|
||||
initializeSocket() {
|
||||
if (this.$root.socket) {
|
||||
// Can happen in dev due to hot reload
|
||||
console.warn('Socket already initialized')
|
||||
this.socket = this.$root.socket
|
||||
this.isSocketConnected = this.$root.socket?.connected
|
||||
this.isFirstSocketConnection = false
|
||||
this.socketConnectionToastId = null
|
||||
return
|
||||
}
|
||||
this.socket = this.$nuxtSocket({
|
||||
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
|
||||
persist: 'main',
|
||||
@@ -364,6 +399,7 @@ export default {
|
||||
path: `${this.$config.routerBasePath}/socket.io`
|
||||
})
|
||||
this.$root.socket = this.socket
|
||||
this.isSocketAuthenticated = false
|
||||
console.log('Socket initialized')
|
||||
|
||||
// Pre-defined socket events
|
||||
@@ -377,6 +413,7 @@ export default {
|
||||
|
||||
// Event received after authorizing socket
|
||||
this.socket.on('init', this.init)
|
||||
this.socket.on('auth_failed', this.authFailed)
|
||||
|
||||
// Stream Listeners
|
||||
this.socket.on('stream_open', this.streamOpen)
|
||||
@@ -571,6 +608,7 @@ export default {
|
||||
this.updateBodyClass()
|
||||
this.resize()
|
||||
this.$eventBus.$on('change-lang', this.changeLanguage)
|
||||
this.$eventBus.$on('token_refreshed', this.tokenRefreshed)
|
||||
window.addEventListener('resize', this.resize)
|
||||
window.addEventListener('keydown', this.keyDown)
|
||||
|
||||
@@ -594,6 +632,7 @@ export default {
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('change-lang', this.changeLanguage)
|
||||
this.$eventBus.$off('token_refreshed', this.tokenRefreshed)
|
||||
window.removeEventListener('resize', this.resize)
|
||||
window.removeEventListener('keydown', this.keyDown)
|
||||
}
|
||||
|
||||
@@ -118,8 +118,8 @@ export default {
|
||||
propsData: props,
|
||||
parent: this,
|
||||
created() {
|
||||
this.$on('edit', (entity) => {
|
||||
if (_this.editEntity) _this.editEntity(entity)
|
||||
this.$on('edit', (entity, tab) => {
|
||||
if (_this.editEntity) _this.editEntity(entity, tab)
|
||||
})
|
||||
this.$on('select', ({ entity, shiftKey }) => {
|
||||
if (_this.selectEntity) _this.selectEntity(entity, shiftKey)
|
||||
|
||||
@@ -73,7 +73,8 @@ module.exports = {
|
||||
|
||||
// Axios module configuration: https://go.nuxtjs.dev/config-axios
|
||||
axios: {
|
||||
baseURL: routerBasePath
|
||||
baseURL: routerBasePath,
|
||||
progress: false
|
||||
},
|
||||
|
||||
// nuxt/pwa https://pwa.nuxtjs.org
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.25.1",
|
||||
"version": "2.35.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.25.1",
|
||||
"version": "2.35.1",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.25.1",
|
||||
"version": "2.35.1",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
|
||||
+11
-10
@@ -182,18 +182,19 @@ export default {
|
||||
password: this.password,
|
||||
newPassword: this.newPassword
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
this.$toast.success(this.$strings.ToastUserPasswordChangeSuccess)
|
||||
this.resetForm()
|
||||
} else {
|
||||
this.$toast.error(res.error || this.$strings.ToastUnknownError)
|
||||
}
|
||||
this.changingPassword = false
|
||||
.then(() => {
|
||||
this.$toast.success(this.$strings.ToastUserPasswordChangeSuccess)
|
||||
this.resetForm()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
this.$toast.error(this.$strings.ToastUnknownError)
|
||||
console.error('Failed to change password', error)
|
||||
let errorMessage = this.$strings.ToastUnknownError
|
||||
if (error.response?.data && typeof error.response.data === 'string') {
|
||||
errorMessage = error.response.data
|
||||
}
|
||||
this.$toast.error(errorMessage)
|
||||
})
|
||||
.finally(() => {
|
||||
this.changingPassword = false
|
||||
})
|
||||
},
|
||||
|
||||
@@ -12,24 +12,24 @@
|
||||
<p class="text-base font-mono ml-4 hidden md:block">{{ $secondsToTimestamp(mediaDurationRounded) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap-reverse lg:flex-nowrap justify-center py-4 px-4">
|
||||
<div class="flex flex-wrap-reverse min-[1120px]:flex-nowrap justify-center py-4 px-4">
|
||||
<div class="w-full max-w-3xl py-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 hidden lg:block" />
|
||||
<div class="w-12 hidden min-w-[1120px]:block" />
|
||||
<p class="text-lg mb-4 font-semibold">{{ $strings.HeaderChapters }}</p>
|
||||
<div class="grow" />
|
||||
<ui-checkbox v-model="showSecondInputs" checkbox-bg="primary" small label-class="text-sm text-gray-200 pl-1" :label="$strings.LabelShowSeconds" class="mx-2" />
|
||||
<div class="w-32 hidden lg:block" />
|
||||
<div class="w-32 hidden min-[1120px]:block" />
|
||||
</div>
|
||||
<div class="flex items-center mb-3 py-1 -mx-1">
|
||||
<div class="w-12 hidden lg:block" />
|
||||
<div class="w-12 hidden min-[1120px]:block" />
|
||||
<ui-btn v-if="chapters.length" color="bg-primary" small class="mx-1 whitespace-nowrap" @click.stop="removeAllChaptersClick">{{ $strings.ButtonRemoveAll }}</ui-btn>
|
||||
<ui-btn v-if="newChapters.length > 1" :color="showShiftTimes ? 'bg-bg' : 'bg-primary'" class="mx-1 whitespace-nowrap" small @click="showShiftTimes = !showShiftTimes">{{ $strings.ButtonShiftTimes }}</ui-btn>
|
||||
<ui-btn color="bg-primary" small :class="{ 'mx-1': newChapters.length > 1 }" @click="showFindChaptersModal = true">{{ $strings.ButtonLookup }}</ui-btn>
|
||||
<div class="grow" />
|
||||
<ui-btn v-if="hasChanges" small class="mx-1" @click.stop="resetChapters">{{ $strings.ButtonReset }}</ui-btn>
|
||||
<ui-btn v-if="hasChanges" color="bg-success" class="mx-1" :disabled="!hasChanges" small @click="saveChapters">{{ $strings.ButtonSave }}</ui-btn>
|
||||
<div class="w-32 hidden lg:block" />
|
||||
<div class="w-32 hidden min-[1120px]:block" />
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden">
|
||||
@@ -53,54 +53,104 @@
|
||||
|
||||
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
|
||||
<div class="w-8 min-w-8 md:w-12 md:min-w-12"></div>
|
||||
<div class="w-24 min-w-24 md:w-32 md:min-w-32 px-2">{{ $strings.LabelStart }}</div>
|
||||
<div class="grow px-2">{{ $strings.LabelTitle }}</div>
|
||||
<div class="w-38 min-w-38 md:w-40 md:min-w-40 px-1 pl-8">{{ $strings.LabelStart }}</div>
|
||||
<div class="grow px-1 min-w-54">{{ $strings.LabelTitle }}</div>
|
||||
<div class="w-7 min-w-7 px-1 flex items-center justify-center">
|
||||
<ui-tooltip :text="allChaptersLocked ? $strings.TooltipUnlockAllChapters : $strings.TooltipLockAllChapters" direction="bottom">
|
||||
<button class="w-7 h-7 rounded-full flex items-center justify-center cursor-pointer transition-colors duration-150" :class="allChaptersLocked ? 'text-orange-400 hover:text-orange-300' : 'text-gray-300 hover:text-white'" @click="toggleAllChaptersLock">
|
||||
<span class="material-symbols text-xl">{{ allChaptersLocked ? 'lock' : 'lock_open' }}</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div class="w-32"></div>
|
||||
</div>
|
||||
<template v-for="chapter in newChapters">
|
||||
<div :key="chapter.id" class="flex py-1">
|
||||
<div class="w-8 min-w-8 md:w-12 md:min-w-12">#{{ chapter.id + 1 }}</div>
|
||||
<div class="w-24 min-w-24 md:w-32 md:min-w-32 px-1">
|
||||
<ui-text-input v-if="showSecondInputs" v-model="chapter.start" type="number" class="text-xs" @change="checkChapters" />
|
||||
<ui-time-picker v-else class="text-xs" v-model="chapter.start" :show-three-digit-hour="mediaDuration >= 360000" @change="checkChapters" />
|
||||
</div>
|
||||
<div class="grow px-1">
|
||||
<ui-text-input v-model="chapter.title" @change="checkChapters" class="text-xs min-w-52" />
|
||||
</div>
|
||||
<div class="w-32 min-w-32 px-2 py-1">
|
||||
<div class="flex items-center">
|
||||
<ui-tooltip :text="$strings.MessageRemoveChapter" direction="bottom">
|
||||
<button v-if="newChapters.length > 1" class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-error transform hover:scale-110 duration-150" @click="removeChapter(chapter)">
|
||||
<span class="material-symbols text-base">remove</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
<div v-for="chapter in newChapters" :key="chapter.id" class="flex py-1">
|
||||
<div class="w-8 min-w-8 md:w-12 md:min-w-12">#{{ chapter.id + 1 }}</div>
|
||||
<div class="w-38 min-w-38 md:w-40 md:min-w-40 px-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<ui-tooltip :text="$strings.TooltipSubtractOneSecond" direction="bottom">
|
||||
<button
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center text-gray-300 hover:text-white transform hover:scale-110 duration-150 flex-shrink-0"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': chapter.id === 0 && chapter.start - timeIncrementAmount < 0 }"
|
||||
@click="incrementChapterTime(chapter, -timeIncrementAmount)"
|
||||
:disabled="chapter.id === 0 && chapter.start - timeIncrementAmount < 0"
|
||||
>
|
||||
<span class="material-symbols text-sm">remove</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip :text="$strings.MessageInsertChapterBelow" direction="bottom">
|
||||
<button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-success transform hover:scale-110 duration-150" @click="addChapter(chapter)">
|
||||
<span class="material-symbols text-lg">add</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip :text="selectedChapterId === chapter.id && isPlayingChapter ? $strings.MessagePauseChapter : $strings.MessagePlayChapter" direction="bottom">
|
||||
<button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-white transform hover:scale-110 duration-150" @click="playChapter(chapter)">
|
||||
<widgets-loading-spinner v-if="selectedChapterId === chapter.id && isLoadingChapter" />
|
||||
<span v-else-if="selectedChapterId === chapter.id && isPlayingChapter" class="material-symbols text-base">pause</span>
|
||||
<span v-else class="material-symbols text-base">play_arrow</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip v-if="chapter.error" :text="chapter.error" direction="left">
|
||||
<button class="w-7 h-7 rounded-full flex items-center justify-center text-error">
|
||||
<span class="material-symbols text-lg">error_outline</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
<div class="flex-1 min-w-0">
|
||||
<ui-text-input v-if="showSecondInputs" v-model="chapter.start" type="number" class="text-xs" @change="checkChapters" />
|
||||
<ui-time-picker v-else class="text-xs" v-model="chapter.start" :show-three-digit-hour="mediaDuration >= 360000" @change="checkChapters" />
|
||||
</div>
|
||||
|
||||
<ui-tooltip :text="$strings.TooltipAddOneSecond" direction="bottom">
|
||||
<button class="w-6 h-6 rounded-full flex items-center justify-center text-gray-300 hover:text-white transform hover:scale-110 duration-150 flex-shrink-0" :class="{ 'opacity-50 cursor-not-allowed': chapter.start + timeIncrementAmount >= mediaDuration }" @click="incrementChapterTime(chapter, timeIncrementAmount)" :disabled="chapter.start + timeIncrementAmount >= mediaDuration">
|
||||
<span class="material-symbols text-sm">add</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="grow px-1">
|
||||
<ui-text-input v-model="chapter.title" @change="checkChapters" class="text-xs min-w-52" />
|
||||
</div>
|
||||
<div class="w-7 min-w-7 px-1 py-1">
|
||||
<div class="flex items-center justify-center">
|
||||
<ui-tooltip :text="lockedChapters.has(chapter.id) ? $strings.TooltipUnlockChapter : $strings.TooltipLockChapter" direction="bottom">
|
||||
<button class="w-7 h-7 rounded-full flex items-center justify-center transform hover:scale-110 duration-150 flex-shrink-0" :class="lockedChapters.has(chapter.id) ? 'text-orange-400 hover:text-orange-300' : 'text-gray-300 hover:text-white'" @click="toggleChapterLock(chapter, $event)">
|
||||
<span class="material-symbols text-base">{{ lockedChapters.has(chapter.id) ? 'lock' : 'lock_open' }}</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-32 min-w-32 px-2 py-1">
|
||||
<div class="flex items-center">
|
||||
<ui-tooltip :text="$strings.MessageRemoveChapter" direction="bottom">
|
||||
<button v-if="newChapters.length > 1" class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-error transform hover:scale-110 duration-150" @click="removeChapter(chapter)">
|
||||
<span class="material-symbols text-base">delete</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip :text="$strings.MessageInsertChapterBelow" direction="bottom">
|
||||
<button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-success transform hover:scale-110 duration-150" @click="addChapter(chapter)">
|
||||
<span class="material-symbols text-lg">add_row_below</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
<ui-tooltip :text="selectedChapterId === chapter.id && isPlayingChapter ? $strings.MessagePauseChapter : $strings.MessagePlayChapter" direction="bottom">
|
||||
<button :disabled="!getAudioTrackForTime(chapter.start)" class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-white transform hover:scale-110 duration-150 disabled:opacity-50 disabled:cursor-not-allowed" @click="playChapter(chapter)">
|
||||
<widgets-loading-spinner v-if="selectedChapterId === chapter.id && isLoadingChapter" />
|
||||
<span v-else-if="selectedChapterId === chapter.id && isPlayingChapter" class="material-symbols text-base">pause</span>
|
||||
<span v-else class="material-symbols text-xl">play_arrow</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
<ui-tooltip v-if="selectedChapterId === chapter.id && (isPlayingChapter || isLoadingChapter)" :text="$strings.TooltipAdjustChapterStart" direction="bottom">
|
||||
<div class="ml-2 text-xs text-gray-300 font-mono min-w-10 cursor-pointer hover:text-white transition-colors duration-150" @click="adjustChapterStartTime(chapter)">{{ elapsedTime }}s</div>
|
||||
</ui-tooltip>
|
||||
<ui-tooltip v-if="chapter.error" :text="chapter.error" plaintext direction="left">
|
||||
<button class="w-7 h-7 rounded-full flex items-center justify-center text-error">
|
||||
<span class="material-symbols text-lg">error_outline</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center mt-4 mb-2">
|
||||
<div class="w-8 min-w-8 md:w-12 md:min-w-12"></div>
|
||||
<div class="w-38 min-w-38 md:w-40 md:min-w-40 px-1"></div>
|
||||
<div class="flex items-center gap-2 grow px-1">
|
||||
<ui-text-input v-model="bulkChapterInput" :placeholder="$strings.PlaceholderBulkChapterInput" class="text-xs grow min-w-52" @keyup.enter="handleBulkChapterAdd" />
|
||||
</div>
|
||||
<div class="w-39 min-w-39 px-1 py-1">
|
||||
<ui-tooltip :text="$strings.TooltipAddChapters" direction="bottom" class="inline-block align-middle">
|
||||
<button class="w-5 h-5 rounded-full flex items-center justify-center text-gray-300 hover:text-success transform hover:scale-110 duration-150 flex-shrink-0" :aria-label="$strings.TooltipAddChapters" :class="{ 'opacity-50 cursor-not-allowed': !bulkChapterInput.trim() }" :disabled="!bulkChapterInput.trim()" @click="handleBulkChapterAdd">
|
||||
<span class="material-symbols text-lg">add</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-xl py-4 px-2">
|
||||
<div class="w-full max-w-3xl min-[1120px]:max-w-xl py-4 px-2">
|
||||
<div class="flex items-center mb-4 py-1">
|
||||
<p class="text-lg font-semibold">{{ $strings.HeaderAudioTracks }}</p>
|
||||
<div class="grow" />
|
||||
@@ -110,23 +160,19 @@
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
|
||||
<div class="grow">{{ $strings.LabelFilename }}</div>
|
||||
<div class="grow min-[1120px]:max-w-64 xl:max-w-sm">{{ $strings.LabelFilename }}</div>
|
||||
<div class="w-20">{{ $strings.LabelDuration }}</div>
|
||||
<div class="w-20 hidden md:block text-center">{{ $strings.HeaderChapters }}</div>
|
||||
</div>
|
||||
<template v-for="track in audioTracks">
|
||||
<div :key="track.ino" class="flex items-center py-2" :class="currentTrackIndex === track.index && isPlayingChapter ? 'bg-success/10' : ''">
|
||||
<div class="grow max-w-[calc(100%-80px)] pr-2">
|
||||
<p class="text-xs truncate max-w-sm">{{ track.metadata.filename }}</p>
|
||||
</div>
|
||||
<div class="w-20" style="min-width: 80px">
|
||||
<p class="text-xs font-mono text-gray-200">{{ $secondsToTimestamp(Math.round(track.duration), false, true) }}</p>
|
||||
</div>
|
||||
<div class="w-20 hidden md:flex justify-center" style="min-width: 80px">
|
||||
<span v-if="(track.chapters || []).length" class="material-symbols text-success text-sm">check</span>
|
||||
</div>
|
||||
<div v-for="track in audioTracks" :key="track.ino" class="flex items-center py-2" :class="currentTrackIndex === track.index && isPlayingChapter ? 'bg-success/10' : ''">
|
||||
<div class="pr-2 grow min-[1120px]:max-w-64 xl:max-w-sm">
|
||||
<p class="text-xs truncate">{{ track.metadata.filename }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="w-20" style="min-width: 80px">
|
||||
<p class="text-xs font-mono text-gray-200">{{ $secondsToTimestamp(Math.round(track.duration), false, true) }}</p>
|
||||
</div>
|
||||
<div class="w-20 hidden md:flex justify-center" style="min-width: 80px"><span v-if="(track.chapters || []).length" class="material-symbols text-success text-sm">check</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -134,6 +180,7 @@
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
|
||||
<!-- audible chapter lookup modal -->
|
||||
<modals-modal v-model="showFindChaptersModal" name="edit-book" :width="500" :processing="findingChapters">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
|
||||
@@ -159,12 +206,16 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full p-4">
|
||||
<div class="flex justify-between mb-4">
|
||||
<div class="flex mb-4">
|
||||
<button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-white flex-shrink-0" :aria-label="$strings.ButtonBack" @click="resetChapterLookupData">
|
||||
<span class="material-symbols text-lg">arrow_back</span>
|
||||
</button>
|
||||
<p>
|
||||
{{ $strings.LabelDurationFound }} <span class="font-semibold">{{ $secondsToTimestamp(chapterData.runtimeLengthSec) }}</span
|
||||
><br />
|
||||
{{ $strings.LabelDurationFound }} <span class="font-semibold">{{ $secondsToTimestamp(chapterData.runtimeLengthSec) }}</span>
|
||||
<br />
|
||||
<span class="font-semibold" :class="{ 'text-warning': chapters.length !== chapterData.chapters.length }">{{ chapterData.chapters.length }}</span> {{ $strings.LabelChaptersFound }}
|
||||
</p>
|
||||
<div class="grow" />
|
||||
<p>
|
||||
{{ $strings.LabelYourAudiobookDuration }}: <span class="font-semibold">{{ $secondsToTimestamp(mediaDurationRounded) }}</span
|
||||
><br />
|
||||
@@ -198,17 +249,49 @@
|
||||
<p class="pl-2">{{ $strings.MessageChapterStartIsAfter }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center pt-2">
|
||||
<ui-btn small color="bg-primary" class="mr-1" @click="applyChapterNamesOnly">{{ $strings.ButtonMapChapterTitles }}</ui-btn>
|
||||
<ui-tooltip :text="$strings.MessageMapChapterTitles" direction="top" class="flex items-center">
|
||||
<span class="material-symbols text-xl text-gray-200">info</span>
|
||||
</ui-tooltip>
|
||||
<div class="grow" />
|
||||
<div class="flex items-center pt-2 justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<ui-btn small color="bg-primary" @click="applyChapterNamesOnly">{{ $strings.ButtonMapChapterTitles }}</ui-btn>
|
||||
<ui-tooltip :text="$strings.MessageMapChapterTitles" direction="top" class="flex items-center">
|
||||
<span class="material-symbols text-xl text-gray-200">info</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<ui-btn small color="bg-success" @click="applyChapterData">{{ $strings.ButtonApplyChapters }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
|
||||
<!-- create bulk chapters modal -->
|
||||
<modals-modal v-model="showBulkChapterModal" name="bulk-chapters" :width="400">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
|
||||
<p class="text-3xl text-white truncate pointer-events-none">{{ $strings.HeaderBulkChapterModal }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="w-full h-full max-h-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative p-6">
|
||||
<div class="flex flex-col space-y-8">
|
||||
<p class="text-base">{{ $strings.MessageBulkChapterPattern }}</p>
|
||||
|
||||
<div v-if="detectedPattern" class="text-sm text-gray-400 bg-gray-800 p-2 rounded">
|
||||
<strong>{{ $strings.LabelDetectedPattern }}</strong> "{{ detectedPattern.before }}{{ formatNumberWithPadding(detectedPattern.startingNumber, detectedPattern) }}{{ detectedPattern.after }}"
|
||||
<br />
|
||||
<strong>{{ $strings.LabelNextChapters }}</strong>
|
||||
"{{ detectedPattern.before }}{{ formatNumberWithPadding(detectedPattern.startingNumber + 1, detectedPattern) }}{{ detectedPattern.after }}", "{{ detectedPattern.before }}{{ formatNumberWithPadding(detectedPattern.startingNumber + 2, detectedPattern) }}{{ detectedPattern.after }}", etc.
|
||||
</div>
|
||||
<div class="flex px-1 items-center">
|
||||
<label class="text-base font-medium">{{ $strings.LabelNumberOfChapters }}</label>
|
||||
<div class="grow" />
|
||||
<ui-text-input v-model="bulkChapterCount" type="number" min="1" max="50" class="w-14" :style="{ height: `2em` }" @keyup.enter="addBulkChapters" />
|
||||
</div>
|
||||
<div class="flex px-1 items-center">
|
||||
<ui-btn small @click="showBulkChapterModal = false">{{ $strings.ButtonCancel }}</ui-btn>
|
||||
<div class="grow" />
|
||||
<ui-btn small color="bg-success" @click="addBulkChapters">{{ $strings.ButtonAddChapters }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -265,7 +348,17 @@ export default {
|
||||
removeBranding: false,
|
||||
showSecondInputs: false,
|
||||
audibleRegions: ['US', 'CA', 'UK', 'AU', 'FR', 'DE', 'JP', 'IT', 'IN', 'ES'],
|
||||
hasChanges: false
|
||||
hasChanges: false,
|
||||
timeIncrementAmount: 1,
|
||||
elapsedTime: 0,
|
||||
playStartTime: null,
|
||||
elapsedTimeInterval: null,
|
||||
lockedChapters: new Set(),
|
||||
lastSelectedLockIndex: null,
|
||||
bulkChapterInput: '',
|
||||
showBulkChapterModal: false,
|
||||
bulkChapterCount: 1,
|
||||
detectedPattern: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -304,9 +397,18 @@ export default {
|
||||
},
|
||||
selectedChapterId() {
|
||||
return this.selectedChapter ? this.selectedChapter.id : null
|
||||
},
|
||||
allChaptersLocked() {
|
||||
return this.newChapters.length > 0 && this.newChapters.every((chapter) => this.lockedChapters.has(chapter.id))
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
formatNumberWithPadding(number, pattern) {
|
||||
if (!pattern || !pattern.hasLeadingZeros || !pattern.originalPadding) {
|
||||
return number.toString()
|
||||
}
|
||||
return number.toString().padStart(pattern.originalPadding, '0')
|
||||
},
|
||||
setChaptersFromTracks() {
|
||||
let currentStartTime = 0
|
||||
let index = 0
|
||||
@@ -321,7 +423,7 @@ export default {
|
||||
currentStartTime += track.duration
|
||||
}
|
||||
this.newChapters = chapters
|
||||
|
||||
this.lockedChapters = new Set()
|
||||
this.checkChapters()
|
||||
},
|
||||
toggleRemoveBranding() {
|
||||
@@ -334,19 +436,22 @@ export default {
|
||||
|
||||
const amount = Number(this.shiftAmount)
|
||||
|
||||
const lastChapter = this.newChapters[this.newChapters.length - 1]
|
||||
if (lastChapter.start + amount > this.mediaDurationRounded) {
|
||||
this.$toast.error(this.$strings.ToastChaptersInvalidShiftAmountLast)
|
||||
return
|
||||
}
|
||||
// Check if any unlocked chapters would be affected negatively
|
||||
const unlockedChapters = this.newChapters.filter((chap) => !this.lockedChapters.has(chap.id))
|
||||
|
||||
if (this.newChapters[1].start + amount <= 0) {
|
||||
this.$toast.error(this.$strings.ToastChaptersInvalidShiftAmountStart)
|
||||
if (unlockedChapters.length === 0) {
|
||||
this.$toast.warning(this.$strings.ToastChaptersAllLocked)
|
||||
return
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.newChapters.length; i++) {
|
||||
const chap = this.newChapters[i]
|
||||
|
||||
// Skip locked chapters
|
||||
if (this.lockedChapters.has(chap.id)) {
|
||||
continue
|
||||
}
|
||||
|
||||
chap.end = Math.min(chap.end + amount, this.mediaDuration)
|
||||
if (i > 0) {
|
||||
chap.start = Math.max(0, chap.start + amount)
|
||||
@@ -354,6 +459,83 @@ export default {
|
||||
}
|
||||
this.checkChapters()
|
||||
},
|
||||
incrementChapterTime(chapter, amount) {
|
||||
if (chapter.id === 0 && chapter.start + amount < 0) {
|
||||
return
|
||||
}
|
||||
if (chapter.start + amount >= this.mediaDuration) {
|
||||
return
|
||||
}
|
||||
|
||||
chapter.start = Math.max(0, chapter.start + amount)
|
||||
this.checkChapters()
|
||||
},
|
||||
adjustChapterStartTime(chapter) {
|
||||
const newStartTime = chapter.start + this.elapsedTime
|
||||
chapter.start = newStartTime
|
||||
this.checkChapters()
|
||||
this.$toast.success(this.$strings.ToastChapterStartTimeAdjusted.replace('{0}', this.elapsedTime))
|
||||
|
||||
this.destroyAudioEl()
|
||||
},
|
||||
startElapsedTimeTracking() {
|
||||
this.elapsedTime = 0
|
||||
this.playStartTime = Date.now()
|
||||
this.elapsedTimeInterval = setInterval(() => {
|
||||
this.elapsedTime = Math.floor((Date.now() - this.playStartTime) / 1000)
|
||||
}, 100)
|
||||
},
|
||||
stopElapsedTimeTracking() {
|
||||
if (this.elapsedTimeInterval) {
|
||||
clearInterval(this.elapsedTimeInterval)
|
||||
this.elapsedTimeInterval = null
|
||||
}
|
||||
this.elapsedTime = 0
|
||||
this.playStartTime = null
|
||||
},
|
||||
toggleChapterLock(chapter, event) {
|
||||
const chapterId = chapter.id
|
||||
|
||||
if (event.shiftKey && this.lastSelectedLockIndex !== null) {
|
||||
const startIndex = Math.min(this.lastSelectedLockIndex, chapterId)
|
||||
const endIndex = Math.max(this.lastSelectedLockIndex, chapterId)
|
||||
const shouldLock = !this.lockedChapters.has(chapterId)
|
||||
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
if (shouldLock) {
|
||||
this.lockedChapters.add(i)
|
||||
} else {
|
||||
this.lockedChapters.delete(i)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (this.lockedChapters.has(chapterId)) {
|
||||
this.lockedChapters.delete(chapterId)
|
||||
} else {
|
||||
this.lockedChapters.add(chapterId)
|
||||
}
|
||||
}
|
||||
|
||||
this.lastSelectedLockIndex = chapterId
|
||||
this.lockedChapters = new Set(this.lockedChapters)
|
||||
},
|
||||
lockAllChapters() {
|
||||
this.newChapters.forEach((chapter) => {
|
||||
this.lockedChapters.add(chapter.id)
|
||||
})
|
||||
this.lockedChapters = new Set(this.lockedChapters)
|
||||
},
|
||||
unlockAllChapters() {
|
||||
this.lockedChapters.clear()
|
||||
this.lockedChapters = new Set(this.lockedChapters)
|
||||
},
|
||||
toggleAllChaptersLock() {
|
||||
if (this.allChaptersLocked) {
|
||||
this.unlockAllChapters()
|
||||
} else {
|
||||
this.lockAllChapters()
|
||||
}
|
||||
},
|
||||
editItem() {
|
||||
this.$store.commit('showEditModal', this.libraryItem)
|
||||
},
|
||||
@@ -368,6 +550,10 @@ export default {
|
||||
this.checkChapters()
|
||||
},
|
||||
removeChapter(chapter) {
|
||||
if (this.lockedChapters.has(chapter.id)) {
|
||||
this.$toast.warning(this.$strings.ToastChapterLocked)
|
||||
return
|
||||
}
|
||||
this.newChapters = this.newChapters.filter((ch) => ch.id !== chapter.id)
|
||||
this.checkChapters()
|
||||
},
|
||||
@@ -408,6 +594,14 @@ export default {
|
||||
|
||||
this.hasChanges = hasChanges
|
||||
},
|
||||
getAudioTrackForTime(time) {
|
||||
if (typeof time !== 'number') {
|
||||
return null
|
||||
}
|
||||
return this.tracks.find((at) => {
|
||||
return time >= at.startOffset && time < at.startOffset + at.duration
|
||||
})
|
||||
},
|
||||
playChapter(chapter) {
|
||||
console.log('Play Chapter', chapter.id)
|
||||
if (this.selectedChapterId === chapter.id) {
|
||||
@@ -422,9 +616,12 @@ export default {
|
||||
this.destroyAudioEl()
|
||||
}
|
||||
|
||||
const audioTrack = this.tracks.find((at) => {
|
||||
return chapter.start >= at.startOffset && chapter.start < at.startOffset + at.duration
|
||||
})
|
||||
const audioTrack = this.getAudioTrackForTime(chapter.start)
|
||||
if (!audioTrack) {
|
||||
console.error('No audio track found for chapter', chapter)
|
||||
return
|
||||
}
|
||||
|
||||
this.selectedChapter = chapter
|
||||
this.isLoadingChapter = true
|
||||
|
||||
@@ -451,6 +648,7 @@ export default {
|
||||
console.log('Audio playing')
|
||||
this.isLoadingChapter = false
|
||||
this.isPlayingChapter = true
|
||||
this.startElapsedTimeTracking()
|
||||
})
|
||||
audioEl.addEventListener('ended', () => {
|
||||
console.log('Audio ended')
|
||||
@@ -473,6 +671,10 @@ export default {
|
||||
this.selectedChapter = null
|
||||
this.isPlayingChapter = false
|
||||
this.isLoadingChapter = false
|
||||
this.stopElapsedTimeTracking()
|
||||
},
|
||||
resetChapterLookupData() {
|
||||
this.chapterData = null
|
||||
},
|
||||
saveChapters() {
|
||||
this.checkChapters()
|
||||
@@ -506,11 +708,7 @@ export default {
|
||||
this.saving = false
|
||||
if (data.updated) {
|
||||
this.$toast.success(this.$strings.ToastChaptersUpdated)
|
||||
if (this.previousRoute) {
|
||||
this.$router.push(this.previousRoute)
|
||||
} else {
|
||||
this.$router.push(`/item/${this.libraryItem.id}`)
|
||||
}
|
||||
this.reloadLibraryItem()
|
||||
} else {
|
||||
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
||||
}
|
||||
@@ -523,7 +721,7 @@ export default {
|
||||
},
|
||||
applyChapterNamesOnly() {
|
||||
this.newChapters.forEach((chapter, index) => {
|
||||
if (this.chapterData.chapters[index]) {
|
||||
if (this.chapterData.chapters[index] && !this.lockedChapters.has(chapter.id)) {
|
||||
chapter.title = this.chapterData.chapters[index].title
|
||||
}
|
||||
})
|
||||
@@ -535,7 +733,7 @@ export default {
|
||||
},
|
||||
applyChapterData() {
|
||||
let index = 0
|
||||
this.newChapters = this.chapterData.chapters
|
||||
const audibleChapters = this.chapterData.chapters
|
||||
.filter((chap) => chap.startOffsetSec < this.mediaDuration)
|
||||
.map((chap) => {
|
||||
return {
|
||||
@@ -545,6 +743,21 @@ export default {
|
||||
title: chap.title
|
||||
}
|
||||
})
|
||||
|
||||
const merged = []
|
||||
let audibleIdx = 0
|
||||
for (let i = 0; i < Math.max(this.newChapters.length, audibleChapters.length); i++) {
|
||||
const isLocked = this.lockedChapters.has(i)
|
||||
if (isLocked && this.newChapters[i]) {
|
||||
merged.push({ ...this.newChapters[i], id: i })
|
||||
} else if (audibleChapters[audibleIdx]) {
|
||||
merged.push({ ...audibleChapters[audibleIdx], id: i })
|
||||
audibleIdx++
|
||||
} else if (this.newChapters[i]) {
|
||||
merged.push({ ...this.newChapters[i], id: i })
|
||||
}
|
||||
}
|
||||
this.newChapters = merged
|
||||
this.showFindChaptersModal = false
|
||||
this.chapterData = null
|
||||
|
||||
@@ -572,7 +785,7 @@ export default {
|
||||
if (data.error) {
|
||||
this.asinError = this.$getString(data.stringKey)
|
||||
} else {
|
||||
console.log('Chapter data', data)
|
||||
console.log('Chapter data', { ...data })
|
||||
this.chapterData = this.removeBranding ? this.removeBrandingFromData(data) : data
|
||||
}
|
||||
})
|
||||
@@ -609,6 +822,11 @@ export default {
|
||||
data.chapters.pop()
|
||||
}
|
||||
|
||||
// Remove Branding durations from Runtime totals
|
||||
data.runtimeLengthMs -= introDuration + outroDuration
|
||||
data.runtimeLengthSec = Math.floor(data.runtimeLengthMs / 1000)
|
||||
console.log('Brandless Chapter data', data)
|
||||
|
||||
return data
|
||||
} catch {
|
||||
return data
|
||||
@@ -638,6 +856,7 @@ export default {
|
||||
}
|
||||
]
|
||||
}
|
||||
this.lockedChapters = new Set()
|
||||
this.checkChapters()
|
||||
},
|
||||
removeAllChaptersClick() {
|
||||
@@ -662,11 +881,7 @@ export default {
|
||||
.then((data) => {
|
||||
if (data.updated) {
|
||||
this.$toast.success(this.$strings.ToastChaptersRemoved)
|
||||
if (this.previousRoute) {
|
||||
this.$router.push(this.previousRoute)
|
||||
} else {
|
||||
this.$router.push(`/item/${this.libraryItem.id}`)
|
||||
}
|
||||
this.reloadLibraryItem()
|
||||
} else {
|
||||
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
||||
}
|
||||
@@ -679,6 +894,91 @@ export default {
|
||||
this.saving = false
|
||||
})
|
||||
},
|
||||
handleBulkChapterAdd() {
|
||||
const input = this.bulkChapterInput.trim()
|
||||
if (!input) return
|
||||
|
||||
const numberMatch = input.match(/(\d+)/)
|
||||
|
||||
if (numberMatch) {
|
||||
// Extract the base pattern and number, preserving zero-padding
|
||||
const originalNumberString = numberMatch[1]
|
||||
const foundNumber = parseInt(originalNumberString)
|
||||
const numberIndex = numberMatch.index
|
||||
const beforeNumber = input.substring(0, numberIndex)
|
||||
const afterNumber = input.substring(numberIndex + originalNumberString.length)
|
||||
|
||||
this.detectedPattern = {
|
||||
before: beforeNumber,
|
||||
after: afterNumber,
|
||||
startingNumber: foundNumber,
|
||||
originalPadding: originalNumberString.length,
|
||||
hasLeadingZeros: originalNumberString.length > 1 && originalNumberString.startsWith('0')
|
||||
}
|
||||
|
||||
this.bulkChapterCount = 1
|
||||
this.showBulkChapterModal = true
|
||||
} else {
|
||||
this.addSingleChapterFromInput(input)
|
||||
}
|
||||
},
|
||||
addSingleChapterFromInput(title) {
|
||||
// Find the last chapter to determine where to add the new one
|
||||
const lastChapter = this.newChapters[this.newChapters.length - 1]
|
||||
const newStart = lastChapter ? lastChapter.end : 0
|
||||
const newEnd = Math.min(newStart + 300, this.mediaDuration)
|
||||
|
||||
const newChapter = {
|
||||
id: this.newChapters.length,
|
||||
start: newStart,
|
||||
end: newEnd,
|
||||
title: title
|
||||
}
|
||||
|
||||
this.newChapters.push(newChapter)
|
||||
this.bulkChapterInput = ''
|
||||
this.checkChapters()
|
||||
},
|
||||
|
||||
addBulkChapters() {
|
||||
const count = parseInt(this.bulkChapterCount)
|
||||
if (!count || count < 1 || count > 150) {
|
||||
this.$toast.error(this.$strings.ToastBulkChapterInvalidCount)
|
||||
return
|
||||
}
|
||||
|
||||
const { before, after, startingNumber, originalPadding, hasLeadingZeros } = this.detectedPattern
|
||||
const lastChapter = this.newChapters[this.newChapters.length - 1]
|
||||
const baseStart = lastChapter ? lastChapter.start + 1 : 0
|
||||
|
||||
// Add multiple chapters with the detected pattern
|
||||
for (let i = 0; i < count; i++) {
|
||||
const chapterNumber = startingNumber + i
|
||||
let formattedNumber = chapterNumber.toString()
|
||||
|
||||
// Apply zero-padding if the original had leading zeros
|
||||
if (hasLeadingZeros && originalPadding > 1) {
|
||||
formattedNumber = chapterNumber.toString().padStart(originalPadding, '0')
|
||||
}
|
||||
|
||||
const newStart = baseStart + i
|
||||
const newEnd = Math.min(newStart + i + i, this.mediaDuration)
|
||||
|
||||
const newChapter = {
|
||||
id: this.newChapters.length,
|
||||
start: newStart,
|
||||
end: newEnd,
|
||||
title: `${before}${formattedNumber}${after}`
|
||||
}
|
||||
|
||||
this.newChapters.push(newChapter)
|
||||
}
|
||||
|
||||
this.bulkChapterInput = ''
|
||||
this.showBulkChapterModal = false
|
||||
this.detectedPattern = null
|
||||
this.checkChapters()
|
||||
},
|
||||
libraryItemUpdated(libraryItem) {
|
||||
if (libraryItem.id === this.libraryItem.id) {
|
||||
if (!!libraryItem.media.metadata.asin && this.mediaMetadata.asin !== libraryItem.media.metadata.asin) {
|
||||
@@ -686,6 +986,18 @@ export default {
|
||||
}
|
||||
this.libraryItem = libraryItem
|
||||
}
|
||||
},
|
||||
reloadLibraryItem() {
|
||||
this.$axios
|
||||
.$get(`/api/items/${this.libraryItem.id}?expanded=1`)
|
||||
.then((data) => {
|
||||
this.libraryItem = data
|
||||
this.initChapters()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to reload library item', error)
|
||||
this.$toast.error(this.$strings.ToastFailedToLoadData)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -53,6 +53,7 @@ export default {
|
||||
else if (pageName === 'sessions') return this.$strings.HeaderListeningSessions
|
||||
else if (pageName === 'stats') return this.$strings.HeaderYourStats
|
||||
else if (pageName === 'users') return this.$strings.HeaderUsers
|
||||
else if (pageName === 'api-keys') return this.$strings.HeaderApiKeys
|
||||
else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
|
||||
else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds
|
||||
else if (pageName === 'email') return this.$strings.HeaderEmail
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div>
|
||||
<app-settings-content :header-text="$strings.HeaderApiKeys">
|
||||
<template #header-items>
|
||||
<div v-if="numApiKeys" class="mx-2 px-1.5 rounded-lg bg-primary/50 text-gray-300/90 text-sm inline-flex items-center justify-center">
|
||||
<span>{{ numApiKeys }}</span>
|
||||
</div>
|
||||
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/api-keys" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
<div class="grow" />
|
||||
|
||||
<ui-btn color="bg-primary" :disabled="loadingUsers || users.length === 0" small @click="setShowApiKeyModal()">{{ $strings.ButtonAddApiKey }}</ui-btn>
|
||||
</template>
|
||||
|
||||
<tables-api-keys-table ref="apiKeysTable" class="pt-2" @edit="setShowApiKeyModal" @numApiKeys="(count) => (numApiKeys = count)" />
|
||||
</app-settings-content>
|
||||
<modals-api-key-modal ref="apiKeyModal" v-model="showApiKeyModal" :api-key="selectedApiKey" :users="users" @created="apiKeyCreated" @updated="apiKeyUpdated" />
|
||||
<modals-api-key-created-modal ref="apiKeyCreatedModal" v-model="showApiKeyCreatedModal" :api-key="selectedApiKey" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ store, redirect }) {
|
||||
if (!store.getters['user/getIsAdminOrUp']) {
|
||||
redirect('/')
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loadingUsers: false,
|
||||
selectedApiKey: null,
|
||||
showApiKeyModal: false,
|
||||
showApiKeyCreatedModal: false,
|
||||
numApiKeys: 0,
|
||||
users: []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
apiKeyCreated(apiKey) {
|
||||
this.numApiKeys++
|
||||
this.selectedApiKey = apiKey
|
||||
this.showApiKeyCreatedModal = true
|
||||
if (this.$refs.apiKeysTable) {
|
||||
this.$refs.apiKeysTable.addApiKey(apiKey)
|
||||
}
|
||||
},
|
||||
apiKeyUpdated(apiKey) {
|
||||
if (this.$refs.apiKeysTable) {
|
||||
this.$refs.apiKeysTable.updateApiKey(apiKey)
|
||||
}
|
||||
},
|
||||
setShowApiKeyModal(selectedApiKey) {
|
||||
this.selectedApiKey = selectedApiKey
|
||||
this.showApiKeyModal = true
|
||||
},
|
||||
loadUsers() {
|
||||
this.loadingUsers = true
|
||||
this.$axios
|
||||
.$get('/api/users')
|
||||
.then((res) => {
|
||||
this.users = res.users.sort((a, b) => {
|
||||
return a.createdAt - b.createdAt
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
})
|
||||
.finally(() => {
|
||||
this.loadingUsers = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadUsers()
|
||||
},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
@@ -131,35 +131,26 @@
|
||||
</div>
|
||||
|
||||
<div class="grow py-2">
|
||||
<ui-dropdown :label="$strings.LabelSettingsDateFormat" v-model="newServerSettings.dateFormat" :items="dateFormats" small class="max-w-52" @input="(val) => updateSettingsKey('dateFormat', val)" />
|
||||
<ui-dropdown :label="$strings.LabelSettingsDateFormat" v-model="newServerSettings.dateFormat" :items="dateFormats" small class="max-w-72" @input="(val) => updateSettingsKey('dateFormat', val)" />
|
||||
<p class="text-xs ml-1 text-white/60">{{ $strings.LabelExample }}: {{ dateExample }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grow py-2">
|
||||
<ui-dropdown :label="$strings.LabelSettingsTimeFormat" v-model="newServerSettings.timeFormat" :items="timeFormats" small class="max-w-52" @input="(val) => updateSettingsKey('timeFormat', val)" />
|
||||
<ui-dropdown :label="$strings.LabelSettingsTimeFormat" v-model="newServerSettings.timeFormat" :items="timeFormats" small class="max-w-72" @input="(val) => updateSettingsKey('timeFormat', val)" />
|
||||
<p class="text-xs ml-1 text-white/60">{{ $strings.LabelExample }}: {{ timeExample }}</p>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<ui-dropdown :label="$strings.LabelLanguageDefaultServer" ref="langDropdown" v-model="newServerSettings.language" :items="$languageCodeOptions" small class="max-w-52" @input="updateServerLanguage" />
|
||||
<ui-dropdown :label="$strings.LabelLanguageDefaultServer" ref="langDropdown" v-model="newServerSettings.language" :items="$languageCodeOptions" small class="max-w-72" @input="updateServerLanguage" />
|
||||
</div>
|
||||
|
||||
<!-- old experimental features -->
|
||||
<!-- <div class="pt-4">
|
||||
<h2 class="font-semibold">{{ $strings.HeaderSettingsExperimental }}</h2>
|
||||
<div class="pt-4">
|
||||
<h2 class="font-semibold">{{ $strings.HeaderSettingsSecurity }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-experimental-features" v-model="showExperimentalFeatures" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsExperimentalFeaturesHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-experimental-features">{{ $strings.LabelSettingsExperimentalFeatures }}</span>
|
||||
<a :aria-label="$strings.LabelSettingsExperimentalFeaturesHelp" href="https://github.com/advplyr/audiobookshelf/discussions/75" target="_blank">
|
||||
<span class="material-symbols icon-text">info</span>
|
||||
</a>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div> -->
|
||||
<div class="py-2">
|
||||
<ui-multi-select v-model="newServerSettings.allowedOrigins" :items="newServerSettings.allowedOrigins" :label="$strings.LabelCorsAllowed" class="max-w-72" @input="updateCorsOrigins" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</app-settings-content>
|
||||
@@ -256,7 +247,8 @@ export default {
|
||||
return this.$store.state.serverSettings
|
||||
},
|
||||
providers() {
|
||||
return this.$store.state.scanners.providers
|
||||
// Use book cover providers for the cover provider dropdown
|
||||
return this.$store.state.scanners.bookCoverProviders || []
|
||||
},
|
||||
dateFormats() {
|
||||
return this.$store.state.globals.dateFormats
|
||||
@@ -323,6 +315,27 @@ export default {
|
||||
updateServerLanguage(val) {
|
||||
this.updateSettingsKey('language', val)
|
||||
},
|
||||
updateCorsOrigins(val) {
|
||||
const validOrigins = []
|
||||
const invalidOrigins = []
|
||||
|
||||
val.forEach((origin) => {
|
||||
const trimmedOrigin = origin.trim().toLowerCase()
|
||||
try {
|
||||
new URL(trimmedOrigin)
|
||||
validOrigins.push(trimmedOrigin)
|
||||
} catch {
|
||||
invalidOrigins.push(trimmedOrigin)
|
||||
}
|
||||
})
|
||||
|
||||
if (invalidOrigins.length > 0) {
|
||||
this.$toast.error(this.$strings.ToastInvalidUrls)
|
||||
}
|
||||
|
||||
this.newServerSettings.allowedOrigins = validOrigins
|
||||
this.updateSettingsKey('allowedOrigins', validOrigins)
|
||||
},
|
||||
updateSettingsKey(key, val) {
|
||||
if (key === 'scannerDisableWatcher') {
|
||||
this.newServerSettings.scannerDisableWatcher = val
|
||||
@@ -352,6 +365,7 @@ export default {
|
||||
initServerSettings() {
|
||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||
this.newServerSettings.sortingPrefixes = [...(this.newServerSettings.sortingPrefixes || [])]
|
||||
this.newServerSettings.allowedOrigins = [...(this.newServerSettings.allowedOrigins || [])]
|
||||
this.scannerEnableWatcher = !this.newServerSettings.scannerDisableWatcher
|
||||
|
||||
this.homepageUseBookshelfView = this.newServerSettings.homeBookshelfView != this.$constants.BookshelfView.DETAIL
|
||||
@@ -376,8 +390,8 @@ export default {
|
||||
},
|
||||
purgeItemsCache() {
|
||||
const payload = {
|
||||
// message: `This will delete the entire folder at <code>/metadata/cache/items</code>.<br />Are you sure you want to purge items cache?`,
|
||||
message: this.$strings.MessageConfirmPurgeItemsCache,
|
||||
allowHtml: true,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.sendPurgeItemsCache()
|
||||
@@ -403,6 +417,8 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.initServerSettings()
|
||||
// Fetch providers if not already loaded (for cover provider dropdown)
|
||||
this.$store.dispatch('scanners/fetchProviders')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -90,9 +90,9 @@ export default {
|
||||
|
||||
let message = this.$getString('MessageConfirmRenameGenre', [this.editingGenre, this.newGenreName])
|
||||
if (genreNameExists) {
|
||||
message += `<br><span class="text-sm">${this.$strings.MessageConfirmRenameGenreMergeNote}</span>`
|
||||
message += ` ${this.$strings.MessageConfirmRenameGenreMergeNote}`
|
||||
} else if (genreNameExistsOfDifferentCase) {
|
||||
message += `<br><span class="text-warning text-sm">${this.$getString('MessageConfirmRenameGenreWarning', [genreNameExistsOfDifferentCase])}</span>`
|
||||
message += ` ${this.$getString('MessageConfirmRenameGenreWarning', [genreNameExistsOfDifferentCase])}`
|
||||
}
|
||||
|
||||
const payload = {
|
||||
|
||||
@@ -86,9 +86,9 @@ export default {
|
||||
|
||||
let message = this.$getString('MessageConfirmRenameTag', [this.editingTag, this.newTagName])
|
||||
if (tagNameExists) {
|
||||
message += `<br><span class="text-sm">${this.$strings.MessageConfirmRenameTagMergeNote}</span>`
|
||||
message += ` ${this.$strings.MessageConfirmRenameTagMergeNote}`
|
||||
} else if (tagNameExistsOfDifferentCase) {
|
||||
message += `<br><span class="text-warning text-sm">${this.$getString('MessageConfirmRenameTagWarning', [tagNameExistsOfDifferentCase])}</span>`
|
||||
message += ` ${this.$getString('MessageConfirmRenameTagWarning', [tagNameExistsOfDifferentCase])}`
|
||||
}
|
||||
|
||||
const payload = {
|
||||
|
||||
@@ -78,10 +78,10 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
return this.$store.getters['getServerSetting']('timeFormat')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -6,80 +6,86 @@
|
||||
</div>
|
||||
|
||||
<div v-if="listeningSessions.length" class="block max-w-full relative">
|
||||
<table class="userSessionsTable">
|
||||
<tr class="bg-primary/40">
|
||||
<th class="w-6 min-w-6 text-left hidden md:table-cell h-11">
|
||||
<ui-checkbox v-model="isAllSelected" :partial="numSelected > 0 && !isAllSelected" small checkbox-bg="bg" />
|
||||
</th>
|
||||
<th v-if="numSelected" class="grow text-left" :colspan="7">
|
||||
<div class="flex items-center">
|
||||
<p>{{ $getString('MessageSelected', [numSelected]) }}</p>
|
||||
<div class="grow" />
|
||||
<ui-btn small color="bg-error" :loading="deletingSessions" @click.stop="removeSessionsClick">{{ $strings.ButtonRemove }}</ui-btn>
|
||||
</div>
|
||||
</th>
|
||||
<th v-if="!numSelected" class="grow sm:grow-0 sm:w-48 sm:max-w-48 text-left group cursor-pointer" @click.stop="sortColumn('displayTitle')">
|
||||
<div class="inline-flex items-center">
|
||||
{{ $strings.LabelItem }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('displayTitle') }" class="material-symbols text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th v-if="!numSelected" class="w-20 min-w-20 text-left hidden md:table-cell">{{ $strings.LabelUser }}</th>
|
||||
<th v-if="!numSelected" class="w-26 min-w-26 text-left hidden md:table-cell group cursor-pointer" @click.stop="sortColumn('playMethod')">
|
||||
<div class="inline-flex items-center">
|
||||
{{ $strings.LabelPlayMethod }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('playMethod') }" class="material-symbols text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th v-if="!numSelected" class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th>
|
||||
<th v-if="!numSelected" class="w-24 min-w-24 sm:w-32 sm:min-w-32 group cursor-pointer" @click.stop="sortColumn('timeListening')">
|
||||
<div class="inline-flex items-center">
|
||||
{{ $strings.LabelTimeListened }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('timeListening') }" class="material-symbols text-base pl-px hidden sm:inline-block">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th v-if="!numSelected" class="w-24 min-w-24 group cursor-pointer" @click.stop="sortColumn('currentTime')">
|
||||
<div class="inline-flex items-center">
|
||||
{{ $strings.LabelLastTime }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('currentTime') }" class="material-symbols text-base pl-px hidden sm:inline-block">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th v-if="!numSelected" class="grow hidden sm:table-cell cursor-pointer group" @click.stop="sortColumn('updatedAt')">
|
||||
<div class="inline-flex items-center">
|
||||
{{ $strings.LabelLastUpdate }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('updatedAt') }" class="material-symbols text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="userSessionsTable">
|
||||
<tr class="bg-primary/40">
|
||||
<th class="w-6 min-w-6 text-left hidden md:table-cell h-11">
|
||||
<ui-checkbox v-model="isAllSelected" :partial="numSelected > 0 && !isAllSelected" small checkbox-bg="bg" />
|
||||
</th>
|
||||
<th v-if="numSelected" class="grow text-left" :colspan="7">
|
||||
<div class="flex items-center">
|
||||
<p>{{ $getString('MessageSelected', [numSelected]) }}</p>
|
||||
<div class="grow" />
|
||||
<ui-btn small color="bg-error" :loading="deletingSessions" @click.stop="removeSessionsClick">{{ $strings.ButtonRemove }}</ui-btn>
|
||||
</div>
|
||||
</th>
|
||||
<th v-if="!numSelected" class="grow sm:grow-0 sm:w-48 sm:max-w-48 text-left group cursor-pointer" @click.stop="sortColumn('displayTitle')">
|
||||
<div class="inline-flex items-center">
|
||||
{{ $strings.LabelItem }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('displayTitle') }" class="material-symbols text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th v-if="!numSelected" class="w-20 min-w-20 text-left hidden md:table-cell">{{ $strings.LabelUser }}</th>
|
||||
<th v-if="!numSelected" class="w-26 min-w-26 text-left hidden md:table-cell group cursor-pointer" @click.stop="sortColumn('playMethod')">
|
||||
<div class="inline-flex items-center">
|
||||
{{ $strings.LabelPlayMethod }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('playMethod') }" class="material-symbols text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th v-if="!numSelected" class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th>
|
||||
<th v-if="!numSelected" class="w-24 min-w-24 sm:w-32 sm:min-w-32 group cursor-pointer" @click.stop="sortColumn('timeListening')">
|
||||
<div class="inline-flex items-center">
|
||||
{{ $strings.LabelTimeListened }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('timeListening') }" class="material-symbols text-base pl-px hidden sm:inline-block">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th v-if="!numSelected" class="w-24 min-w-24 group cursor-pointer" @click.stop="sortColumn('currentTime')">
|
||||
<div class="inline-flex items-center">
|
||||
{{ $strings.LabelLastTime }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('currentTime') }" class="material-symbols text-base pl-px hidden sm:inline-block">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th v-if="!numSelected" class="grow hidden sm:table-cell cursor-pointer group" @click.stop="sortColumn('updatedAt')">
|
||||
<div class="inline-flex items-center">
|
||||
{{ $strings.LabelLastUpdate }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('updatedAt') }" class="material-symbols text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
<tr v-for="session in listeningSessions" :key="session.id" :class="{ selected: session.selected }" class="cursor-pointer" @click="clickSessionRow(session)">
|
||||
<td class="hidden md:table-cell py-1 max-w-6 relative">
|
||||
<ui-checkbox v-model="session.selected" small checkbox-bg="bg" />
|
||||
<!-- overlay of the checkbox so that the entire box is clickable -->
|
||||
<div class="absolute inset-0 w-full h-full" @click.stop="session.selected = !session.selected" />
|
||||
</td>
|
||||
<td class="py-1 grow sm:grow-0 sm:w-48 sm:max-w-48">
|
||||
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
|
||||
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
|
||||
</td>
|
||||
<td class="hidden md:table-cell w-20 min-w-20">
|
||||
<p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p>
|
||||
<p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p>
|
||||
</td>
|
||||
<td class="hidden md:table-cell w-26 min-w-26">
|
||||
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
|
||||
</td>
|
||||
<td class="hidden sm:table-cell max-w-32 min-w-32">
|
||||
<p class="text-xs truncate" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||
</td>
|
||||
<td class="text-center w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
|
||||
</td>
|
||||
<td class="text-center hover:underline w-24 min-w-24" @click.stop="clickCurrentTime(session)">
|
||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
|
||||
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<tr v-for="session in listeningSessions" :key="session.id" :class="{ selected: session.selected }" class="cursor-pointer" @click="clickSessionRow(session)">
|
||||
<td class="hidden md:table-cell py-1 max-w-6 relative">
|
||||
<ui-checkbox v-model="session.selected" small checkbox-bg="bg" />
|
||||
<!-- overlay of the checkbox so that the entire box is clickable -->
|
||||
<div class="absolute inset-0 w-full h-full" @click.stop="session.selected = !session.selected" />
|
||||
</td>
|
||||
<td class="py-1 grow sm:grow-0 sm:w-48 sm:max-w-48">
|
||||
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
|
||||
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
|
||||
</td>
|
||||
<td class="hidden md:table-cell w-20 min-w-20">
|
||||
<p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p>
|
||||
<p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p>
|
||||
</td>
|
||||
<td class="hidden md:table-cell w-26 min-w-26">
|
||||
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
|
||||
</td>
|
||||
<td class="hidden sm:table-cell max-w-32 min-w-32">
|
||||
<p class="text-xs truncate">
|
||||
<template v-for="(line, index) in getDeviceInfoLines(session.deviceInfo)">
|
||||
<br v-if="index > 0" :key="'br-' + index" />{{ line }}
|
||||
</template>
|
||||
</p>
|
||||
</td>
|
||||
<td class="text-center w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<p class="text-xs font-mono">{{ $elapsedPrettyLocalized(session.timeListening) }}</p>
|
||||
</td>
|
||||
<td class="text-center hover:underline w-24 min-w-24" @click.stop="clickCurrentTime(session)">
|
||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
|
||||
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!-- table bottom options -->
|
||||
<div class="flex items-center my-2">
|
||||
<div class="grow" />
|
||||
@@ -128,7 +134,11 @@
|
||||
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
|
||||
</td>
|
||||
<td class="hidden sm:table-cell max-w-32 min-w-32">
|
||||
<p class="text-xs truncate" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||
<p class="text-xs truncate">
|
||||
<template v-for="(line, index) in getDeviceInfoLines(session.deviceInfo)">
|
||||
<br v-if="index > 0" :key="'br-' + index" />{{ line }}
|
||||
</template>
|
||||
</p>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
|
||||
@@ -170,7 +180,11 @@
|
||||
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
|
||||
</td>
|
||||
<td class="hidden sm:table-cell max-w-32 min-w-32">
|
||||
<p class="text-xs truncate" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||
<p class="text-xs truncate">
|
||||
<template v-for="(line, index) in getDeviceInfoLines(session.deviceInfo)">
|
||||
<br v-if="index > 0" :key="'br-' + index" />{{ line }}
|
||||
</template>
|
||||
</p>
|
||||
</td>
|
||||
<td class="text-center hover:underline" @click.stop="clickCurrentTime(session)">
|
||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
@@ -250,10 +264,10 @@ export default {
|
||||
return user?.username || null
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
return this.$store.getters['getServerSetting']('timeFormat')
|
||||
},
|
||||
numSelected() {
|
||||
return this.listeningSessions.filter((s) => s.selected).length
|
||||
@@ -431,16 +445,16 @@ export default {
|
||||
this.selectedSession = session
|
||||
this.showSessionModal = true
|
||||
},
|
||||
getDeviceInfoString(deviceInfo) {
|
||||
if (!deviceInfo) return ''
|
||||
var lines = []
|
||||
getDeviceInfoLines(deviceInfo) {
|
||||
if (!deviceInfo) return []
|
||||
const lines = []
|
||||
if (deviceInfo.clientName) lines.push(`${deviceInfo.clientName} ${deviceInfo.clientVersion || ''}`)
|
||||
if (deviceInfo.osName) lines.push(`${deviceInfo.osName} ${deviceInfo.osVersion}`)
|
||||
if (deviceInfo.browserName) lines.push(deviceInfo.browserName)
|
||||
|
||||
if (deviceInfo.manufacturer && deviceInfo.model) lines.push(`${deviceInfo.manufacturer} ${deviceInfo.model}`)
|
||||
if (deviceInfo.sdkVersion) lines.push(`SDK Version: ${deviceInfo.sdkVersion}`)
|
||||
return lines.join('<br>')
|
||||
return lines
|
||||
},
|
||||
getPlayMethodName(playMethod) {
|
||||
if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play'
|
||||
|
||||
@@ -13,8 +13,10 @@
|
||||
<widgets-online-indicator :value="!!userOnline" />
|
||||
<h1 class="text-xl pl-2">{{ username }}</h1>
|
||||
</div>
|
||||
<div v-if="userToken" class="flex text-xs mt-4">
|
||||
<ui-text-input-with-label :label="$strings.LabelApiToken" :value="userToken" readonly show-copy />
|
||||
<div v-if="legacyToken" class="text-xs space-y-2 mt-4">
|
||||
<ui-text-input-with-label label="Legacy API Token" :value="legacyToken" readonly show-copy />
|
||||
|
||||
<p class="text-warning" v-html="$strings.MessageAuthenticationLegacyTokenWarning" />
|
||||
</div>
|
||||
<div class="w-full h-px bg-white/10 my-2" />
|
||||
<div class="py-2">
|
||||
@@ -100,9 +102,12 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
legacyToken() {
|
||||
return this.user.token
|
||||
},
|
||||
userToken() {
|
||||
return this.user.accessToken
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
@@ -129,10 +134,10 @@ export default {
|
||||
return this.listeningSessions.sessions[0]
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
return this.$store.getters['getServerSetting']('timeFormat')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -19,39 +19,45 @@
|
||||
<div class="py-2">
|
||||
<h1 class="text-lg mb-2 text-white/90 px-2 sm:px-0">{{ $strings.HeaderListeningSessions }}</h1>
|
||||
<div v-if="listeningSessions.length">
|
||||
<table class="userSessionsTable">
|
||||
<tr class="bg-primary/40">
|
||||
<th class="w-48 min-w-48 text-left">{{ $strings.LabelItem }}</th>
|
||||
<th class="w-32 min-w-32 text-left hidden md:table-cell">{{ $strings.LabelPlayMethod }}</th>
|
||||
<th class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th>
|
||||
<th class="w-32 min-w-32">{{ $strings.LabelTimeListened }}</th>
|
||||
<th class="w-16 min-w-16">{{ $strings.LabelLastTime }}</th>
|
||||
<th class="grow hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th>
|
||||
</tr>
|
||||
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
|
||||
<td class="py-1 max-w-48">
|
||||
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
|
||||
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="userSessionsTable">
|
||||
<tr class="bg-primary/40">
|
||||
<th class="w-48 min-w-48 text-left">{{ $strings.LabelItem }}</th>
|
||||
<th class="w-32 min-w-32 text-left hidden md:table-cell">{{ $strings.LabelPlayMethod }}</th>
|
||||
<th class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th>
|
||||
<th class="w-32 min-w-32">{{ $strings.LabelTimeListened }}</th>
|
||||
<th class="w-16 min-w-16">{{ $strings.LabelLastTime }}</th>
|
||||
<th class="grow hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th>
|
||||
</tr>
|
||||
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
|
||||
<td class="py-1 max-w-48">
|
||||
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
|
||||
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
|
||||
</td>
|
||||
<td class="hidden md:table-cell">
|
||||
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
|
||||
</td>
|
||||
<td class="hidden sm:table-cell min-w-32 max-w-32">
|
||||
<p class="text-xs truncate">
|
||||
<template v-for="(line, index) in getDeviceInfoLines(session.deviceInfo)">
|
||||
<br v-if="index > 0" :key="'br-' + index" />{{ line }}
|
||||
</template>
|
||||
</p>
|
||||
</td>
|
||||
<td class="hidden md:table-cell">
|
||||
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
|
||||
</td>
|
||||
<td class="hidden sm:table-cell min-w-32 max-w-32">
|
||||
<p class="text-xs truncate" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
|
||||
</td>
|
||||
<td class="text-center hover:underline" @click.stop="clickCurrentTime(session)">
|
||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
|
||||
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<td class="text-center">
|
||||
<p class="text-xs font-mono">{{ $elapsedPrettyLocalized(session.timeListening) }}</p>
|
||||
</td>
|
||||
<td class="text-center hover:underline" @click.stop="clickCurrentTime(session)">
|
||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
|
||||
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="flex items-center justify-end py-1">
|
||||
<ui-icon-btn icon="arrow_back_ios_new" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage === 0" @click="prevPage" />
|
||||
<p class="text-sm mx-1">{{ $getString('LabelPaginationPageXOfY', [currentPage + 1, numPages]) }}</p>
|
||||
@@ -98,10 +104,10 @@ export default {
|
||||
return this.$store.getters['users/getIsUserOnline'](this.user.id)
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
return this.$store.getters['getServerSetting']('timeFormat')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -191,16 +197,16 @@ export default {
|
||||
this.selectedSession = session
|
||||
this.showSessionModal = true
|
||||
},
|
||||
getDeviceInfoString(deviceInfo) {
|
||||
if (!deviceInfo) return ''
|
||||
var lines = []
|
||||
getDeviceInfoLines(deviceInfo) {
|
||||
if (!deviceInfo) return []
|
||||
const lines = []
|
||||
if (deviceInfo.clientName) lines.push(`${deviceInfo.clientName} ${deviceInfo.clientVersion || ''}`)
|
||||
if (deviceInfo.osName) lines.push(`${deviceInfo.osName} ${deviceInfo.osVersion}`)
|
||||
if (deviceInfo.browserName) lines.push(deviceInfo.browserName)
|
||||
|
||||
if (deviceInfo.manufacturer && deviceInfo.model) lines.push(`${deviceInfo.manufacturer} ${deviceInfo.model}`)
|
||||
if (deviceInfo.sdkVersion) lines.push(`SDK Version: ${deviceInfo.sdkVersion}`)
|
||||
return lines.join('<br>')
|
||||
return lines
|
||||
},
|
||||
getPlayMethodName(playMethod) {
|
||||
if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play'
|
||||
|
||||
@@ -193,7 +193,7 @@ export default {
|
||||
return `${process.env.serverUrl}/api/items/${this.libraryItemId}/download?token=${this.userToken}`
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</tr>
|
||||
<tr v-for="narrator in narrators" :key="narrator.id">
|
||||
<td>
|
||||
<p v-if="selectedNarrator?.id !== narrator.id" class="text-sm md:text-base text-gray-100">{{ narrator.name }}</p>
|
||||
<nuxt-link v-if="selectedNarrator?.id !== narrator.id" :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${narrator.id}`" class="text-sm md:text-base text-gray-100 hover:underline">{{ narrator.name }}</nuxt-link>
|
||||
<form v-else @submit.prevent="saveClick">
|
||||
<ui-text-input v-model="newNarratorName" />
|
||||
</form>
|
||||
|
||||
@@ -141,7 +141,7 @@ export default {
|
||||
return episodeIds
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
+45
-13
@@ -17,9 +17,9 @@
|
||||
|
||||
<form @submit.prevent="submitServerSetup">
|
||||
<p class="text-lg font-semibold mb-2 pl-1 text-center">Create Root User</p>
|
||||
<ui-text-input-with-label v-model.trim="newRoot.username" label="Username" :disabled="processing" class="w-full mb-3 text-sm" />
|
||||
<ui-text-input-with-label v-model="newRoot.password" label="Password" type="password" :disabled="processing" class="w-full mb-3 text-sm" />
|
||||
<ui-text-input-with-label v-model="confirmPassword" label="Confirm Password" type="password" :disabled="processing" class="w-full mb-3 text-sm" />
|
||||
<ui-text-input-with-label v-model.trim="newRoot.username" label="Username" autocomplete="username" :disabled="processing" class="w-full mb-3 text-sm" />
|
||||
<ui-text-input-with-label v-model="newRoot.password" label="Password" type="password" autocomplete="new-password" :disabled="processing" class="w-full mb-3 text-sm" />
|
||||
<ui-text-input-with-label v-model="confirmPassword" label="Confirm Password" type="password" autocomplete="new-password" :disabled="processing" class="w-full mb-3 text-sm" />
|
||||
|
||||
<p class="text-lg font-semibold mt-6 mb-2 pl-1 text-center">Directory Paths</p>
|
||||
<ui-text-input-with-label v-model="ConfigPath" label="Config Path" disabled class="w-full mb-3 text-sm" />
|
||||
@@ -40,12 +40,21 @@
|
||||
|
||||
<p v-if="error" class="text-error text-center py-2">{{ error }}</p>
|
||||
|
||||
<div v-if="showNewAuthSystemMessage" class="mb-4">
|
||||
<widgets-alert type="warning">
|
||||
<div>
|
||||
<p>{{ $strings.MessageAuthenticationSecurityMessage }}</p>
|
||||
<a v-if="showNewAuthSystemAdminMessage" href="https://github.com/advplyr/audiobookshelf/discussions/4460" target="_blank" class="underline">{{ $strings.LabelMoreInfo }}</a>
|
||||
</div>
|
||||
</widgets-alert>
|
||||
</div>
|
||||
|
||||
<form v-show="login_local" @submit.prevent="submitForm">
|
||||
<label class="text-xs text-gray-300 uppercase">{{ $strings.LabelUsername }}</label>
|
||||
<ui-text-input v-model.trim="username" :disabled="processing" class="mb-3 w-full" inputName="username" />
|
||||
<ui-text-input v-model.trim="username" autocomplete="username" :disabled="processing" class="mb-3 w-full" inputName="username" />
|
||||
|
||||
<label class="text-xs text-gray-300 uppercase">{{ $strings.LabelPassword }}</label>
|
||||
<ui-text-input v-model.trim="password" type="password" :disabled="processing" class="w-full mb-3" inputName="password" />
|
||||
<ui-text-input v-model.trim="password" type="password" autocomplete="current-password" :disabled="processing" class="w-full mb-3" inputName="password" />
|
||||
<div class="w-full flex justify-end py-3">
|
||||
<ui-btn type="submit" :disabled="processing" color="bg-primary" class="leading-none">{{ processing ? 'Checking...' : $strings.ButtonSubmit }}</ui-btn>
|
||||
</div>
|
||||
@@ -85,7 +94,10 @@ export default {
|
||||
MetadataPath: '',
|
||||
login_local: true,
|
||||
login_openid: false,
|
||||
authFormData: null
|
||||
authFormData: null,
|
||||
// New JWT auth system re-login flags
|
||||
showNewAuthSystemMessage: false,
|
||||
showNewAuthSystemAdminMessage: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -177,13 +189,20 @@ export default {
|
||||
require('@/plugins/chromecast.js').default(this)
|
||||
}
|
||||
|
||||
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
|
||||
this.$store.commit('libraries/setLastLoad', 0) // Ensure libraries get loaded again when switching users
|
||||
this.$store.commit('libraries/setCurrentLibrary', { id: userDefaultLibraryId })
|
||||
this.$store.commit('user/setUser', user)
|
||||
// Access token only returned from login, not authorize
|
||||
if (user.accessToken) {
|
||||
this.$store.commit('user/setAccessToken', user.accessToken)
|
||||
}
|
||||
|
||||
this.$store.dispatch('user/loadUserSettings')
|
||||
},
|
||||
async submitForm() {
|
||||
this.error = null
|
||||
this.showNewAuthSystemMessage = false
|
||||
this.showNewAuthSystemAdminMessage = false
|
||||
this.processing = true
|
||||
|
||||
const payload = {
|
||||
@@ -210,6 +229,8 @@ export default {
|
||||
|
||||
this.processing = true
|
||||
|
||||
this.$store.commit('user/setAccessToken', token)
|
||||
|
||||
return this.$axios
|
||||
.$post('/api/authorize', null, {
|
||||
headers: {
|
||||
@@ -217,15 +238,25 @@ export default {
|
||||
}
|
||||
})
|
||||
.then((res) => {
|
||||
// Force re-login if user is using an old token with no expiration
|
||||
if (res.user.isOldToken) {
|
||||
this.username = res.user.username
|
||||
this.showNewAuthSystemMessage = true
|
||||
// Admin user sees link to github discussion
|
||||
this.showNewAuthSystemAdminMessage = res.user.type === 'admin' || res.user.type === 'root'
|
||||
return false
|
||||
}
|
||||
|
||||
this.setUser(res)
|
||||
this.processing = false
|
||||
return true
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Authorize error', error)
|
||||
this.processing = false
|
||||
return false
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
checkStatus() {
|
||||
this.processing = true
|
||||
@@ -268,8 +299,8 @@ export default {
|
||||
}
|
||||
|
||||
if (authMethods.includes('openid')) {
|
||||
// Auto redirect unless query string ?autoLaunch=0
|
||||
if (this.authFormData?.authOpenIDAutoLaunch && this.$route.query?.autoLaunch !== '0') {
|
||||
// Auto redirect unless query string ?autoLaunch=0 OR when explicity requested through ?autoLaunch=1
|
||||
if ((this.authFormData?.authOpenIDAutoLaunch && this.$route.query?.autoLaunch !== '0') || this.$route.query?.autoLaunch == '1') {
|
||||
window.location.href = this.openidAuthUri
|
||||
}
|
||||
|
||||
@@ -280,8 +311,9 @@ export default {
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
if (this.$route.query?.setToken) {
|
||||
localStorage.setItem('token', this.$route.query.setToken)
|
||||
// Token passed as query parameter after successful oidc login
|
||||
if (this.$route.query?.accessToken) {
|
||||
localStorage.setItem('token', this.$route.query.accessToken)
|
||||
}
|
||||
if (localStorage.getItem('token')) {
|
||||
if (await this.checkAuth()) return // if valid user no need to check status
|
||||
|
||||
@@ -364,6 +364,7 @@ export default {
|
||||
}
|
||||
|
||||
const startTime = this.playbackSession.currentTime || 0
|
||||
|
||||
this.localAudioPlayer.set(null, this.audioTracks, false, startTime, false)
|
||||
this.localAudioPlayer.on('stateChange', this.playerStateChange.bind(this))
|
||||
this.localAudioPlayer.on('timeupdate', this.playerTimeUpdate.bind(this))
|
||||
|
||||
@@ -155,7 +155,7 @@ export default {
|
||||
},
|
||||
providers() {
|
||||
if (this.selectedLibraryIsPodcast) return this.$store.state.scanners.podcastProviders
|
||||
return this.$store.state.scanners.providers
|
||||
return this.$store.state.scanners.bookProviders
|
||||
},
|
||||
canFetchMetadata() {
|
||||
return !this.selectedLibraryIsPodcast && this.fetchMetadata.enabled
|
||||
@@ -297,6 +297,15 @@ export default {
|
||||
ref.setUploadStatus(status)
|
||||
}
|
||||
},
|
||||
updateItemCardProgress(index, progress) {
|
||||
var ref = this.$refs[`itemCard-${index}`]
|
||||
if (ref && ref.length) ref = ref[0]
|
||||
if (!ref) {
|
||||
console.error('Book card ref not found', index, this.$refs)
|
||||
} else {
|
||||
ref.setUploadProgress(progress)
|
||||
}
|
||||
},
|
||||
async uploadItem(item) {
|
||||
var form = new FormData()
|
||||
form.set('title', item.title)
|
||||
@@ -312,8 +321,20 @@ export default {
|
||||
form.set(`${index++}`, file)
|
||||
})
|
||||
|
||||
const config = {
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.lengthComputable) {
|
||||
const progress = {
|
||||
loaded: progressEvent.loaded,
|
||||
total: progressEvent.total
|
||||
}
|
||||
this.updateItemCardProgress(item.index, progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.$axios
|
||||
.$post('/api/upload', form)
|
||||
.$post('/api/upload', form, config)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
console.error('Failed to upload item', error)
|
||||
@@ -394,6 +415,8 @@ export default {
|
||||
this.setMetadataProvider()
|
||||
|
||||
this.setDefaultFolder()
|
||||
// Fetch providers if not already loaded
|
||||
this.$store.dispatch('scanners/fetchProviders')
|
||||
window.addEventListener('dragenter', this.dragenter)
|
||||
window.addEventListener('dragleave', this.dragleave)
|
||||
window.addEventListener('dragover', this.dragover)
|
||||
|
||||
@@ -46,7 +46,20 @@ export default class LocalAudioPlayer extends EventEmitter {
|
||||
this.player.addEventListener('loadedmetadata', this.evtLoadedMetadata.bind(this))
|
||||
this.player.addEventListener('timeupdate', this.evtTimeupdate.bind(this))
|
||||
|
||||
var mimeTypes = ['audio/flac', 'audio/mpeg', 'audio/mp4', 'audio/ogg', 'audio/aac', 'audio/x-ms-wma', 'audio/x-aiff', 'audio/webm']
|
||||
var mimeTypes = [
|
||||
'audio/flac',
|
||||
'audio/mpeg',
|
||||
'audio/mp4',
|
||||
'audio/ogg',
|
||||
'audio/aac',
|
||||
'audio/x-ms-wma',
|
||||
'audio/x-aiff',
|
||||
'audio/webm',
|
||||
// `audio/matroska` is the correct mimetype, but the server still uses `audio/x-matroska`
|
||||
// ref: https://www.iana.org/assignments/media-types/media-types.xhtml
|
||||
'audio/matroska',
|
||||
'audio/x-matroska'
|
||||
]
|
||||
var mimeTypeCanPlayMap = {}
|
||||
mimeTypes.forEach((mt) => {
|
||||
var canPlay = this.player.canPlayType(mt)
|
||||
|
||||
+88
-3
@@ -1,4 +1,19 @@
|
||||
export default function ({ $axios, store, $config }) {
|
||||
export default function ({ $axios, store, $root, app }) {
|
||||
// Track if we're currently refreshing to prevent multiple refresh attempts
|
||||
let isRefreshing = false
|
||||
let failedQueue = []
|
||||
|
||||
const processQueue = (error, token = null) => {
|
||||
failedQueue.forEach(({ resolve, reject }) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve(token)
|
||||
}
|
||||
})
|
||||
failedQueue = []
|
||||
}
|
||||
|
||||
$axios.onRequest((config) => {
|
||||
if (!config.url) {
|
||||
console.error('Axios request invalid config', config)
|
||||
@@ -7,7 +22,7 @@ export default function ({ $axios, store, $config }) {
|
||||
if (config.url.startsWith('http:') || config.url.startsWith('https:')) {
|
||||
return
|
||||
}
|
||||
const bearerToken = store.state.user.user?.token || null
|
||||
const bearerToken = store.getters['user/getToken']
|
||||
if (bearerToken) {
|
||||
config.headers.common['Authorization'] = `Bearer ${bearerToken}`
|
||||
}
|
||||
@@ -17,9 +32,79 @@ export default function ({ $axios, store, $config }) {
|
||||
}
|
||||
})
|
||||
|
||||
$axios.onError((error) => {
|
||||
$axios.onError(async (error) => {
|
||||
const originalRequest = error.config
|
||||
const code = parseInt(error.response && error.response.status)
|
||||
const message = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||
|
||||
console.error('Axios error', code, message)
|
||||
|
||||
// Handle 401 Unauthorized (token expired)
|
||||
if (code === 401 && !originalRequest._retry) {
|
||||
// Skip refresh for auth endpoints to prevent infinite loops
|
||||
if (originalRequest.url === '/auth/refresh' || originalRequest.url === '/login') {
|
||||
// Refresh failed or login failed, redirect to login
|
||||
store.commit('user/setUser', null)
|
||||
store.commit('user/setAccessToken', null)
|
||||
app.router.push('/login')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
if (isRefreshing) {
|
||||
// If already refreshing, queue this request
|
||||
return new Promise((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject })
|
||||
})
|
||||
.then((token) => {
|
||||
if (!originalRequest.headers) {
|
||||
originalRequest.headers = {}
|
||||
}
|
||||
originalRequest.headers['Authorization'] = `Bearer ${token}`
|
||||
return $axios(originalRequest)
|
||||
})
|
||||
.catch((err) => {
|
||||
return Promise.reject(err)
|
||||
})
|
||||
}
|
||||
|
||||
originalRequest._retry = true
|
||||
isRefreshing = true
|
||||
|
||||
try {
|
||||
// Attempt to refresh the token
|
||||
// Updates store if successful, otherwise clears store and throw error
|
||||
const newAccessToken = await store.dispatch('user/refreshToken')
|
||||
if (!newAccessToken) {
|
||||
console.error('No new access token received')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// Update the original request with new token
|
||||
if (!originalRequest.headers) {
|
||||
originalRequest.headers = {}
|
||||
}
|
||||
originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`
|
||||
|
||||
// Process any queued requests
|
||||
processQueue(null, newAccessToken)
|
||||
|
||||
// Retry the original request
|
||||
return $axios(originalRequest)
|
||||
} catch (refreshError) {
|
||||
console.error('Token refresh failed:', refreshError)
|
||||
|
||||
// Process queued requests with error
|
||||
processQueue(refreshError, null)
|
||||
|
||||
// Redirect to login
|
||||
app.router.push('/login')
|
||||
|
||||
return Promise.reject(refreshError)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ const defaultCode = 'en-us'
|
||||
|
||||
const languageCodeMap = {
|
||||
ar: { label: 'عربي', dateFnsLocale: 'ar' },
|
||||
be: { label: 'Беларуская', dateFnsLocale: 'be' },
|
||||
bg: { label: 'Български', dateFnsLocale: 'bg' },
|
||||
bn: { label: 'বাংলা', dateFnsLocale: 'bn' },
|
||||
ca: { label: 'Català', dateFnsLocale: 'ca' },
|
||||
@@ -20,15 +21,19 @@ const languageCodeMap = {
|
||||
he: { label: 'עברית', dateFnsLocale: 'he' },
|
||||
hr: { label: 'Hrvatski', dateFnsLocale: 'hr' },
|
||||
it: { label: 'Italiano', dateFnsLocale: 'it' },
|
||||
ja: { label: '日本語', dateFnsLocale: 'ja' },
|
||||
lt: { label: 'Lietuvių', dateFnsLocale: 'lt' },
|
||||
hu: { label: 'Magyar', dateFnsLocale: 'hu' },
|
||||
ko: { label: '한국어', dateFnsLocale: 'ko' },
|
||||
nl: { label: 'Nederlands', dateFnsLocale: 'nl' },
|
||||
no: { label: 'Norsk', dateFnsLocale: 'no' },
|
||||
pl: { label: 'Polski', dateFnsLocale: 'pl' },
|
||||
'pt-br': { label: 'Português (Brasil)', dateFnsLocale: 'ptBR' },
|
||||
ru: { label: 'Русский', dateFnsLocale: 'ru' },
|
||||
sk: { label: 'Slovenčina', dateFnsLocale: 'sk' },
|
||||
sl: { label: 'Slovenščina', dateFnsLocale: 'sl' },
|
||||
sv: { label: 'Svenska', dateFnsLocale: 'sv' },
|
||||
tr: { label: 'Türkçe', dateFnsLocale: 'tr' },
|
||||
uk: { label: 'Українська', dateFnsLocale: 'uk' },
|
||||
'vi-vn': { label: 'Tiếng Việt', dateFnsLocale: 'vi' },
|
||||
'zh-cn': { label: '简体中文 (Simplified Chinese)', dateFnsLocale: 'zhCN' },
|
||||
@@ -46,6 +51,7 @@ const podcastSearchRegionMap = {
|
||||
au: { label: 'Australia' },
|
||||
br: { label: 'Brasil' },
|
||||
be: { label: 'België / Belgique / Belgien' },
|
||||
by: { label: 'Беларусь' },
|
||||
cz: { label: 'Česko' },
|
||||
dk: { label: 'Danmark' },
|
||||
de: { label: 'Deutschland' },
|
||||
@@ -55,6 +61,7 @@ const podcastSearchRegionMap = {
|
||||
hr: { label: 'Hrvatska' },
|
||||
il: { label: 'ישראל / إسرائيل' },
|
||||
it: { label: 'Italia' },
|
||||
jp: { label: '日本' },
|
||||
lu: { label: 'Luxembourg / Luxemburg / Lëtezebuerg' },
|
||||
hu: { label: 'Magyarország' },
|
||||
nl: { label: 'Nederland' },
|
||||
@@ -65,6 +72,7 @@ const podcastSearchRegionMap = {
|
||||
pt: { label: 'Portugal' },
|
||||
ru: { label: 'Россия' },
|
||||
ch: { label: 'Schweiz / Suisse / Svizzera' },
|
||||
sk: { label: 'Slovensko' },
|
||||
se: { label: 'Sverige' },
|
||||
vn: { label: 'Việt Nam' },
|
||||
ua: { label: 'Україна' },
|
||||
|
||||
@@ -37,6 +37,48 @@ Vue.prototype.$elapsedPretty = (seconds, useFullNames = false, useMilliseconds =
|
||||
return `${hours} ${useFullNames ? `hour${hours === 1 ? '' : 's'}` : 'hr'} ${minutes} ${useFullNames ? `minute${minutes === 1 ? '' : 's'}` : 'min'}`
|
||||
}
|
||||
|
||||
Vue.prototype.$elapsedPrettyLocalized = (seconds, useFullNames = false, useMilliseconds = false) => {
|
||||
if (isNaN(seconds) || seconds === null) return ''
|
||||
|
||||
try {
|
||||
const df = new Intl.DurationFormat(Vue.prototype.$languageCodes.current, {
|
||||
style: useFullNames ? 'long' : 'short'
|
||||
})
|
||||
|
||||
const duration = {}
|
||||
|
||||
if (seconds < 60) {
|
||||
if (useMilliseconds && seconds < 1) {
|
||||
duration.milliseconds = Math.floor(seconds * 1000)
|
||||
} else {
|
||||
duration.seconds = Math.floor(seconds)
|
||||
}
|
||||
} else if (seconds < 3600) {
|
||||
// 1 hour
|
||||
duration.minutes = Math.floor(seconds / 60)
|
||||
} else if (seconds < 86400) {
|
||||
// 1 day
|
||||
duration.hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
if (minutes > 0) {
|
||||
duration.minutes = minutes
|
||||
}
|
||||
} else {
|
||||
duration.days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
if (hours > 0) {
|
||||
duration.hours = hours
|
||||
}
|
||||
}
|
||||
|
||||
return df.format(duration)
|
||||
} catch (error) {
|
||||
// Handle not supported
|
||||
console.warn('Intl.DurationFormat not supported, not localizing duration')
|
||||
return Vue.prototype.$elapsedPretty(seconds, useFullNames, useMilliseconds)
|
||||
}
|
||||
}
|
||||
|
||||
Vue.prototype.$secondsToTimestamp = (seconds, includeMs = false, alwaysIncludeHours = false) => {
|
||||
if (!seconds) {
|
||||
return alwaysIncludeHours ? '00:00:00' : '0:00'
|
||||
|
||||
@@ -117,7 +117,6 @@ export const actions = {
|
||||
const library = data.library
|
||||
const filterData = data.filterdata
|
||||
const issues = data.issues || 0
|
||||
const customMetadataProviders = data.customMetadataProviders || []
|
||||
const numUserPlaylists = data.numUserPlaylists
|
||||
|
||||
dispatch('user/checkUpdateLibrarySortFilter', library.mediaType, { root: true })
|
||||
@@ -131,9 +130,7 @@ export const actions = {
|
||||
commit('setLibraryIssues', issues)
|
||||
commit('setLibraryFilterData', filterData)
|
||||
commit('setNumUserPlaylists', numUserPlaylists)
|
||||
commit('scanners/setCustomMetadataProviders', customMetadataProviders, { root: true })
|
||||
|
||||
commit('setCurrentLibrary', libraryId)
|
||||
commit('setCurrentLibrary', { id: libraryId })
|
||||
return data
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -159,7 +156,7 @@ export const actions = {
|
||||
.$get(`/api/libraries`)
|
||||
.then((data) => {
|
||||
commit('set', data.libraries)
|
||||
commit('setLastLoad')
|
||||
commit('setLastLoad', new Date())
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
@@ -176,14 +173,14 @@ export const mutations = {
|
||||
setFoldersLastUpdate(state) {
|
||||
state.folderLastUpdate = Date.now()
|
||||
},
|
||||
setLastLoad(state) {
|
||||
state.lastLoad = Date.now()
|
||||
setLastLoad(state, date) {
|
||||
state.lastLoad = date
|
||||
},
|
||||
setLibraryIssues(state, val) {
|
||||
state.issues = val
|
||||
},
|
||||
setCurrentLibrary(state, val) {
|
||||
state.currentLibraryId = val
|
||||
setCurrentLibrary(state, { id }) {
|
||||
state.currentLibraryId = id
|
||||
},
|
||||
set(state, libraries) {
|
||||
state.libraries = libraries
|
||||
|
||||
+50
-116
@@ -1,126 +1,60 @@
|
||||
export const state = () => ({
|
||||
providers: [
|
||||
{
|
||||
text: 'Google Books',
|
||||
value: 'google'
|
||||
},
|
||||
{
|
||||
text: 'Open Library',
|
||||
value: 'openlibrary'
|
||||
},
|
||||
{
|
||||
text: 'iTunes',
|
||||
value: 'itunes'
|
||||
},
|
||||
{
|
||||
text: 'Audible.com',
|
||||
value: 'audible'
|
||||
},
|
||||
{
|
||||
text: 'Audible.ca',
|
||||
value: 'audible.ca'
|
||||
},
|
||||
{
|
||||
text: 'Audible.co.uk',
|
||||
value: 'audible.uk'
|
||||
},
|
||||
{
|
||||
text: 'Audible.com.au',
|
||||
value: 'audible.au'
|
||||
},
|
||||
{
|
||||
text: 'Audible.fr',
|
||||
value: 'audible.fr'
|
||||
},
|
||||
{
|
||||
text: 'Audible.de',
|
||||
value: 'audible.de'
|
||||
},
|
||||
{
|
||||
text: 'Audible.co.jp',
|
||||
value: 'audible.jp'
|
||||
},
|
||||
{
|
||||
text: 'Audible.it',
|
||||
value: 'audible.it'
|
||||
},
|
||||
{
|
||||
text: 'Audible.co.in',
|
||||
value: 'audible.in'
|
||||
},
|
||||
{
|
||||
text: 'Audible.es',
|
||||
value: 'audible.es'
|
||||
},
|
||||
{
|
||||
text: 'FantLab.ru',
|
||||
value: 'fantlab'
|
||||
}
|
||||
],
|
||||
podcastProviders: [
|
||||
{
|
||||
text: 'iTunes',
|
||||
value: 'itunes'
|
||||
}
|
||||
],
|
||||
coverOnlyProviders: [
|
||||
{
|
||||
text: 'AudiobookCovers.com',
|
||||
value: 'audiobookcovers'
|
||||
}
|
||||
]
|
||||
bookProviders: [],
|
||||
podcastProviders: [],
|
||||
bookCoverProviders: [],
|
||||
podcastCoverProviders: [],
|
||||
providersLoaded: false
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
checkBookProviderExists: state => (providerValue) => {
|
||||
return state.providers.some(p => p.value === providerValue)
|
||||
checkBookProviderExists: (state) => (providerValue) => {
|
||||
return state.bookProviders.some((p) => p.value === providerValue)
|
||||
},
|
||||
checkPodcastProviderExists: state => (providerValue) => {
|
||||
return state.podcastProviders.some(p => p.value === providerValue)
|
||||
checkPodcastProviderExists: (state) => (providerValue) => {
|
||||
return state.podcastProviders.some((p) => p.value === providerValue)
|
||||
},
|
||||
areProvidersLoaded: (state) => state.providersLoaded
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
async fetchProviders({ commit, state }) {
|
||||
// Only fetch if not already loaded
|
||||
if (state.providersLoaded) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.$axios.$get('/api/search/providers')
|
||||
if (response?.providers) {
|
||||
commit('setAllProviders', response.providers)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch providers', error)
|
||||
}
|
||||
},
|
||||
async refreshProviders({ commit, state }) {
|
||||
// if providers are not loaded, do nothing - they will be fetched when required (
|
||||
if (!state.providersLoaded) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.$axios.$get('/api/search/providers')
|
||||
if (response?.providers) {
|
||||
commit('setAllProviders', response.providers)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh providers', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = {}
|
||||
|
||||
export const mutations = {
|
||||
addCustomMetadataProvider(state, provider) {
|
||||
if (provider.mediaType === 'book') {
|
||||
if (state.providers.some(p => p.value === provider.slug)) return
|
||||
state.providers.push({
|
||||
text: provider.name,
|
||||
value: provider.slug
|
||||
})
|
||||
} else {
|
||||
if (state.podcastProviders.some(p => p.value === provider.slug)) return
|
||||
state.podcastProviders.push({
|
||||
text: provider.name,
|
||||
value: provider.slug
|
||||
})
|
||||
}
|
||||
},
|
||||
removeCustomMetadataProvider(state, provider) {
|
||||
if (provider.mediaType === 'book') {
|
||||
state.providers = state.providers.filter(p => p.value !== provider.slug)
|
||||
} else {
|
||||
state.podcastProviders = state.podcastProviders.filter(p => p.value !== provider.slug)
|
||||
}
|
||||
},
|
||||
setCustomMetadataProviders(state, providers) {
|
||||
if (!providers?.length) return
|
||||
|
||||
const mediaType = providers[0].mediaType
|
||||
if (mediaType === 'book') {
|
||||
// clear previous values, and add new values to the end
|
||||
state.providers = state.providers.filter((p) => !p.value.startsWith('custom-'))
|
||||
state.providers = [
|
||||
...state.providers,
|
||||
...providers.map((p) => ({
|
||||
text: p.name,
|
||||
value: p.slug
|
||||
}))
|
||||
]
|
||||
} else {
|
||||
// Podcast providers not supported yet
|
||||
}
|
||||
setAllProviders(state, providers) {
|
||||
state.bookProviders = providers.books || []
|
||||
state.podcastProviders = providers.podcasts || []
|
||||
state.bookCoverProviders = providers.booksCovers || []
|
||||
state.podcastCoverProviders = providers.podcasts || [] // Use same as bookCovers since podcasts use iTunes only
|
||||
state.providersLoaded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-12
@@ -1,5 +1,6 @@
|
||||
export const state = () => ({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
settings: {
|
||||
orderBy: 'media.metadata.title',
|
||||
orderDesc: false,
|
||||
@@ -25,19 +26,19 @@ export const getters = {
|
||||
getIsRoot: (state) => state.user && state.user.type === 'root',
|
||||
getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'),
|
||||
getToken: (state) => {
|
||||
return state.user?.token || null
|
||||
return state.accessToken || null
|
||||
},
|
||||
getUserMediaProgress:
|
||||
(state) =>
|
||||
(libraryItemId, episodeId = null) => {
|
||||
if (!state.user.mediaProgress) return null
|
||||
if (!state.user?.mediaProgress) return null
|
||||
return state.user.mediaProgress.find((li) => {
|
||||
if (episodeId && li.episodeId !== episodeId) return false
|
||||
return li.libraryItemId == libraryItemId
|
||||
})
|
||||
},
|
||||
getUserBookmarksForItem: (state) => (libraryItemId) => {
|
||||
if (!state.user.bookmarks) return []
|
||||
if (!state.user?.bookmarks) return []
|
||||
return state.user.bookmarks.filter((bm) => bm.libraryItemId === libraryItemId)
|
||||
},
|
||||
getUserSetting: (state) => (key) => {
|
||||
@@ -91,7 +92,7 @@ export const actions = {
|
||||
if (state.settings.orderBy == 'media.duration') {
|
||||
settingsUpdate.orderBy = 'media.numTracks'
|
||||
}
|
||||
if (state.settings.orderBy == 'media.metadata.publishedYear') {
|
||||
if (state.settings.orderBy == 'media.metadata.publishedYear' || state.settings.orderBy == 'progress') {
|
||||
settingsUpdate.orderBy = 'media.metadata.title'
|
||||
}
|
||||
const invalidFilters = ['series', 'authors', 'narrators', 'publishers', 'publishedDecades', 'languages', 'progress', 'issues', 'ebooks', 'abridged']
|
||||
@@ -145,21 +146,41 @@ export const actions = {
|
||||
} catch (error) {
|
||||
console.error('Failed to load userSettings from local storage', error)
|
||||
}
|
||||
},
|
||||
refreshToken({ state, commit }) {
|
||||
return this.$axios
|
||||
.$post('/auth/refresh')
|
||||
.then(async (response) => {
|
||||
const newAccessToken = response.user.accessToken
|
||||
commit('setAccessToken', newAccessToken)
|
||||
// Emit event used to re-authenticate socket in default.vue since $root is not available here
|
||||
if (this.$eventBus) {
|
||||
this.$eventBus.$emit('token_refreshed', newAccessToken)
|
||||
}
|
||||
return newAccessToken
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to refresh token', error)
|
||||
commit('setUser', null)
|
||||
commit('setAccessToken', null)
|
||||
// Calling function handles redirect to login
|
||||
throw error
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
setUser(state, user) {
|
||||
state.user = user
|
||||
if (user) {
|
||||
if (user.token) localStorage.setItem('token', user.token)
|
||||
} else {
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
},
|
||||
setUserToken(state, token) {
|
||||
state.user.token = token
|
||||
localStorage.setItem('token', token)
|
||||
setAccessToken(state, token) {
|
||||
if (!token) {
|
||||
localStorage.removeItem('token')
|
||||
state.accessToken = null
|
||||
} else {
|
||||
state.accessToken = token
|
||||
localStorage.setItem('token', token)
|
||||
}
|
||||
},
|
||||
updateMediaProgress(state, { id, data }) {
|
||||
if (!state.user) return
|
||||
|
||||
+20
-5
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"ButtonAdd": "إضافة",
|
||||
"ButtonAddApiKey": "إضافة مفتاح واجهة برمجة التطبيقات",
|
||||
"ButtonAddChapters": "إضافة الفصول",
|
||||
"ButtonAddDevice": "إضافة جهاز",
|
||||
"ButtonAddLibrary": "إضافة مكتبة",
|
||||
@@ -20,7 +21,8 @@
|
||||
"ButtonChooseAFolder": "اختر المجلد",
|
||||
"ButtonChooseFiles": "اختر الملفات",
|
||||
"ButtonClearFilter": "تصفية الفرز",
|
||||
"ButtonCloseFeed": "إغلاق",
|
||||
"ButtonClose": "إغلاق",
|
||||
"ButtonCloseFeed": "إغلاق الموجز",
|
||||
"ButtonCloseSession": "إغلاق الجلسة المفتوحة",
|
||||
"ButtonCollections": "المجموعات",
|
||||
"ButtonConfigureScanner": "إعدادات الماسح الضوئي",
|
||||
@@ -119,11 +121,13 @@
|
||||
"HeaderAccount": "الحساب",
|
||||
"HeaderAddCustomMetadataProvider": "إضافة موفر بيانات تعريفية مخصص",
|
||||
"HeaderAdvanced": "متقدم",
|
||||
"HeaderApiKeys": "مفاتيح API",
|
||||
"HeaderAppriseNotificationSettings": "إعدادات الإشعارات",
|
||||
"HeaderAudioTracks": "المقاطع الصوتية",
|
||||
"HeaderAudiobookTools": "أدوات إدارة ملفات الكتب الصوتية",
|
||||
"HeaderAuthentication": "المصادقة",
|
||||
"HeaderBackups": "النسخ الاحتياطية",
|
||||
"HeaderBulkChapterModal": "أضف فصولاً متعددة",
|
||||
"HeaderChangePassword": "تغيير كلمة المرور",
|
||||
"HeaderChapters": "الفصول",
|
||||
"HeaderChooseAFolder": "اختيار المجلد",
|
||||
@@ -162,6 +166,7 @@
|
||||
"HeaderMetadataOrderOfPrecedence": "ترتيب أولوية البيانات الوصفية",
|
||||
"HeaderMetadataToEmbed": "البيانات الوصفية المراد تضمينها",
|
||||
"HeaderNewAccount": "حساب جديد",
|
||||
"HeaderNewApiKey": "مفتاح API جديد",
|
||||
"HeaderNewLibrary": "مكتبة جديدة",
|
||||
"HeaderNotificationCreate": "إنشاء إشعار",
|
||||
"HeaderNotificationUpdate": "تحديث إشعار",
|
||||
@@ -195,6 +200,7 @@
|
||||
"HeaderSettingsExperimental": "ميزات تجريبية",
|
||||
"HeaderSettingsGeneral": "عام",
|
||||
"HeaderSettingsScanner": "إعدادات المسح",
|
||||
"HeaderSettingsSecurity": "الأمان",
|
||||
"HeaderSettingsWebClient": "عميل الويب",
|
||||
"HeaderSleepTimer": "مؤقت النوم",
|
||||
"HeaderStatsLargestItems": "أكبر العناصر حجماً",
|
||||
@@ -206,6 +212,7 @@
|
||||
"HeaderTableOfContents": "جدول المحتويات",
|
||||
"HeaderTools": "أدوات",
|
||||
"HeaderUpdateAccount": "تحديث الحساب",
|
||||
"HeaderUpdateApiKey": "تحديث مفتاح API",
|
||||
"HeaderUpdateAuthor": "تحديث المؤلف",
|
||||
"HeaderUpdateDetails": "تحديث التفاصيل",
|
||||
"HeaderUpdateLibrary": "تحديث المكتبة",
|
||||
@@ -235,6 +242,10 @@
|
||||
"LabelAllUsersExcludingGuests": "جميع المستخدمين باستثناء الضيوف",
|
||||
"LabelAllUsersIncludingGuests": "جميع المستخدمين بما في ذلك الضيوف",
|
||||
"LabelAlreadyInYourLibrary": "موجود بالفعل في مكتبتك",
|
||||
"LabelApiKeyCreated": "تم إنشاء مفتاح API \"{0}\" بنجاح.",
|
||||
"LabelApiKeyCreatedDescription": "تأكد من نسخ مفتاح API الآن، لن تتمكن من رؤيته مرة أخرى.",
|
||||
"LabelApiKeyUser": "التصرف بالنيابة عن مستخدم",
|
||||
"LabelApiKeyUserDescription": "مفتاح API سيمتلك نفس صلاحيات المستخدم الذي ينوب عنه ، سيظهر بالسجلات وكأن المستخدم قام بالطلب.",
|
||||
"LabelApiToken": "رمز API",
|
||||
"LabelAppend": "إلحاق",
|
||||
"LabelAudioBitrate": "معدل بت الصوت (على سبيل المثال 128 كيلو بايت)",
|
||||
@@ -284,6 +295,7 @@
|
||||
"LabelContinueListening": "استمرار الاستماع",
|
||||
"LabelContinueReading": "استمرار القراءة",
|
||||
"LabelContinueSeries": "استمرار المسلسلات",
|
||||
"LabelCorsAllowed": "CORS Origins مسموح",
|
||||
"LabelCover": "الغلاف",
|
||||
"LabelCoverImageURL": "رابط صورة الغلاف",
|
||||
"LabelCoverProvider": "مزود الغلاف",
|
||||
@@ -346,7 +358,7 @@
|
||||
"LabelExample": "مثال",
|
||||
"LabelExpandSeries": "توسيع السلاسل",
|
||||
"LabelExpandSubSeries": "توسيع السلاسل الفرعية",
|
||||
"LabelExplicit": "صريح",
|
||||
"LabelExplicit": "محتوى صريح",
|
||||
"LabelExplicitChecked": "صريح (محدد)",
|
||||
"LabelExplicitUnchecked": "غير صريح (غير محدد)",
|
||||
"LabelExportOPML": "تصدير OPML",
|
||||
@@ -365,7 +377,7 @@
|
||||
"LabelFolders": "مجلدات",
|
||||
"LabelFontBold": "عريض",
|
||||
"LabelFontBoldness": "تعريض الخط",
|
||||
"LabelFontFamily": "عائلة الخط",
|
||||
"LabelFontFamily": "عائلة الخطوط",
|
||||
"LabelFontItalic": "مائل",
|
||||
"LabelFontScale": "نطاق الخط",
|
||||
"LabelFontStrikethrough": "يتوسطه خط",
|
||||
@@ -417,6 +429,9 @@
|
||||
"LabelLibraryFilterSublistEmpty": "لا يوجد {0}",
|
||||
"LabelLibraryItem": "عنصر المكتبة",
|
||||
"LabelLibraryName": "اسم المكتبة",
|
||||
"LabelLibrarySortByProgress": "المرحلة: الأحدث",
|
||||
"LabelLibrarySortByProgressFinished": "المرحلة: تم الانتهاء",
|
||||
"LabelLibrarySortByProgressStarted": "المرحلة: تم البدء",
|
||||
"LabelLimit": "حد",
|
||||
"LabelLineSpacing": "تباعد الأسطر",
|
||||
"LabelListenAgain": "الاستماع مجدداً",
|
||||
@@ -561,7 +576,7 @@
|
||||
"LabelSettingsBookshelfViewHelp": "تصميم يحاكي الواقع مع رفوف خشبية",
|
||||
"LabelSettingsChromecastSupport": "دعم Chromecast",
|
||||
"LabelSettingsDateFormat": "تنسيق التاريخ",
|
||||
"LabelSettingsEnableWatcher": "فحص المكتبات تلقائيًا بحثًا عن تغييرات",
|
||||
"LabelSettingsEnableWatcher": "مراقبة المكتبات تلقائياً بحثاً عن تغييرات",
|
||||
"LabelSettingsEnableWatcherForLibrary": "فحص المكتبة تلقائيًا بحثًا عن تغييرات",
|
||||
"LabelSettingsEnableWatcherHelp": "يمكّن الإضافة/التحديث التلقائي للعناصر عند اكتشاف تغييرات في الملفات. *يتطلب إعادة تشغيل الخادم",
|
||||
"LabelSettingsEpubsAllowScriptedContent": "السماح بالمحتوى النصي في ملفات epub",
|
||||
@@ -852,7 +867,7 @@
|
||||
"MessageResetChaptersConfirm": "هل أنت متأكد أنك تريد إعادة تعيين الفصول والتراجع عن التغييرات التي أجريتها؟",
|
||||
"MessageRestoreBackupConfirm": "هل أنت متأكد أنك تريد استعادة النسخ الاحتياطي الذي تم إنشاؤه في",
|
||||
"MessageRestoreBackupWarning": "ستؤدي استعادة النسخ الاحتياطي إلى الكتابة فوق قاعدة البيانات بأكملها الموجودة في /config وصور الأغلفة في /metadata/items و /metadata/authors.<br /><br /> لا تعدل النسخ الاحتياطية أي ملفات في مجلدات مكتبتك. إذا قمت بتمكين إعدادات الخادم لتخزين صور الأغلفة والبيانات الوصفية في مجلدات مكتبتك، فلن يتم نسخها احتياطيًا أو الكتابة فوقها.<br /><br /> سيتم تحديث جميع العملاء الذين يستخدمون الخادم الخاص بك تلقائيًا.",
|
||||
"MessageScheduleLibraryScanNote": "بالنسبة لمعظم المستخدمين، يوصى بترك هذه الميزة معطلة وإبقاء إعداد مراقب المجلدات ممكّنًا. سيكتشف مراقب المجلدات تلقائيًا التغييرات في مجلدات مكتبتك. لا يعمل مراقب المجلدات مع كل نظام ملفات (مثل NFS)، لذا يمكن استخدام عمليات فحص المكتبة المجدولة بدلاً من ذلك.",
|
||||
"MessageScheduleLibraryScanNote": "لمعظم المستخدمين، موصى بترك هذه الميزة معطلة وإبقاء ممكّنة الأعداد، ”قم بمراقبة المكتبة تلقائاً للتغييرات“. سوف يقم بالكشف التلقائي عن تغييرات في مجلدات مكتبتك. لو لم يعمل الإعداد، \"قم بمراقبة المكتبة تلقائاً للتغييرات،“مع نظمة ملفاتك المستخدمة (مثل NFS على سبيل المثال)، فأمكِن هذه الميزة.",
|
||||
"MessageScheduleRunEveryWeekdayAtTime": "تشغيل كل {0} في الساعة {1}",
|
||||
"MessageSearchResultsFor": "نتائج البحث عن",
|
||||
"MessageSelected": "تم تحديد {0}",
|
||||
|
||||
+722
-172
File diff suppressed because it is too large
Load Diff
+267
-10
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"ButtonAdd": "Създай",
|
||||
"ButtonAddApiKey": "Добави API ключ",
|
||||
"ButtonAddChapters": "Добави Глави",
|
||||
"ButtonAddDevice": "Добави Устройство",
|
||||
"ButtonAddLibrary": "Добави Библиотека",
|
||||
@@ -20,6 +21,7 @@
|
||||
"ButtonChooseAFolder": "Избери Папка",
|
||||
"ButtonChooseFiles": "Избери Файлове",
|
||||
"ButtonClearFilter": "Изчисти филтър",
|
||||
"ButtonClose": "Затвори",
|
||||
"ButtonCloseFeed": "Затвори стената",
|
||||
"ButtonCloseSession": "Затвори отворената сесия",
|
||||
"ButtonCollections": "Колекции",
|
||||
@@ -119,11 +121,13 @@
|
||||
"HeaderAccount": "Профил",
|
||||
"HeaderAddCustomMetadataProvider": "Добави персонализиран доставчик на метаданни",
|
||||
"HeaderAdvanced": "Разширени настройки",
|
||||
"HeaderApiKeys": "API ключове",
|
||||
"HeaderAppriseNotificationSettings": "Apprise Notification Опции",
|
||||
"HeaderAudioTracks": "Песни",
|
||||
"HeaderAudiobookTools": "Инструмент за Менижиране на Аудиокниги",
|
||||
"HeaderAuthentication": "Аутентикация",
|
||||
"HeaderBackups": "Архив",
|
||||
"HeaderBulkChapterModal": "Добави няколко глави",
|
||||
"HeaderChangePassword": "Промяна на Парола",
|
||||
"HeaderChapters": "Глави",
|
||||
"HeaderChooseAFolder": "Избети Папка",
|
||||
@@ -162,6 +166,7 @@
|
||||
"HeaderMetadataOrderOfPrecedence": "Предимство на Метаданни",
|
||||
"HeaderMetadataToEmbed": "Метаданни за Вграждане",
|
||||
"HeaderNewAccount": "Нов Профил",
|
||||
"HeaderNewApiKey": "Нов API ключ",
|
||||
"HeaderNewLibrary": "Нова Библиотека",
|
||||
"HeaderNotificationCreate": "Създай нотификация",
|
||||
"HeaderNotificationUpdate": "Обнови нотификация",
|
||||
@@ -195,6 +200,7 @@
|
||||
"HeaderSettingsExperimental": "Експериментални Функции",
|
||||
"HeaderSettingsGeneral": "Общи",
|
||||
"HeaderSettingsScanner": "Скенер",
|
||||
"HeaderSettingsSecurity": "Сигурност",
|
||||
"HeaderSettingsWebClient": "Уеб клиент",
|
||||
"HeaderSleepTimer": "Таймер за заспиване",
|
||||
"HeaderStatsLargestItems": "Най-Големите Елементи",
|
||||
@@ -206,6 +212,7 @@
|
||||
"HeaderTableOfContents": "Съдържание",
|
||||
"HeaderTools": "Инструменти",
|
||||
"HeaderUpdateAccount": "Обнови Профил",
|
||||
"HeaderUpdateApiKey": "Обнови API ключ",
|
||||
"HeaderUpdateAuthor": "Обнови Автор",
|
||||
"HeaderUpdateDetails": "Обнови Детайли",
|
||||
"HeaderUpdateLibrary": "Обнови Библиотека",
|
||||
@@ -230,10 +237,15 @@
|
||||
"LabelAddedDate": "Добавено",
|
||||
"LabelAdminUsersOnly": "Само за Администратори",
|
||||
"LabelAll": "Всичко",
|
||||
"LabelAllEpisodesDownloaded": "Всички епизоди са изтеглени",
|
||||
"LabelAllUsers": "Всички Потребители",
|
||||
"LabelAllUsersExcludingGuests": "Всички потребители без гости",
|
||||
"LabelAllUsersIncludingGuests": "Всички потребители включително гости",
|
||||
"LabelAlreadyInYourLibrary": "Вече е в твоята библиотека",
|
||||
"LabelApiKeyCreated": "API ключ \"{0}\" успешно създатен.",
|
||||
"LabelApiKeyCreatedDescription": "Погрижете се да копирате API ключът сега, защото повече няма да можете да го виждате онново.",
|
||||
"LabelApiKeyUser": "Действай от името на потребителя",
|
||||
"LabelApiKeyUserDescription": "Този API ключ ще има същите права като на потребителя за чието име действа. В логовете ще изглежда все едно потребителя прави заявката.",
|
||||
"LabelApiToken": "АПИ Токен",
|
||||
"LabelAppend": "Добави",
|
||||
"LabelAudioBitrate": "Аудио битрейт (напр. 128k)",
|
||||
@@ -253,7 +265,7 @@
|
||||
"LabelBackToUser": "Обратно към Потребител",
|
||||
"LabelBackupAudioFiles": "Създай резервно копие на аудио файлове",
|
||||
"LabelBackupLocation": "Местоположение на Архив",
|
||||
"LabelBackupsEnableAutomaticBackups": "Включи автоматично архивиране",
|
||||
"LabelBackupsEnableAutomaticBackups": "Автоматично архивиране",
|
||||
"LabelBackupsEnableAutomaticBackupsHelp": "Архиви запазени в /metadata/backups",
|
||||
"LabelBackupsMaxBackupSize": "Максимален размер на архива (в GB) (0 за неограничен)",
|
||||
"LabelBackupsMaxBackupSizeHelp": "За защита срещу грешки в конфигурацията, архивите ще се провалят ако надхвърлят конфигурирания размер.",
|
||||
@@ -272,7 +284,7 @@
|
||||
"LabelChaptersFound": "намерени глави",
|
||||
"LabelClickForMoreInfo": "Кликни за повече информация",
|
||||
"LabelClickToUseCurrentValue": "Натисни да ползваш сегашната стойност",
|
||||
"LabelClosePlayer": "Затвори",
|
||||
"LabelClosePlayer": "Затвори плейъра",
|
||||
"LabelCodec": "Кодек",
|
||||
"LabelCollapseSeries": "Скрий сериите",
|
||||
"LabelCollapseSubSeries": "Свий подсерии",
|
||||
@@ -283,6 +295,7 @@
|
||||
"LabelContinueListening": "Продължи слушане",
|
||||
"LabelContinueReading": "Продължи четене",
|
||||
"LabelContinueSeries": "Продължи серии",
|
||||
"LabelCorsAllowed": "Разрешени CORS Origins",
|
||||
"LabelCover": "Корица",
|
||||
"LabelCoverImageURL": "URL на Корица",
|
||||
"LabelCoverProvider": "Източник за обложки",
|
||||
@@ -296,6 +309,7 @@
|
||||
"LabelDeleteFromFileSystemCheckbox": "Изтрий от файловата система (отмени за да бъдат премахни само от базата данни)",
|
||||
"LabelDescription": "Описание",
|
||||
"LabelDeselectAll": "Премахни всички",
|
||||
"LabelDetectedPattern": "Намерен образец:",
|
||||
"LabelDevice": "Устройство",
|
||||
"LabelDeviceInfo": "Информация за Устройство",
|
||||
"LabelDeviceIsAvailableTo": "Устройството е достъпно за ...",
|
||||
@@ -345,7 +359,11 @@
|
||||
"LabelExample": "Пример",
|
||||
"LabelExpandSeries": "Покажи сериите",
|
||||
"LabelExpandSubSeries": "Покажи съб сериите",
|
||||
"LabelExplicit": "С нецензурно съдържание",
|
||||
"LabelExpired": "Изтекъл",
|
||||
"LabelExpiresAt": "Изтича на",
|
||||
"LabelExpiresInSeconds": "Изтича след (секунди)",
|
||||
"LabelExpiresNever": "Никога",
|
||||
"LabelExplicit": "Експлицитно",
|
||||
"LabelExplicitChecked": "С нецензурно съдържание (проверено)",
|
||||
"LabelExplicitUnchecked": "Без нецензурно съдържание (непроверено)",
|
||||
"LabelExportOPML": "Експортирай OPML",
|
||||
@@ -360,6 +378,7 @@
|
||||
"LabelFilterByUser": "Филтриране по Потребител",
|
||||
"LabelFindEpisodes": "Намери Епизоди",
|
||||
"LabelFinished": "Дата на приключване",
|
||||
"LabelFinishedDate": "Приключено на {0}",
|
||||
"LabelFolder": "Папка",
|
||||
"LabelFolders": "Папки",
|
||||
"LabelFontBold": "Получерно",
|
||||
@@ -404,6 +423,7 @@
|
||||
"LabelLanguages": "Езици",
|
||||
"LabelLastBookAdded": "Последно Добавена Книга",
|
||||
"LabelLastBookUpdated": "Последно Обновена Книга",
|
||||
"LabelLastProgressDate": "Последен прогрес: {0}",
|
||||
"LabelLastSeen": "Последно Видян",
|
||||
"LabelLastTime": "Последно Време",
|
||||
"LabelLastUpdate": "Последно Обновяване",
|
||||
@@ -416,6 +436,9 @@
|
||||
"LabelLibraryFilterSublistEmpty": "Не {0}",
|
||||
"LabelLibraryItem": "Елемент на Библиотека",
|
||||
"LabelLibraryName": "Име на Библиотека",
|
||||
"LabelLibrarySortByProgress": "Прогрес: Последно обновление",
|
||||
"LabelLibrarySortByProgressFinished": "Прогрес: Приключено",
|
||||
"LabelLibrarySortByProgressStarted": "Прогрес: Започнато",
|
||||
"LabelLimit": "Лимит",
|
||||
"LabelLineSpacing": "Междуредие",
|
||||
"LabelListenAgain": "Слушай отново",
|
||||
@@ -424,6 +447,7 @@
|
||||
"LabelLogLevelWarn": "Предупреждение",
|
||||
"LabelLookForNewEpisodesAfterDate": "Търси нови епизоди след дата",
|
||||
"LabelLowestPriority": "Най-нисък Приоритет",
|
||||
"LabelMatchConfidence": "Увереност",
|
||||
"LabelMatchExistingUsersBy": "Съпостави съществуващи потребители по",
|
||||
"LabelMatchExistingUsersByDescription": "Използва се за свързване на съществуващи потребители. След свързване потребителите ще бъдат съпоставени по уникален идентификатор от вашия доставчик на SSO",
|
||||
"LabelMaxEpisodesToDownload": "Максимален брой епизоди за сваляне. Използвай 0 за неограничен.",
|
||||
@@ -453,7 +477,9 @@
|
||||
"LabelNewestAuthors": "Най-новите автори",
|
||||
"LabelNewestEpisodes": "Най-новите епизоди",
|
||||
"LabelNextBackupDate": "Следваща Дата на Архивиране",
|
||||
"LabelNextChapters": "Следващите глави ще бъдат:",
|
||||
"LabelNextScheduledRun": "Следващо Планирано Изпълнение",
|
||||
"LabelNoApiKeys": "Няма API ключове",
|
||||
"LabelNoCustomMetadataProviders": "Няма потребителски доставчици на метаданни",
|
||||
"LabelNoEpisodesSelected": "Няма избрани епизоди",
|
||||
"LabelNotFinished": "Не е приключено",
|
||||
@@ -469,6 +495,7 @@
|
||||
"LabelNotificationsMaxQueueSize": "Максимален размер на опашката за известия",
|
||||
"LabelNotificationsMaxQueueSizeHelp": "Събитията са ограничени до изстрелване на 1 на секунда. Събитията ще бъдат игнорирани ако опашката е на максимален размер. Това предотвратява спамирането на известия.",
|
||||
"LabelNumberOfBooks": "Брой на Книги",
|
||||
"LabelNumberOfChapters": "Брой глави:",
|
||||
"LabelNumberOfEpisodes": "Брой епизоди",
|
||||
"LabelOpenIDAdvancedPermsClaimDescription": "Име на OpenID твърдението, което съдържа разширени права за достъп до потребителски действия в приложението, които ще се прилагат за роли, различни от администраторските (<b>ако е конфигурирано</b>). Ако твърдението липсва в отговора, достъпът до ABS ще бъде отказан. Ако липсва една опция, тя ще се третира като <code>false</code>. Уверете се, че твърдението на доставчика на идентичност съответства на очакваната структура:",
|
||||
"LabelOpenIDClaims": "Оставете следните опции празни, за да деактивирате разширеното присвояване на групи, като автоматично ще бъде присвоена групата 'Потребител'.",
|
||||
@@ -513,7 +540,7 @@
|
||||
"LabelPublishers": "Издателство",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Персонализиран имейл на собственика",
|
||||
"LabelRSSFeedCustomOwnerName": "Персонализирано име на собственика",
|
||||
"LabelRSSFeedOpen": "RSS Feed Оптворен",
|
||||
"LabelRSSFeedOpen": "RSS Feed е отворен",
|
||||
"LabelRSSFeedPreventIndexing": "Предотвратете индексиране",
|
||||
"LabelRSSFeedSlug": "идентификатор на RSS емисия",
|
||||
"LabelRSSFeedURL": "URL на RSS емисия",
|
||||
@@ -543,6 +570,7 @@
|
||||
"LabelSelectAll": "Избери всичко",
|
||||
"LabelSelectAllEpisodes": "Избери всички епизоди",
|
||||
"LabelSelectEpisodesShowing": "Избери {0} епизоди показани",
|
||||
"LabelSelectUser": "Избери потребител",
|
||||
"LabelSelectUsers": "Избери Потребители",
|
||||
"LabelSendEbookToDevice": "Изпрати електронна книга до ...",
|
||||
"LabelSequence": "Последователност",
|
||||
@@ -560,8 +588,8 @@
|
||||
"LabelSettingsBookshelfViewHelp": "Скеуморфен дизайн с дървени рафтове",
|
||||
"LabelSettingsChromecastSupport": "Chromecast поддръжка",
|
||||
"LabelSettingsDateFormat": "Формат на Дата",
|
||||
"LabelSettingsEnableWatcher": "Автоматично сканиране на библиотеките за промени",
|
||||
"LabelSettingsEnableWatcherForLibrary": "Автоматично сканиране на библиотеката за промени",
|
||||
"LabelSettingsEnableWatcher": "Автоматично преглеждане на библиотеките за промени",
|
||||
"LabelSettingsEnableWatcherForLibrary": "Автоматично преглеждане на библиотеката за промени",
|
||||
"LabelSettingsEnableWatcherHelp": "Включва автоматичното добавяне/обновяване на елементи, когато се открият промени във файловете. *Изисква рестарт на сървъра",
|
||||
"LabelSettingsEpubsAllowScriptedContent": "Позволи скриптово съдържание в epub-и",
|
||||
"LabelSettingsEpubsAllowScriptedContentHelp": "Позволи epub файловете да изпълняват скриптове. Препоръчително е да бъде изключено освен ако не се доверявате на източника на epub файловете.",
|
||||
@@ -610,6 +638,7 @@
|
||||
"LabelStartTime": "Начално Време",
|
||||
"LabelStarted": "Стартирано",
|
||||
"LabelStartedAt": "Стартирано на",
|
||||
"LabelStartedDate": "Започнато {0}",
|
||||
"LabelStatsAudioTracks": "Аудио Канали",
|
||||
"LabelStatsAuthors": "Автори",
|
||||
"LabelStatsBestDay": "Най-добър ден",
|
||||
@@ -639,6 +668,7 @@
|
||||
"LabelTheme": "Тема",
|
||||
"LabelThemeDark": "Тъмна",
|
||||
"LabelThemeLight": "Светла",
|
||||
"LabelThemeSepia": "Сепия",
|
||||
"LabelTimeBase": "Времева Основа",
|
||||
"LabelTimeDurationXHours": "{0} часа",
|
||||
"LabelTimeDurationXMinutes": "{0} минути",
|
||||
@@ -693,7 +723,11 @@
|
||||
"LabelViewPlayerSettings": "Виж настройки на плеъра",
|
||||
"LabelViewQueue": "Виж Опашка",
|
||||
"LabelVolume": "Сила на Звука",
|
||||
"LabelWebRedirectURLsDescription": "Разрешете тези URL-и във вашият OAuth доставчик, за да позволите пренасочването обратно към уеб приложението след вход:",
|
||||
"LabelWebRedirectURLsSubfolder": "Подпапка за URL адреси за пренасочване",
|
||||
"LabelWeekdaysToRun": "Делници за изпълнение",
|
||||
"LabelXBooks": "{0} книги",
|
||||
"LabelXItems": "{0} елемента",
|
||||
"LabelYearReviewHide": "Скрий ревю на годината ти",
|
||||
"LabelYearReviewShow": "Виж ревю на годината ти",
|
||||
"LabelYourAudiobookDuration": "Продължителност на вашата аудиокнига",
|
||||
@@ -702,41 +736,64 @@
|
||||
"LabelYourProgress": "Твоят прогрес",
|
||||
"MessageAddToPlayerQueue": "Добави към опашката на плейъра",
|
||||
"MessageAppriseDescription": "За да ползвате тази функция трябва да имате активна инстанция на <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> или на друго АПИ което да обработва тези заявки. <br />The Apprise API Url-а трябва дае пълния URL път за изпращане на известията, например, ако вашето АПИ ве подава от <code>http://192.168.1.1:8337</code> трябва да сложитев <code>http://192.168.1.1:8337/notify</code>.",
|
||||
"MessageAsinCheck": "Уверете се, че използвате ASIN от правилния Audible регион, а не от Amazon.",
|
||||
"MessageAuthenticationLegacyTokenWarning": "Остарелите API токени ще бъдат премахнати в бъдеще. Вместо това използвайте <a href=\"/config/api-keys\">API ключове</a>.",
|
||||
"MessageAuthenticationOIDCChangesRestart": "Рестартирайте сървърът след записването на настройките, за да активирате OIDC промените.",
|
||||
"MessageAuthenticationSecurityMessage": "За осигуряването на по-добра сигурност, автентикацията беше подобрена. Всеки потребител ще трябва да се автентикира наново.",
|
||||
"MessageBackupsDescription": "Резервните копия включват потребители, напредък на потребителите, подробности за елементите в библиотеката, настройки на сървъра и изображения, съхранени в <code>/metadata/items</code> и <code>/metadata/authors</code>. Резервните копия <strong>не</strong> включват никакви файлове, съхранени в папките на вашата библиотека.",
|
||||
"MessageBackupsLocationEditNote": "Забележка: Актуализирането на местоположението за архивиране няма да премести или промени съществуващите архиви",
|
||||
"MessageBackupsLocationNoEditNote": "Забележка: Местоположението за архивиране се задава с помощта на променлива на средата и не може бъде променена от тук.",
|
||||
"MessageBackupsLocationPathEmpty": "Пътят към местоположението за архивиране не може да бъде празен",
|
||||
"MessageBatchEditPopulateMapDetailsAllHelp": "Популирайте активираните полета с данни от всички елементи. Полетата със няколко стоайности ще бъдат обединени",
|
||||
"MessageBatchEditPopulateMapDetailsItemHelp": "Попълнете активираните полета с информация за картата с данни от този елемент",
|
||||
"MessageBatchQuickMatchDescription": "Бързото Съпоставяне ще опита да добави липсващи корици и метаданни за избраните елементи. Активирайте опциите по-долу, за да позволите на Бързото съпоставяне да презапише съществуващите корици и/или метаданни.",
|
||||
"MessageBookshelfNoCollections": "Все още нямате създадени колекции",
|
||||
"MessageBookshelfNoCollectionsHelp": "Колекциите са публични. Всички потребители с достъп до библиотеката ще могат да ги виждат.",
|
||||
"MessageBookshelfNoRSSFeeds": "Няма отворени RSS feed-ове",
|
||||
"MessageBookshelfNoResultsForFilter": "Няма резултат за филтер \"{0}: {1}\"",
|
||||
"MessageBookshelfNoResultsForQuery": "Няма резултати от заявката",
|
||||
"MessageBookshelfNoSeries": "Нямаш сеЗЙ",
|
||||
"MessageBookshelfNoSeries": "Нямате поредица",
|
||||
"MessageBulkChapterPattern": "Колко глави искате да добавите, използвайки тази схема за номериране?",
|
||||
"MessageChapterEndIsAfter": "Краят на главата е след края на вашата аудиокнига",
|
||||
"MessageChapterErrorFirstNotZero": "Първата глава трябва да започва от 0",
|
||||
"MessageChapterErrorStartGteDuration": "Началото на главата трябва да бъде по-малко от продължителността на аудиокнигата",
|
||||
"MessageChapterErrorStartLtPrev": "Началото на главата трябва да бъде по-голямо или равно на края на предишната глава",
|
||||
"MessageChapterStartIsAfter": "Началото на главата е след края на вашата аудиокнига",
|
||||
"MessageChaptersNotFound": "Главите не са намерени",
|
||||
"MessageCheckingCron": "Проверяване на cron...",
|
||||
"MessageConfirmCloseFeed": "Сигурни ли сте, че искате да затворите този feed?",
|
||||
"MessageConfirmDeleteApiKey": "Сигурни ли сте, че искате да изтриете API ключ \"{0}\"?",
|
||||
"MessageConfirmDeleteBackup": "Сигурни ли сте, че искате да изтриете този архив {0}?",
|
||||
"MessageConfirmDeleteDevice": "Сигурни ли сте, че искате да изтриете е-четец \"{0}\"?",
|
||||
"MessageConfirmDeleteFile": "Това ще изтрие файла от файловата Ви система. Сигурни ли сте?",
|
||||
"MessageConfirmDeleteLibrary": "Сигурни ли сте, че искате да изтриете за винаги библиотека \"{0}\"?",
|
||||
"MessageConfirmDeleteLibraryItem": "Това ще изтрие елемента от базата данни и файловата Ви система. Сигурни ли сте?",
|
||||
"MessageConfirmDeleteLibraryItems": "Това ще изтрие {0} елемента от базата данни и файловата Ви система. Сигурни ли сте?",
|
||||
"MessageConfirmDeleteMetadataProvider": "Сигурни ли сте, че искате да изтриете доставчика нa метаданни \"{0}\"?",
|
||||
"MessageConfirmDeleteNotification": "Сигурни ли сте, че искате да изтриете това уведомление?",
|
||||
"MessageConfirmDeleteSession": "Сигурни ли сте, че искате да изтриете тази сесия?",
|
||||
"MessageConfirmEmbedMetadataInAudioFiles": "Сигурнли ли сте, че искате да вградите метаданните в {0} аудио файла?",
|
||||
"MessageConfirmForceReScan": "Сигурни ли сте, че искате да принудите повторно сканиране?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "Сигурни ли сте, че искате да маркирате всички епизоди като завършени?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "Сигурни ли сте, че искате да маркирате всички епизоди като незавършени?",
|
||||
"MessageConfirmMarkItemFinished": "Сигурни ли сте, че искате да маркирате \"{0}\" като приключено?",
|
||||
"MessageConfirmMarkItemNotFinished": "Сигурни ли сте, че искате да маркирате \"{0}\" като неприключено?",
|
||||
"MessageConfirmMarkSeriesFinished": "Сигурни ли сте, че искате да маркирате всички книги в тази серия като завършени?",
|
||||
"MessageConfirmMarkSeriesNotFinished": "Сигурни ли сте, че искате да маркирате всички книги в тази серия като незавършени?",
|
||||
"MessageConfirmNotificationTestTrigger": "Пуснете това уведомление с тестови данни?",
|
||||
"MessageConfirmPurgeCache": "Изчистването на кеша ще изтрие цялата директория в <code>/metadata/cache</code>. <br /><br />Сигурни ли сте, че искате да премахнете директорията на кеша?",
|
||||
"MessageConfirmPurgeItemsCache": "Изчистването на кеша на елементите ще изтрие цялата директория в <code>/metadata/cache/items</code>. <br />Сигурни ли сте?",
|
||||
"MessageConfirmQuickEmbed": "Внимание! Бързото вграждане няма да архивира вашите аудио файлове. Уверете се, че имате резервно копие на вашите аудио файлове. <br><br>Искате ли да продължите?",
|
||||
"MessageConfirmQuickMatchEpisodes": "Бързото сравняване на епизоди ще презапише детайлите, ако се намери съвпадение. Само не съвпаднали епизоди ще бъдат обновени. Сигурни ли сте?",
|
||||
"MessageConfirmReScanLibraryItems": "Сигурни ли сте, че искате да сканирате отново {0} елемента?",
|
||||
"MessageConfirmRemoveAllChapters": "Сигурни ли сте, че искате да премахнете всички глави?",
|
||||
"MessageConfirmRemoveAuthor": "Сигурни ли сте, че искате да премахнете автор \"{0}\"?",
|
||||
"MessageConfirmRemoveCollection": "Сигурни ли сте, че искате да премахнете колекция \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisode": "Сигурни ли сте, че искате да премахнете епизод \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisodeNote": "Забележка: Това няма да доведе до изтриване на аудио файла, освен ако не активирате опцията \"Твърдо изтриване на файла\"",
|
||||
"MessageConfirmRemoveEpisodes": "Сигурни ли сте, че искате да премахнете {0} епизода?",
|
||||
"MessageConfirmRemoveListeningSessions": "Сигурни ли сте, че искате да премахнете {0} слушателски сесии?",
|
||||
"MessageConfirmRemoveMetadataFiles": "Сигурни ли сте, че искате да премахнете всичките метаданни. {0} файлове във папките на Вашата библиотека?",
|
||||
"MessageConfirmRemoveNarrator": "Сигурни ли сте, че искате да премахнете разказвач \"{0}\"?",
|
||||
"MessageConfirmRemovePlaylist": "Сигурни ли сте, че искате да премахнете плейлиста \"{0}\"?",
|
||||
"MessageConfirmRenameGenre": "Сигурни ли сте, че искате да преименувате жанра \"{0}\" на \"{1}\" за всички елементи?",
|
||||
@@ -745,19 +802,27 @@
|
||||
"MessageConfirmRenameTag": "Сигурни ли сте, че искате да преименувате таг \"{0}\" на \"{1}\" за всички елементи?",
|
||||
"MessageConfirmRenameTagMergeNote": "Забележка: Този таг вече съществува и ще бъде слято.",
|
||||
"MessageConfirmRenameTagWarning": "Внимание! Вече съществува подобен таг с различно писане \"{0}\".",
|
||||
"MessageConfirmResetProgress": "Сигурни ли сте, че искате да нулирате прогреса си?",
|
||||
"MessageConfirmSendEbookToDevice": "Сигурни ли сте, че искате да изпратите {0} електронна книга \"{1}\" до устройство \"{2}\"?",
|
||||
"MessageConfirmUnlinkOpenId": "Сигурни ли сте, че искате да отвържете този потребител от OpenID?",
|
||||
"MessageDaysListenedInTheLastYear": "{0} дни слушане през последната година",
|
||||
"MessageDownloadingEpisode": "Сваля епизод",
|
||||
"MessageDragFilesIntoTrackOrder": "Плъзнете файлове в правилния ред на каналите",
|
||||
"MessageEmbedFailed": "Вграждането беше неуспешно!",
|
||||
"MessageEmbedFinished": "Вграждането завърши!",
|
||||
"MessageEmbedQueue": "Поставено в опашката за вграждане на метаданни ({0} в опашката)",
|
||||
"MessageEpisodesQueuedForDownload": "{0} Епизод(и) са сложени за сваляне",
|
||||
"MessageEreaderDevices": "За да осигурите доставката на е-книги, може да се наложи да добавите горепосочения имейл адрес като валиден подател за всяко устройство, изброено по-долу.",
|
||||
"MessageFeedURLWillBe": "Адресът на емисията ще бъде {0}",
|
||||
"MessageFetching": "Извличане...",
|
||||
"MessageForceReScanDescription": "ще сканира всички файлове отново като прясно сканиране. Аудио файлове ID3 тагове, OPF файлове и текстови файлове ще бъдат сканирани като нови.",
|
||||
"MessageHeatmapListeningTimeTooltip": "<strong>{0} слушане</strong> на {1}",
|
||||
"MessageHeatmapNoListeningSessions": "Няма сесии за слушане на {0}",
|
||||
"MessageImportantNotice": "Важно Съобщение!",
|
||||
"MessageInsertChapterBelow": "Вмъкни глава под",
|
||||
"MessageItemsSelected": "{0} избрани",
|
||||
"MessageItemsUpdated": "{0} елемента обновени",
|
||||
"MessageInvalidAsin": "Невалиден ASIN",
|
||||
"MessageItemsSelected": "{0} избрани елемента",
|
||||
"MessageItemsUpdated": "{0} обновени елемента",
|
||||
"MessageJoinUsOn": "Присъединете се към нас",
|
||||
"MessageLoading": "Зарежда...",
|
||||
"MessageLoadingFolders": "Зареждане на Папки...",
|
||||
@@ -778,6 +843,7 @@
|
||||
"MessageNoCollections": "Няма колекции",
|
||||
"MessageNoCoversFound": "Не са намерени корици",
|
||||
"MessageNoDescription": "Няма описание",
|
||||
"MessageNoDevices": "Няма устройства",
|
||||
"MessageNoDownloadsInProgress": "Няма изтегляния в прогрес",
|
||||
"MessageNoDownloadsQueued": "Няма изтегляния в опашка",
|
||||
"MessageNoEpisodeMatchesFound": "Няма намерени съвпадения за епизоди",
|
||||
@@ -791,6 +857,7 @@
|
||||
"MessageNoLogs": "Няма логове",
|
||||
"MessageNoMediaProgress": "Няма прогрес на медията",
|
||||
"MessageNoNotifications": "Няма известия",
|
||||
"MessageNoPodcastFeed": "Невалиден подкаст: Няма канал",
|
||||
"MessageNoPodcastsFound": "Няма намерени подкасти",
|
||||
"MessageNoResults": "Няма резултати",
|
||||
"MessageNoSearchResultsFor": "Няма резултати за \"{0}\"",
|
||||
@@ -799,13 +866,19 @@
|
||||
"MessageNoTasksRunning": "Няма вършещи се задачи",
|
||||
"MessageNoUpdatesWereNecessary": "Няма нужда от обновяване",
|
||||
"MessageNoUserPlaylists": "Нямате създадени плейлисти",
|
||||
"MessageNoUserPlaylistsHelp": "Плейлистите за частни. Само създалият ги потребител ще може да ги вижда.",
|
||||
"MessageNotYetImplemented": "Още не е изпълнено",
|
||||
"MessageOpmlPreviewNote": "Забележка: Това е преглед на анализирания OPML файл. Действителното заглавие на подкаста ще бъде взето от RSS фийда.",
|
||||
"MessageOr": "или",
|
||||
"MessagePauseChapter": "Пауза на глава",
|
||||
"MessagePlayChapter": "Пусни налчалото на глава",
|
||||
"MessagePlaylistCreateFromCollection": "Създай плейлист от колекция",
|
||||
"MessagePleaseWait": "Моля изчакайте...",
|
||||
"MessagePodcastHasNoRSSFeedForMatching": "Подкастът няма URL адрес на RSS feed за използване за съпоставяне",
|
||||
"MessagePodcastSearchField": "Въведи какво да търся или RSS емисия адрес",
|
||||
"MessageQuickEmbedInProgress": "Бързото вграждане е в процес на изпълнение",
|
||||
"MessageQuickEmbedQueue": "Поставено в опашката за бързо вграждане ({0} в опашката)",
|
||||
"MessageQuickMatchAllEpisodes": "Бързо Сравняване на Всички Епизоди",
|
||||
"MessageQuickMatchDescription": "Попълни празните детайли и корици с първия резултат от '{0}'. Не презаписва детайлите, освен ако не е активирана настройката 'Предпочети съвпадащи метаданни' на сървъра.",
|
||||
"MessageRemoveChapter": "Премахни глава",
|
||||
"MessageRemoveEpisodes": "Премахни {0} епизод(и)",
|
||||
@@ -815,11 +888,52 @@
|
||||
"MessageResetChaptersConfirm": "Сигурни ли сте, че искате да нулирате главите и да отмените промените, които сте направили?",
|
||||
"MessageRestoreBackupConfirm": "Сигурни ли сте, че искате да възстановите архива създаден на",
|
||||
"MessageRestoreBackupWarning": "Възстановяването на архив ще презапише цялата база данни, намираща се в /config и кориците в /metadata/items & /metadata/authors.<br /><br />Архивите не променят файловете в папките на вашата библиотека. Ако сте активирали настройките на сървъра за съхранение на корици и метаданни в папките на вашата библиотека, те няма да бъдат архивирани или презаписани.<br /><br />Всички клиенти, използващи вашия сървър, ще бъдат автоматично обновени.",
|
||||
"MessageScheduleLibraryScanNote": "За повече потребители се препоръчва да оставят този фийчър изключен и да оставят настройката \"Автоматично преглеждане за промени в библиотеката\" включена - тя автоматично ще засече промени в папките на вашата библиотека. Включете тази настройка ако \"Автоматично преглеждане за промени в библиотеката\" не рабови на вашата файлова система (например NFS).",
|
||||
"MessageScheduleRunEveryWeekdayAtTime": "Изпълни всеки {0} в {1}",
|
||||
"MessageSearchResultsFor": "Резултати от търсенето за",
|
||||
"MessageSelected": "{0} избрани",
|
||||
"MessageSeriesSequenceCannotContainSpaces": "Подредбата в серия не може да съдържа шпации",
|
||||
"MessageServerCouldNotBeReached": "Сървърът не може да бъде достигнат",
|
||||
"MessageSetChaptersFromTracksDescription": "Задайте глави, като използвате всеки аудио файл като глава и заглавие на главата като име на аудио файла",
|
||||
"MessageShareExpirationWillBe": "Изтичането ще бъде на <strong>{0}</strong>",
|
||||
"MessageShareExpiresIn": "Изтича след {0}",
|
||||
"MessageShareURLWillBe": "URL за споделяне ще бъде <strong>{0}</strong>",
|
||||
"MessageStartPlaybackAtTime": "Започни възпроизвеждане на \"{0}\" в {1}?",
|
||||
"MessageTaskAudioFileNotWritable": "На Аудио файл \"{0}\" не може да се записва",
|
||||
"MessageTaskCanceledByUser": "Задачата е отказана от потребител",
|
||||
"MessageTaskDownloadingEpisodeDescription": "Изтегляне на епизод \"{0}\"",
|
||||
"MessageTaskEmbeddingMetadata": "Вграждане на метаданни",
|
||||
"MessageTaskEmbeddingMetadataDescription": "Вграждане на метаданни в аудиокнига \"{0}\"",
|
||||
"MessageTaskEncodingM4b": "Кодиране M4B",
|
||||
"MessageTaskEncodingM4bDescription": "Кодиране на аудиокнига \"{0}\" в единичен m4b файл",
|
||||
"MessageTaskFailed": "Неуспешно",
|
||||
"MessageTaskFailedToBackupAudioFile": "Неуспешно създаване на разервно копие на аудио файл \"{0}\"",
|
||||
"MessageTaskFailedToCreateCacheDirectory": "Неуспешно създаване на директория за кеширане",
|
||||
"MessageTaskFailedToEmbedMetadataInFile": "Неуспешно вграждане на метаданни във файл \"{0}\"",
|
||||
"MessageTaskFailedToMergeAudioFiles": "Неуспешно сливане на аудио файловете",
|
||||
"MessageTaskFailedToMoveM4bFile": "Неуспешно преместване на m4b файл",
|
||||
"MessageTaskFailedToWriteMetadataFile": "Неуспешно записване на файла за метаданни",
|
||||
"MessageTaskMatchingBooksInLibrary": "Съответстващи книги в библиотека \"{0}\"",
|
||||
"MessageTaskNoFilesToScan": "Няма файлове за сканиране",
|
||||
"MessageTaskOpmlImport": "OPML импортиране",
|
||||
"MessageTaskOpmlImportDescription": "Създаване на подкасти от {0} RSS хранилки",
|
||||
"MessageTaskOpmlImportFeed": "OPML импортиран фийд",
|
||||
"MessageTaskOpmlImportFeedDescription": "Импортиране на RSS хранилка \"{0}\"",
|
||||
"MessageTaskOpmlImportFeedFailed": "Неуспешно взимане на подкаст фийд",
|
||||
"MessageTaskOpmlImportFeedPodcastDescription": "Създаване на подкаст \"{0}\"",
|
||||
"MessageTaskOpmlImportFeedPodcastExists": "На този път вече съществува подкаст",
|
||||
"MessageTaskOpmlImportFeedPodcastFailed": "Неуспешно създаване на подкаст",
|
||||
"MessageTaskOpmlImportFinished": "Добавени {0} подкаста",
|
||||
"MessageTaskOpmlParseFailed": "Неуспешно анализиране на OPML файла",
|
||||
"MessageTaskOpmlParseFastFail": "Невалиден OPML файл, не беше намерен нито <opml> таг нито <outline> таг",
|
||||
"MessageTaskOpmlParseNoneFound": "Няма намерени канали във OPML файла",
|
||||
"MessageTaskScanItemsAdded": "{0} добавени",
|
||||
"MessageTaskScanItemsMissing": "{0} липсващи",
|
||||
"MessageTaskScanItemsUpdated": "{0} обновени",
|
||||
"MessageTaskScanNoChangesNeeded": "Не са нужни промени",
|
||||
"MessageTaskScanningFileChanges": "Проверка за промени във файловете в \"{0}\"",
|
||||
"MessageTaskScanningLibrary": "Сканиране на \"{0}\" библиотека",
|
||||
"MessageTaskTargetDirectoryNotWritable": "Целевата директория не е достъпна за запис",
|
||||
"MessageThinking": "Мисля...",
|
||||
"MessageUploaderItemFailed": "Неуспешно качване",
|
||||
"MessageUploaderItemSuccess": "Успешно качване!",
|
||||
@@ -837,43 +951,117 @@
|
||||
"NoteUploaderFoldersWithMediaFiles": "Папките с медийни файлове ще бъдат обработени като отделни елементи на библиотеката.",
|
||||
"NoteUploaderOnlyAudioFiles": "Ако качвате само аудио файлове, то всеки аудио файл ще бъде обработен като отделна аудиокнига.",
|
||||
"NoteUploaderUnsupportedFiles": "Неподдържаните файлове се игнорират. При избор или пускане на папка, други файлове, които не са в папка на елемент, се игнорират.",
|
||||
"NotificationOnBackupCompletedDescription": "Изпълнява се при завършване на създаване на резервно копие",
|
||||
"NotificationOnBackupFailedDescription": "Изпълнява се при неуспешено създаване на резервно копие",
|
||||
"NotificationOnEpisodeDownloadedDescription": "Изпълнява се при автоматично изтегляне на подкаст епизод",
|
||||
"NotificationOnRSSFeedDisabledDescription": "Изпълнява се, когато автоматичното изтегляне на епизодите е деактивирано, поради твърде много неуспешни опити",
|
||||
"NotificationOnRSSFeedFailedDescription": "Пуска се когато заявката за RSS фийд е неуспешна за автоматично сваляне на епизод",
|
||||
"NotificationOnTestDescription": "Event за тестване на системата за нотификации",
|
||||
"PlaceholderBulkChapterInput": "Въведете име на глава или използвайте номериране (прим. 'Епизод 1', 'Глава 10', '1.')",
|
||||
"PlaceholderNewCollection": "Ново име на колекцията",
|
||||
"PlaceholderNewFolderPath": "Нов път на папката",
|
||||
"PlaceholderNewPlaylist": "Ново име на плейлиста",
|
||||
"PlaceholderSearch": "Търсене...",
|
||||
"PlaceholderSearchEpisode": "Търсене на Епизоди...",
|
||||
"StatsAuthorsAdded": "добаврени автори",
|
||||
"StatsBooksAdded": "добавени книги",
|
||||
"StatsBooksAdditional": "Някой от вкючените добавки…",
|
||||
"StatsBooksFinished": "завършени книги",
|
||||
"StatsBooksFinishedThisYear": "Някой от книгите приключени тази година…",
|
||||
"StatsBooksListenedTo": "слушани книги",
|
||||
"StatsCollectionGrewTo": "Твоята книжна колекция израсна до…",
|
||||
"StatsSessions": "сесии",
|
||||
"StatsSpentListening": "прекарано в слушане",
|
||||
"StatsTopAuthor": "ТОП АВТОР",
|
||||
"StatsTopAuthors": "ТОП АВТОРИ",
|
||||
"StatsTopGenre": "ТОП ЖАНР",
|
||||
"StatsTopGenres": "ТОП ЖАНРА",
|
||||
"StatsTopMonth": "ТОП МЕСЕЦ",
|
||||
"StatsTopNarrator": "ТОП РАЗКАЗВАЧ",
|
||||
"StatsTopNarrators": "ТОП РАЗКАЗВАЧИ",
|
||||
"StatsTotalDuration": "С пълно времетраене…",
|
||||
"StatsYearInReview": "ГОДИНАТА В ПРЕГЛЕД",
|
||||
"ToastAccountUpdateSuccess": "Успешно обновяване на акаунта",
|
||||
"ToastAppriseUrlRequired": "Трябва да въведете Apprise URL",
|
||||
"ToastAsinRequired": "ASIN-а е задължителен",
|
||||
"ToastAuthorImageRemoveSuccess": "Авторската снимка е премахната",
|
||||
"ToastAuthorNotFound": "Автор \"{0}\" не е намерен",
|
||||
"ToastAuthorRemoveSuccess": "Арторът е премахнат",
|
||||
"ToastAuthorSearchNotFound": "Авторът не е намерен",
|
||||
"ToastAuthorUpdateMerged": "Обновяване на автора сливано",
|
||||
"ToastAuthorUpdateSuccess": "Автора обновен",
|
||||
"ToastAuthorUpdateSuccessNoImageFound": "Автор обновен (не е намерена снимка)",
|
||||
"ToastBackupAppliedSuccess": "Архивът е приложен",
|
||||
"ToastBackupCreateFailed": "Неуспешно създаване на архив",
|
||||
"ToastBackupCreateSuccess": "Архивът е създаден",
|
||||
"ToastBackupDeleteFailed": "Неуспешно изтриване на архив",
|
||||
"ToastBackupDeleteSuccess": "Архивът е изтрит",
|
||||
"ToastBackupInvalidMaxKeep": "Невалиден брой за архиви за запазване",
|
||||
"ToastBackupInvalidMaxSize": "Невалиден максимален рамер на архив",
|
||||
"ToastBackupRestoreFailed": "Неуспешно възстановяване на архив",
|
||||
"ToastBackupUploadFailed": "Неуспешно качване на архив",
|
||||
"ToastBackupUploadSuccess": "Архивът е качен",
|
||||
"ToastBatchApplyDetailsToItemsSuccess": "Детайли приложени на предмети",
|
||||
"ToastBatchDeleteFailed": "Груповото изтриване се провали",
|
||||
"ToastBatchDeleteSuccess": "Успешно групово изтриване",
|
||||
"ToastBatchQuickMatchFailed": "Груповото Бързо Съвпадение се провали!",
|
||||
"ToastBatchQuickMatchStarted": "Груповото Бързо Съвпадение на {0} книги започна!",
|
||||
"ToastBatchUpdateFailed": "Неуспешно групово актуализиране",
|
||||
"ToastBatchUpdateSuccess": "Успешно групово актуализиране",
|
||||
"ToastBookmarkCreateFailed": "Неуспешно създаване на отметка",
|
||||
"ToastBookmarkCreateSuccess": "Отметката е създадена",
|
||||
"ToastBookmarkRemoveSuccess": "Отметката е премахната",
|
||||
"ToastBulkChapterInvalidCount": "Въведете число между 1 и 150",
|
||||
"ToastCachePurgeFailed": "Неуспешно изчистване на кеша",
|
||||
"ToastCachePurgeSuccess": "Успешно изчистване на кеша",
|
||||
"ToastChapterLocked": "Главата е заключена.",
|
||||
"ToastChapterStartTimeAdjusted": "Начално време на главате е настоено с {0} секунди",
|
||||
"ToastChaptersAllLocked": "Всички глави са заключени. Оключете някой глави за да преместите техните времена.",
|
||||
"ToastChaptersHaveErrors": "Главите имат грешки",
|
||||
"ToastChaptersInvalidShiftAmountLast": "Невалидно време за преместване. Началният час на последната глава ще превиши общата продължителност на аудиокнигата.",
|
||||
"ToastChaptersInvalidShiftAmountStart": "Невалидно време за преместване. Първата глава ще има нулева или отрицателна дължина и ще бъде презаписана от втората глава. Увеличете началното време на втората глава.",
|
||||
"ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия",
|
||||
"ToastChaptersRemoved": "Главите са премахнати",
|
||||
"ToastChaptersUpdated": "Главите са актуализирани",
|
||||
"ToastCollectionItemsAddFailed": "Неуспешно добавяне на елемент(и) към колекцията",
|
||||
"ToastCollectionRemoveSuccess": "Колекцията е премахната",
|
||||
"ToastCollectionUpdateSuccess": "Колекцията е обновена",
|
||||
"ToastConnectionNotAvailable": "Няма връзка. Моля, опитайте отново по-късно",
|
||||
"ToastCoverSearchFailed": "Търсенето на корица е неуспешно",
|
||||
"ToastCoverUpdateFailed": "Обновяването на корицата е неуспешно",
|
||||
"ToastDateTimeInvalidOrIncomplete": "Датата и часът са невалидни или непълни",
|
||||
"ToastDeleteFileFailed": "Неуспешно изтриване на файла",
|
||||
"ToastDeleteFileSuccess": "Успешно изтриване на файла",
|
||||
"ToastDeviceAddFailed": "Неуспешно добавяне на устройство",
|
||||
"ToastDeviceNameAlreadyExists": "Вече съществува четец с това име",
|
||||
"ToastDeviceTestEmailFailed": "Неуспешно изпращане на тестов имейл",
|
||||
"ToastDeviceTestEmailSuccess": "Тестовият имейл е изпратен",
|
||||
"ToastEmailSettingsUpdateSuccess": "Имейл настройките са актуализирани",
|
||||
"ToastEncodeCancelFailed": "Неуспешно отменяне на кодирането",
|
||||
"ToastEncodeCancelSucces": "Кодирането е отменено",
|
||||
"ToastEpisodeDownloadQueueClearFailed": "Неуспешно изчистване на опашката",
|
||||
"ToastEpisodeDownloadQueueClearSuccess": "Опашката за изтегляне на епизоди е изчистена",
|
||||
"ToastEpisodeUpdateSuccess": "{0} епизода са актуализирани",
|
||||
"ToastErrorCannotShare": "Не може да се споделя директно от това устройство",
|
||||
"ToastFailedToCreate": "Неуспешно създаване",
|
||||
"ToastFailedToDelete": "Неуспешно изтриване",
|
||||
"ToastFailedToLoadData": "Неуспешно зареждане на данни",
|
||||
"ToastFailedToMatch": "Неуспешно съвпадение",
|
||||
"ToastFailedToShare": "Неуспешно споделяне",
|
||||
"ToastFailedToUpdate": "Неуспешно актуализиране",
|
||||
"ToastInvalidImageUrl": "Невалиден URL адрес на изображение",
|
||||
"ToastInvalidMaxEpisodesToDownload": "Невалиден максимален брой епизоди за изтегляне",
|
||||
"ToastInvalidUrl": "Невалиден URL адрес",
|
||||
"ToastInvalidUrls": "Един или повече URL адреси са невалидни",
|
||||
"ToastItemCoverUpdateSuccess": "Корицата на елемента е обновена",
|
||||
"ToastItemDeletedFailed": "Неуспешно изтриване на елемента",
|
||||
"ToastItemDeletedSuccess": "Елементът е изтрит",
|
||||
"ToastItemDetailsUpdateSuccess": "Детайлите на елемента са обновени",
|
||||
"ToastItemMarkedAsFinishedFailed": "Неуспешно маркиране като Завършено",
|
||||
"ToastItemMarkedAsFinishedSuccess": "Елементът е маркиран като завършен",
|
||||
"ToastItemMarkedAsNotFinishedFailed": "Неуспешно маркиране като Незавършено",
|
||||
"ToastItemMarkedAsNotFinishedSuccess": "Елементът е маркиран като незавършен",
|
||||
"ToastItemUpdateSuccess": "Елементът е актуализиран",
|
||||
"ToastLibraryCreateFailed": "Неуспешно създаване на библиотека",
|
||||
"ToastLibraryCreateSuccess": "Библиотеката \"{0}\" е създадена",
|
||||
"ToastLibraryDeleteFailed": "Неуспешно изтриване на библиотека",
|
||||
@@ -881,28 +1069,97 @@
|
||||
"ToastLibraryScanFailedToStart": "Неуспешно стартиране на сканиране",
|
||||
"ToastLibraryScanStarted": "Сканирането на библиотеката е стартирано",
|
||||
"ToastLibraryUpdateSuccess": "Библиотеката \"{0}\" е обновена",
|
||||
"ToastMatchAllAuthorsFailed": "Неуспешно съвпадение на всички автори",
|
||||
"ToastMetadataFilesRemovedError": "Грешка при премахване на metadata.{0} файлове",
|
||||
"ToastMetadataFilesRemovedNoneFound": "Не са намерени metadata.{0} файлове в библиотеката",
|
||||
"ToastMetadataFilesRemovedNoneRemoved": "Не са премахнати metadata.{0} файлове",
|
||||
"ToastMetadataFilesRemovedSuccess": "Премахнати са {0} файла metadata.{1}",
|
||||
"ToastMustHaveAtLeastOnePath": "Трябва да има поне един път",
|
||||
"ToastNameEmailRequired": "Изискват се име и имейл",
|
||||
"ToastNameRequired": "Изисква се име",
|
||||
"ToastNewApiKeyUserError": "Трябва да изберете потребител",
|
||||
"ToastNewEpisodesFound": "Намерени са {0} нови епизода",
|
||||
"ToastNewUserCreatedFailed": "Неуспешно създаване на акаунт: „{0}“",
|
||||
"ToastNewUserCreatedSuccess": "Създаден е нов акаунт",
|
||||
"ToastNewUserLibraryError": "Трябва да изберете поне една библиотека",
|
||||
"ToastNewUserPasswordError": "Трябва да има парола; само root потребителят може да бъде с празна парола",
|
||||
"ToastNewUserTagError": "Трябва да изберете поне един етикет",
|
||||
"ToastNewUserUsernameError": "Въведете потребителско име",
|
||||
"ToastNoNewEpisodesFound": "Не са намерени нови епизоди",
|
||||
"ToastNoRSSFeed": "Подкастът няма RSS емисия",
|
||||
"ToastNoUpdatesNecessary": "Не са необходими актуализации",
|
||||
"ToastNotificationCreateFailed": "Неуспешно създаване на известие",
|
||||
"ToastNotificationDeleteFailed": "Неуспешно изтриване на известието",
|
||||
"ToastNotificationFailedMaximum": "Максималният брой неуспешни опити трябва да бъде >= 0",
|
||||
"ToastNotificationQueueMaximum": "Максималната опашка за известия трябва да бъде >= 0",
|
||||
"ToastNotificationSettingsUpdateSuccess": "Настройките за известия са актуализирани",
|
||||
"ToastNotificationTestTriggerFailed": "Неуспешно задействане на тестово известие",
|
||||
"ToastNotificationTestTriggerSuccess": "Тестовото известие е задействано",
|
||||
"ToastNotificationUpdateSuccess": "Известието е актуализирано",
|
||||
"ToastPlaylistCreateFailed": "Неуспешно създаване на плейлист",
|
||||
"ToastPlaylistCreateSuccess": "Плейлистът е създаден",
|
||||
"ToastPlaylistRemoveSuccess": "Плейлистът е премахнат",
|
||||
"ToastPlaylistUpdateSuccess": "Плейлистът е обновен",
|
||||
"ToastPodcastCreateFailed": "Неуспешно създаване на подкаст",
|
||||
"ToastPodcastCreateSuccess": "Подкаст успешно създаден",
|
||||
"ToastPodcastEpisodeUpdated": "Епизодът е актуализиран",
|
||||
"ToastPodcastGetFeedFailed": "Неуспешно извличане на емисията на подкаста",
|
||||
"ToastPodcastNoEpisodesInFeed": "Не са намерени епизоди в RSS емисията",
|
||||
"ToastPodcastNoRssFeed": "Подкастът няма RSS емисия",
|
||||
"ToastProgressIsNotBeingSynced": "Напредъкът не се синхронизира, рестартирайте възпроизвеждането",
|
||||
"ToastProviderCreatedFailed": "Неуспешно добавяне на доставчик",
|
||||
"ToastProviderCreatedSuccess": "Добавен е нов доставчик",
|
||||
"ToastProviderNameAndUrlRequired": "Изискват се име и URL адрес",
|
||||
"ToastProviderRemoveSuccess": "Доставчикът е премахнат",
|
||||
"ToastRSSFeedCloseFailed": "Неуспешно затваряне на RSS емисията",
|
||||
"ToastRSSFeedCloseSuccess": "RSS емисията е затворена",
|
||||
"ToastRemoveFailed": "Неуспешно премахване",
|
||||
"ToastRemoveItemFromCollectionFailed": "Неуспешно премахване на елемент от колекция",
|
||||
"ToastRemoveItemFromCollectionSuccess": "Елементът е премахнат от колекция",
|
||||
"ToastRemoveItemsWithIssuesFailed": "Неуспешно премахване на елементите от библиотеката с проблеми",
|
||||
"ToastRemoveItemsWithIssuesSuccess": "Елементите от библиотеката с проблеми са премахнати",
|
||||
"ToastRenameFailed": "Неуспешно преименуване",
|
||||
"ToastRescanFailed": "Повторното сканиране е неуспешно за {0}",
|
||||
"ToastRescanRemoved": "Повторното сканиране завърши: елементът е премахнат",
|
||||
"ToastRescanUpToDate": "Повторното сканиране завърши: елементът вече е актуален",
|
||||
"ToastRescanUpdated": "Повторното сканиране завърши: елементът е актуализиран",
|
||||
"ToastScanFailed": "Неуспешно сканиране на елемент от библиотеката",
|
||||
"ToastSelectAtLeastOneUser": "Изберете поне един потребител",
|
||||
"ToastSendEbookToDeviceFailed": "Неуспешно изпращане на електронна книга до устройство",
|
||||
"ToastSendEbookToDeviceSuccess": "Електронната книга е изпратена до устройство \"{0}\"",
|
||||
"ToastSeriesSubmitFailedSameName": "Не могат да бъдат добавени два сериала с едно и също име",
|
||||
"ToastSeriesUpdateFailed": "Неуспешно обновяване на серия",
|
||||
"ToastSeriesUpdateSuccess": "Серията е обновена",
|
||||
"ToastServerSettingsUpdateSuccess": "Настройките на сървъра са актуализирани",
|
||||
"ToastSessionCloseFailed": "Неуспешно затваряне на сесията",
|
||||
"ToastSessionDeleteFailed": "Неуспешно изтриване на сесия",
|
||||
"ToastSessionDeleteSuccess": "Сесията е изтрита",
|
||||
"ToastSleepTimerDone": "Таймерът за заспиване приключи... zZzzZz",
|
||||
"ToastSlugMustChange": "Краткият URL (slug) съдържа невалидни символи",
|
||||
"ToastSlugRequired": "Изисква се кратък URL (slug)",
|
||||
"ToastSocketConnected": "Свързан сокет",
|
||||
"ToastSocketDisconnected": "Сокетът е прекъснат",
|
||||
"ToastSocketFailedToConnect": "Неуспешно свързване на сокет",
|
||||
"ToastSortingPrefixesEmptyError": "Трябва да има поне 1 префикс за сортиране",
|
||||
"ToastSortingPrefixesUpdateSuccess": "Префиксите за сортиране са актуализирани ({0} елемента)",
|
||||
"ToastTitleRequired": "Изисква се заглавие",
|
||||
"ToastUnknownError": "Неизвестна грешка",
|
||||
"ToastUnlinkOpenIdFailed": "Неуспешно прекъсване на връзката на потребителя с OpenID",
|
||||
"ToastUnlinkOpenIdSuccess": "Връзката на потребителя с OpenID е прекъсната",
|
||||
"ToastUploaderFilepathExistsError": "Файловият път „{0}“ вече съществува на сървъра",
|
||||
"ToastUploaderItemExistsInSubdirectoryError": "Елементът „{0}“ използва поддиректория на пътя за качване.",
|
||||
"ToastUserDeleteFailed": "Неуспешно изтриване на потребител",
|
||||
"ToastUserDeleteSuccess": "Потребителят е изтрит"
|
||||
"ToastUserDeleteSuccess": "Потребителят е изтрит",
|
||||
"ToastUserPasswordChangeSuccess": "Паролата е променена успешно",
|
||||
"ToastUserPasswordMismatch": "Паролите не съвпадат",
|
||||
"ToastUserPasswordMustChange": "Новата парола не може да бъде същата като старата",
|
||||
"ToastUserRootRequireName": "Трябва да въведете root потребителско име",
|
||||
"TooltipAddChapters": "Добавяне на глава(и)",
|
||||
"TooltipAddOneSecond": "Добавяне на 1 секунда",
|
||||
"TooltipAdjustChapterStart": "Кликнете за коригиране на началния час",
|
||||
"TooltipLockAllChapters": "Заключване на всички глави",
|
||||
"TooltipLockChapter": "Заключване на глава (Shift+клик за диапазон)",
|
||||
"TooltipSubtractOneSecond": "Изваждане на 1 секунда",
|
||||
"TooltipUnlockAllChapters": "Отключване на всички глави",
|
||||
"TooltipUnlockChapter": "Отключване на глава (Shift+клик за диапазон)"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"ButtonAdd": "যোগ করুন",
|
||||
"ButtonAddApiKey": "এপিআই কী যোগ করুন",
|
||||
"ButtonAddChapters": "অধ্যায় যোগ করুন",
|
||||
"ButtonAddDevice": "ডিভাইস যোগ করুন",
|
||||
"ButtonAddLibrary": "লাইব্রেরি যোগ করুন",
|
||||
@@ -10,6 +11,8 @@
|
||||
"ButtonApplyChapters": "অধ্যায় প্রয়োগ করুন",
|
||||
"ButtonAuthors": "লেখকগণ",
|
||||
"ButtonBack": "পেছনে যান",
|
||||
"ButtonBatchEditPopulateFromExisting": "বিদ্যমান থেকে পূরণ করুন",
|
||||
"ButtonBatchEditPopulateMapDetails": "ম্যাপ থেকে পূরণ করুন",
|
||||
"ButtonBrowseForFolder": "ফোল্ডারের জন্য ব্রাউজ করুন",
|
||||
"ButtonCancel": "বাতিল করুন",
|
||||
"ButtonCancelEncode": "এনকোড বাতিল করুন",
|
||||
@@ -18,6 +21,7 @@
|
||||
"ButtonChooseAFolder": "একটি ফোল্ডার চয়ন করুন",
|
||||
"ButtonChooseFiles": "ফাইল চয়ন করুন",
|
||||
"ButtonClearFilter": "ফিল্টার পরিষ্কার করুন",
|
||||
"ButtonClose": "বন্ধ করুন",
|
||||
"ButtonCloseFeed": "ফিড বন্ধ করুন",
|
||||
"ButtonCloseSession": "খোলা সেশন বন্ধ করুন",
|
||||
"ButtonCollections": "সংগ্রহ",
|
||||
@@ -117,11 +121,13 @@
|
||||
"HeaderAccount": "অ্যাকাউন্ট",
|
||||
"HeaderAddCustomMetadataProvider": "কাস্টম মেটাডেটা সরবরাহকারী যোগ করুন",
|
||||
"HeaderAdvanced": "অ্যাডভান্সড",
|
||||
"HeaderApiKeys": "এপিআই কী সমূহ",
|
||||
"HeaderAppriseNotificationSettings": "বিজ্ঞপ্তি সেটিংস অবহিত করুন",
|
||||
"HeaderAudioTracks": "অডিও ট্র্যাকসগুলো",
|
||||
"HeaderAudiobookTools": "অডিওবই ফাইল ম্যানেজমেন্ট টুলস",
|
||||
"HeaderAuthentication": "প্রমাণীকরণ",
|
||||
"HeaderBackups": "ব্যাকআপ",
|
||||
"HeaderBulkChapterModal": "একাধিক অধ্যায় যোগ করুন",
|
||||
"HeaderChangePassword": "পাসওয়ার্ড পরিবর্তন করুন",
|
||||
"HeaderChapters": "অধ্যায়",
|
||||
"HeaderChooseAFolder": "একটি ফোল্ডার চয়ন করুন",
|
||||
@@ -160,6 +166,7 @@
|
||||
"HeaderMetadataOrderOfPrecedence": "মেটাডেটা অগ্রাধিকারের ক্রম",
|
||||
"HeaderMetadataToEmbed": "এম্বেড করার জন্য মেটাডেটা",
|
||||
"HeaderNewAccount": "নতুন অ্যাকাউন্ট",
|
||||
"HeaderNewApiKey": "নতুন API কী",
|
||||
"HeaderNewLibrary": "নতুন লাইব্রেরি",
|
||||
"HeaderNotificationCreate": "বিজ্ঞপ্তি তৈরি করুন",
|
||||
"HeaderNotificationUpdate": "বিজ্ঞপ্তি আপডেট করুন",
|
||||
@@ -175,6 +182,7 @@
|
||||
"HeaderPlaylist": "প্লেলিস্ট",
|
||||
"HeaderPlaylistItems": "প্লেলিস্ট আইটেম",
|
||||
"HeaderPodcastsToAdd": "যোগ করার জন্য পডকাস্ট",
|
||||
"HeaderPresets": "প্রিসেট",
|
||||
"HeaderPreviewCover": "কভার ্দেখুন",
|
||||
"HeaderRSSFeedGeneral": "আরএসএস বিবরণ",
|
||||
"HeaderRSSFeedIsOpen": "আরএসএস ফিড খোলা আছে",
|
||||
@@ -192,6 +200,7 @@
|
||||
"HeaderSettingsExperimental": "পরীক্ষামূলক ফিচার",
|
||||
"HeaderSettingsGeneral": "সাধারণ",
|
||||
"HeaderSettingsScanner": "স্ক্যানার",
|
||||
"HeaderSettingsSecurity": "নিরাপত্তা",
|
||||
"HeaderSettingsWebClient": "ওয়েব ক্লায়েন্ট",
|
||||
"HeaderSleepTimer": "স্লিপ টাইমার",
|
||||
"HeaderStatsLargestItems": "সবচেয়ে বড় আইটেম",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user