mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-02 00:40:39 +02:00
Compare commits
696 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| f77de1743e | |||
| 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 | |||
| 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 | |||
| 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 | |||
| a992400d6a | |||
| 108b2a60f5 | |||
| af684e6a69 | |||
| 5336d0525e | |||
| bb4eec9355 | |||
| 28404f37b8 | |||
| 7b92c15a46 | |||
| c150ed4e98 | |||
| cb7632b216 | |||
| b8849677de | |||
| 9bf8d7de11 | |||
| 6634ce8fd4 | |||
| 9d4303ef7b | |||
| 1f7be58124 | |||
| 6b8b27b04f | |||
| ba4061e5a4 | |||
| 5017e7ce9e | |||
| 693dc00fa3 | |||
| f3f5f3b9bd | |||
| b515c6c746 | |||
| 35e196238a | |||
| 2dc93258f1 | |||
| 5123f7d240 | |||
| 06d3bd76a8 | |||
| 52196afd99 | |||
| 3e44ee6f50 | |||
| 9841826e10 | |||
| def93d18ec | |||
| 387a3d05b4 | |||
| 398d04fc08 | |||
| c5e5e516af | |||
| 1c6f99b876 | |||
| d0af82e71a | |||
| 76e7616439 | |||
| fe99a269bc | |||
| 5315f65023 | |||
| c2809808c3 | |||
| 204ac4f204 | |||
| accd5d1096 | |||
| 5025c6a3ea | |||
| 6d0d1415e4 | |||
| 514f5c2409 | |||
| 2cc58b2c8a | |||
| 777a055fcd | |||
| b45085d2d6 | |||
| 22f6e86a12 | |||
| dc6783ea76 | |||
| a6f10ca48e | |||
| aac01d6d9a | |||
| a617994207 | |||
| 7a33a412fc | |||
| 0135b3560c | |||
| 6968a5c02a | |||
| 5e2bb0b12c | |||
| 7122756e58 | |||
| 8ecc912c2d | |||
| c8cea4e6af | |||
| 9da0be6d36 | |||
| 0c5d05d319 | |||
| c41bdb951c | |||
| 4a3eb7727b | |||
| 81640464ba | |||
| 54815ea9c7 | |||
| 679ffed0ea | |||
| 09397cf3de | |||
| eda7036f70 | |||
| e669a8d378 | |||
| 8e01859075 | |||
| f0525d4f0d | |||
| 84c9c6cb50 | |||
| 346df3680c | |||
| 6aa7c8a3d8 | |||
| 704c6f7bde | |||
| f01055f6e6 | |||
| 759c58d3f7 | |||
| 357176b301 | |||
| 9bb4dc3ab0 | |||
| 709c33f27a | |||
| 4d846e225a | |||
| 5dc6d613bd | |||
| 63ccdb68f0 | |||
| 424ef1aec3 | |||
| b6995ba5d1 | |||
| 9968743a93 | |||
| c377b57601 | |||
| 262d0b46e3 | |||
| 32fc4f6555 | |||
| 81572adab6 | |||
| 1ad2e71fd5 | |||
| db66b9eaeb | |||
| 28c2e62e61 | |||
| 96401c377c | |||
| 9d45880b37 | |||
| 9052ceedd3 | |||
| 4968864498 | |||
| f44c2d9e11 | |||
| 0c8e334b1a | |||
| abaa7b5ad0 | |||
| df01e493ec | |||
| 949c8ce230 | |||
| 9eaa0c26cd | |||
| d71f091e3e | |||
| 2589121908 | |||
| ff425212e7 | |||
| 243baaf775 | |||
| 7275b1063b | |||
| 4fd97510b8 | |||
| 6e67b1d9dd | |||
| 0fc6afec26 | |||
| c950ac7d69 | |||
| 8979e19e92 | |||
| 6a51cb07e8 | |||
| 846a8c3881 | |||
| 0cd698cc8d | |||
| 13d9462868 | |||
| d8e2ff8b0e | |||
| 35c2a5c1a3 | |||
| 19dc096d22 | |||
| 535ebc10f0 | |||
| 7486a0659b | |||
| 273866fe92 | |||
| 6425d95deb | |||
| 68a39449a2 | |||
| 8e08458ea2 | |||
| 1119ddef8a | |||
| 3d0219a866 | |||
| 6ce1806359 | |||
| f05a513767 | |||
| d03c338b48 | |||
| 5e5a988f7a | |||
| 6d1f0b27df | |||
| de25763a74 | |||
| a894ceb9cf | |||
| 387e58a714 | |||
| d01a7cb756 | |||
| cae874ef05 | |||
| 733afc3e29 | |||
| 0772730336 | |||
| 8b02fe07c8 | |||
| 98f93a665c | |||
| 754566b221 | |||
| f4f9adad35 | |||
| 16f7f1166e | |||
| f527b0f4d5 | |||
| 4f41df53c9 | |||
| 8a15f775a2 | |||
| 5e83bcd283 | |||
| 2fd5dfcb66 | |||
| 872ce4fa38 | |||
| ba792d91e5 | |||
| 4997c716db | |||
| fd72d05280 | |||
| 241b56ad45 | |||
| 635c384952 | |||
| ef930fd1b4 | |||
| 49997a1336 | |||
| 8d0434143c | |||
| 8e0319994e | |||
| 0ed6045d1e | |||
| 25c7e95a64 | |||
| 1781c4bbcb | |||
| c4ce72d44e | |||
| 78813c4b28 | |||
| 990baa2dc6 | |||
| c85f4467d2 | |||
| 59f7609054 | |||
| 2ef827e3fa | |||
| 5cadc8d90f | |||
| 40e7e36ef6 | |||
| d60ad96f8a | |||
| 46ba342d49 | |||
| ace6b2b81f | |||
| fa7e2dfafe | |||
| 015310c15d | |||
| f624f04dec | |||
| 7c13cfcda2 | |||
| fc265dadae | |||
| f9905f887e | |||
| eb72bfbbc0 | |||
| c268cace09 | |||
| 9666caf7a3 | |||
| 9e01e5c24e | |||
| 25e613a867 | |||
| fe23a86eaa | |||
| cb5a7d6aef | |||
| 7deb89ce7a | |||
| 1e300c77c9 | |||
| ed7cc42959 | |||
| f681ff68a1 | |||
| ba112bf9c2 | |||
| 718434545a | |||
| 0e9a4c95a9 | |||
| 3c997c8468 | |||
| eb49646256 | |||
| c54b5eadfd | |||
| 659c671c25 | |||
| 0df5a7816d | |||
| 26c976b6b9 | |||
| bdeb22615e | |||
| 257bf2ebe0 | |||
| fc33da447a | |||
| df45347690 | |||
| b876256736 | |||
| 3ce6e45761 | |||
| 5ac6b85da1 | |||
| 69e0a0732a | |||
| 087835a9f3 | |||
| 1f7b181b7b | |||
| 1afb8840db | |||
| 3cc527484d | |||
| a1074e69ac | |||
| 65aec6a099 | |||
| 38957d4f32 | |||
| a2dc76e190 | |||
| dc3c978f8d | |||
| 13fac2d5bc | |||
| fd0af6b2dd |
@@ -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}}'
|
||||
|
||||
+34
-16
@@ -1,34 +1,32 @@
|
||||
ARG NUSQLITE3_DIR="/usr/local/lib/nusqlite3"
|
||||
ARG NUSQLITE3_PATH="${NUSQLITE3_DIR}/libnusqlite3.so"
|
||||
|
||||
### STAGE 0: Build client ###
|
||||
FROM node:20-alpine AS build
|
||||
FROM node:20-alpine AS build-client
|
||||
|
||||
WORKDIR /client
|
||||
COPY /client /client
|
||||
RUN npm ci && npm cache clean --force
|
||||
RUN npm run generate
|
||||
|
||||
### STAGE 1: Build server ###
|
||||
FROM node:20-alpine
|
||||
FROM node:20-alpine AS build-server
|
||||
|
||||
ARG NUSQLITE3_DIR
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN apk update && \
|
||||
apk add --no-cache --update \
|
||||
RUN apk add --no-cache --update \
|
||||
curl \
|
||||
tzdata \
|
||||
ffmpeg \
|
||||
make \
|
||||
python3 \
|
||||
g++ \
|
||||
tini \
|
||||
unzip
|
||||
|
||||
COPY --from=build /client/dist /client/dist
|
||||
COPY index.js package* /
|
||||
COPY server server
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
ENV NUSQLITE3_DIR="/usr/local/lib/nusqlite3"
|
||||
ENV NUSQLITE3_PATH="${NUSQLITE3_DIR}/libnusqlite3.so"
|
||||
WORKDIR /server
|
||||
COPY index.js package* /server
|
||||
COPY /server /server/server
|
||||
|
||||
RUN case "$TARGETPLATFORM" in \
|
||||
"linux/amd64") \
|
||||
@@ -42,14 +40,34 @@ RUN case "$TARGETPLATFORM" in \
|
||||
|
||||
RUN npm ci --only=production
|
||||
|
||||
RUN apk del make python3 g++
|
||||
### STAGE 2: Create minimal runtime image ###
|
||||
FROM node:20-alpine
|
||||
|
||||
ARG NUSQLITE3_DIR
|
||||
ARG NUSQLITE3_PATH
|
||||
|
||||
# Install only runtime dependencies
|
||||
RUN apk add --no-cache --update \
|
||||
tzdata \
|
||||
ffmpeg \
|
||||
tini
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy compiled frontend and server from build stages
|
||||
COPY --from=build-client /client/dist /app/client/dist
|
||||
COPY --from=build-server /server /app
|
||||
COPY --from=build-server ${NUSQLITE3_PATH} ${NUSQLITE3_PATH}
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
ENV PORT=80
|
||||
ENV NODE_ENV=production
|
||||
ENV CONFIG_PATH="/config"
|
||||
ENV METADATA_PATH="/metadata"
|
||||
ENV SOURCE="docker"
|
||||
ENV NUSQLITE3_DIR=${NUSQLITE3_DIR}
|
||||
ENV NUSQLITE3_PATH=${NUSQLITE3_PATH}
|
||||
|
||||
ENTRYPOINT ["tini", "--"]
|
||||
CMD ["node", "index.js"]
|
||||
|
||||
@@ -217,6 +217,16 @@ export default {
|
||||
})
|
||||
}
|
||||
|
||||
if (this.results.episodes?.length) {
|
||||
shelves.push({
|
||||
id: 'episodes',
|
||||
label: 'Episodes',
|
||||
labelStringKey: 'LabelEpisodes',
|
||||
type: 'episode',
|
||||
entities: this.results.episodes.map((res) => res.libraryItem)
|
||||
})
|
||||
}
|
||||
|
||||
if (this.results.series?.length) {
|
||||
shelves.push({
|
||||
id: 'series',
|
||||
|
||||
@@ -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>
|
||||
@@ -274,15 +263,10 @@ export default {
|
||||
isAuthorsPage() {
|
||||
return this.page === 'authors'
|
||||
},
|
||||
isAlbumsPage() {
|
||||
return this.page === 'albums'
|
||||
},
|
||||
numShowing() {
|
||||
return this.totalEntities
|
||||
},
|
||||
entityName() {
|
||||
if (this.isAlbumsPage) return 'Albums'
|
||||
|
||||
if (this.isPodcastLibrary) return this.$strings.LabelPodcasts
|
||||
if (!this.page) return this.$strings.LabelBooks
|
||||
if (this.isSeriesPage) return this.$strings.LabelSeries
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="flex items-center h-full px-1 overflow-hidden">
|
||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div class="grow px-2 episodeSearchCardContent">
|
||||
<p class="truncate text-sm">{{ episodeTitle }}</p>
|
||||
<p class="text-xs text-gray-200 truncate">{{ podcastTitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
episode: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
coverWidth() {
|
||||
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
|
||||
return 50
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem?.media || {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
episodeTitle() {
|
||||
return this.episode.title || 'No Title'
|
||||
},
|
||||
podcastTitle() {
|
||||
return this.mediaMetadata.title || 'No Title'
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.episodeSearchCardContent {
|
||||
width: calc(100% - 80px);
|
||||
height: 75px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
<template>
|
||||
<div ref="card" :id="`album-card-${index}`" :style="{ width: cardWidth + 'px' }" class="absolute top-0 left-0 rounded-xs z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div class="relative" :style="{ height: coverHeight + 'px' }">
|
||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||
<div class="w-full h-full bg-primary relative rounded-sm overflow-hidden">
|
||||
<covers-preview-cover ref="cover" :src="coverSrc" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative w-full">
|
||||
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6e h-6e rounded-md text-center" :style="{ width: Math.min(200, cardWidth) + 'px' }">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-xs border" :style="{ padding: `0em ${0.5}em` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8e h-8e py-1e rounded-md text-center">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ title }}</p>
|
||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ artist || ' ' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
index: Number,
|
||||
width: Number,
|
||||
height: {
|
||||
type: Number,
|
||||
default: 192
|
||||
},
|
||||
bookshelfView: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
albumMount: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
album: null,
|
||||
isSelectionMode: false,
|
||||
selected: false,
|
||||
isHovering: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
cardWidth() {
|
||||
return this.width || this.coverHeight
|
||||
},
|
||||
coverHeight() {
|
||||
return this.height * this.sizeMultiplier
|
||||
},
|
||||
/*
|
||||
cardHeight() {
|
||||
return this.coverHeight + this.bottomTextHeight
|
||||
},
|
||||
bottomTextHeight() {
|
||||
if (!this.isAlternativeBookshelfView) return 0
|
||||
const lineHeight = 1.5
|
||||
const remSize = 16
|
||||
const baseHeight = this.sizeMultiplier * lineHeight * remSize
|
||||
const titleHeight = this.labelFontSize * baseHeight
|
||||
const paddingHeight = 4 * 2 * this.sizeMultiplier // py-1
|
||||
return titleHeight + paddingHeight
|
||||
},
|
||||
*/
|
||||
coverSrc() {
|
||||
const config = this.$config || this.$nuxt.$config
|
||||
if (!this.album || !this.album.libraryItemId) return `${config.routerBasePath}/book_placeholder.jpg`
|
||||
return this.store.getters['globals/getLibraryItemCoverSrcById'](this.album.libraryItemId)
|
||||
},
|
||||
labelFontSize() {
|
||||
if (this.width < 160) return 0.75
|
||||
return 0.9
|
||||
},
|
||||
sizeMultiplier() {
|
||||
return this.store.getters['user/getSizeMultiplier']
|
||||
},
|
||||
title() {
|
||||
return this.album ? this.album.title : ''
|
||||
},
|
||||
artist() {
|
||||
return this.album ? this.album.artist : ''
|
||||
},
|
||||
store() {
|
||||
return this.$store || this.$nuxt.$store
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.store.state.libraries.currentLibraryId
|
||||
},
|
||||
isAlternativeBookshelfView() {
|
||||
const constants = this.$constants || this.$nuxt.$constants
|
||||
return this.bookshelfView == constants.BookshelfView.DETAIL
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setEntity(album) {
|
||||
this.album = album
|
||||
},
|
||||
setSelectionMode(val) {
|
||||
this.isSelectionMode = val
|
||||
},
|
||||
mouseover() {
|
||||
this.isHovering = true
|
||||
},
|
||||
mouseleave() {
|
||||
this.isHovering = false
|
||||
},
|
||||
clickCard() {
|
||||
if (!this.album) return
|
||||
// const router = this.$router || this.$nuxt.$router
|
||||
// router.push(`/album/${this.$encode(this.title)}`)
|
||||
},
|
||||
clickEdit() {
|
||||
this.$emit('edit', this.album)
|
||||
},
|
||||
destroy() {
|
||||
// destroy the vue listeners, etc
|
||||
this.$destroy()
|
||||
|
||||
// remove the element from the DOM
|
||||
if (this.$el && this.$el.parentNode) {
|
||||
this.$el.parentNode.removeChild(this.$el)
|
||||
} else if (this.$el && this.$el.remove) {
|
||||
this.$el.remove()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.albumMount) {
|
||||
this.setEntity(this.albumMount)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -39,6 +39,15 @@
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<p v-if="episodeResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">{{ $strings.LabelEpisodes }}</p>
|
||||
<template v-for="item in episodeResults">
|
||||
<li :key="item.libraryItem.recentEpisode.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||
<cards-episode-search-card :episode="item.libraryItem.recentEpisode" :library-item="item.libraryItem" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelAuthors }}</p>
|
||||
<template v-for="item in authorResults">
|
||||
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
@@ -100,6 +109,7 @@ export default {
|
||||
isFetching: false,
|
||||
search: null,
|
||||
podcastResults: [],
|
||||
episodeResults: [],
|
||||
bookResults: [],
|
||||
authorResults: [],
|
||||
seriesResults: [],
|
||||
@@ -115,7 +125,7 @@ export default {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
totalResults() {
|
||||
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.genreResults.length + this.podcastResults.length + this.narratorResults.length
|
||||
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.genreResults.length + this.podcastResults.length + this.narratorResults.length + this.episodeResults.length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -132,6 +142,7 @@ export default {
|
||||
this.search = null
|
||||
this.lastSearch = null
|
||||
this.podcastResults = []
|
||||
this.episodeResults = []
|
||||
this.bookResults = []
|
||||
this.authorResults = []
|
||||
this.seriesResults = []
|
||||
@@ -175,6 +186,7 @@ export default {
|
||||
if (!this.isFetching) return
|
||||
|
||||
this.podcastResults = searchResults.podcast || []
|
||||
this.episodeResults = searchResults.episodes || []
|
||||
this.bookResults = searchResults.book || []
|
||||
this.authorResults = searchResults.authors || []
|
||||
this.seriesResults = searchResults.series || []
|
||||
|
||||
@@ -94,6 +94,9 @@ export default {
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
userCanAccessExplicitContent() {
|
||||
return this.$store.getters['user/getUserCanAccessExplicitContent']
|
||||
},
|
||||
libraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
@@ -239,6 +242,15 @@ export default {
|
||||
sublist: false
|
||||
}
|
||||
]
|
||||
|
||||
if (this.userCanAccessExplicitContent) {
|
||||
items.push({
|
||||
text: this.$strings.LabelExplicit,
|
||||
value: 'explicit',
|
||||
sublist: false
|
||||
})
|
||||
}
|
||||
|
||||
if (this.userIsAdminOrUp) {
|
||||
items.push({
|
||||
text: this.$strings.LabelShareOpen,
|
||||
@@ -249,7 +261,7 @@ export default {
|
||||
return items
|
||||
},
|
||||
podcastItems() {
|
||||
return [
|
||||
const items = [
|
||||
{
|
||||
text: this.$strings.LabelAll,
|
||||
value: 'all'
|
||||
@@ -276,8 +288,23 @@ export default {
|
||||
text: this.$strings.ButtonIssues,
|
||||
value: 'issues',
|
||||
sublist: false
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelRSSFeedOpen,
|
||||
value: 'feed-open',
|
||||
sublist: false
|
||||
}
|
||||
]
|
||||
|
||||
if (this.userCanAccessExplicitContent) {
|
||||
items.push({
|
||||
text: this.$strings.LabelExplicit,
|
||||
value: 'explicit',
|
||||
sublist: false
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
},
|
||||
selectItems() {
|
||||
if (this.isSeries) return this.seriesItems
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<ui-text-input-with-label ref="sequenceInput" v-model="selectedSeries.sequence" :label="$strings.LabelSequence" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="error" class="text-error text-sm mt-2 p-1">{{ error }}</div>
|
||||
<div class="flex justify-end mt-2 p-1">
|
||||
<ui-btn type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
</div>
|
||||
@@ -34,12 +35,17 @@ export default {
|
||||
existingSeriesNames: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
originalSeriesSequence: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
el: null,
|
||||
content: null
|
||||
content: null,
|
||||
error: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -85,10 +91,17 @@ export default {
|
||||
}
|
||||
},
|
||||
submitSeriesForm() {
|
||||
this.error = null
|
||||
|
||||
if (this.$refs.newSeriesSelect) {
|
||||
this.$refs.newSeriesSelect.blur()
|
||||
}
|
||||
|
||||
if (this.selectedSeries.sequence !== this.originalSeriesSequence && this.selectedSeries.sequence.includes(' ')) {
|
||||
this.error = this.$strings.MessageSeriesSequenceCannotContainSpaces
|
||||
return
|
||||
}
|
||||
|
||||
this.$emit('submit')
|
||||
},
|
||||
clickClose() {
|
||||
@@ -100,6 +113,7 @@ export default {
|
||||
}
|
||||
},
|
||||
setShow() {
|
||||
this.error = null
|
||||
if (!this.el || !this.content) {
|
||||
this.init()
|
||||
}
|
||||
|
||||
@@ -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 || []
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -74,19 +74,12 @@ export default {
|
||||
mediaTracks() {
|
||||
return this.media.tracks || []
|
||||
},
|
||||
isSingleM4b() {
|
||||
return this.mediaTracks.length === 1 && this.mediaTracks[0].metadata.ext.toLowerCase() === '.m4b'
|
||||
},
|
||||
chapters() {
|
||||
return this.media.chapters || []
|
||||
},
|
||||
showM4bDownload() {
|
||||
if (!this.mediaTracks.length) return false
|
||||
return !this.isSingleM4b
|
||||
},
|
||||
showMp3Split() {
|
||||
if (!this.mediaTracks.length) return false
|
||||
return this.isSingleM4b && this.chapters.length
|
||||
return true
|
||||
},
|
||||
queuedEmbedLIds() {
|
||||
return this.$store.state.tasks.queuedEmbedLIds || []
|
||||
|
||||
@@ -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 [
|
||||
{
|
||||
|
||||
@@ -35,7 +35,14 @@
|
||||
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
||||
</div>
|
||||
<p v-if="episode.subtitle" class="mb-1 text-sm text-gray-300 line-clamp-2">{{ episode.subtitle }}</p>
|
||||
<p class="text-xs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- published -->
|
||||
<p class="text-xs text-gray-300 w-40">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
||||
<!-- duration -->
|
||||
<p v-if="episode.durationSeconds && !isNaN(episode.durationSeconds)" class="text-xs text-gray-300 min-w-28">{{ $strings.LabelDuration }}: {{ $elapsedPretty(episode.durationSeconds) }}</p>
|
||||
<!-- size -->
|
||||
<p v-if="episode.enclosure?.length && !isNaN(episode.enclosure.length) && Number(episode.enclosure.length) > 0" class="text-xs text-gray-300">{{ $strings.LabelSize }}: {{ $bytesPretty(Number(episode.enclosure.length)) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -244,8 +251,8 @@ export default {
|
||||
const sizeInMb = payloadSize / 1024 / 1024
|
||||
const sizeInMbPretty = sizeInMb.toFixed(2) + 'MB'
|
||||
console.log('Request size', sizeInMb)
|
||||
if (sizeInMb > 4.99) {
|
||||
return this.$toast.error(`Request is too large (${sizeInMbPretty}) should be < 5Mb`)
|
||||
if (sizeInMb > 9.99) {
|
||||
return this.$toast.error(`Request is too large (${sizeInMbPretty}) should be < 10Mb`)
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p>
|
||||
<div v-if="description" dir="auto" class="default-style less-spacing" v-html="description" />
|
||||
<div v-if="description" dir="auto" class="default-style less-spacing" @click="handleDescriptionClick" v-html="description" />
|
||||
<p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p>
|
||||
|
||||
<div class="w-full h-px bg-white/5 my-4" />
|
||||
@@ -34,6 +34,12 @@
|
||||
{{ audioFileSize }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grow">
|
||||
<p class="font-semibold text-xs mb-1">{{ $strings.LabelDuration }}</p>
|
||||
<p class="mb-2 text-xs">
|
||||
{{ audioFileDuration }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
@@ -68,7 +74,7 @@ export default {
|
||||
return this.episode.title || 'No Episode Title'
|
||||
},
|
||||
description() {
|
||||
return this.episode.description || ''
|
||||
return this.parseDescription(this.episode.description || '')
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem?.media || {}
|
||||
@@ -90,11 +96,49 @@ export default {
|
||||
|
||||
return this.$bytesPretty(size)
|
||||
},
|
||||
audioFileDuration() {
|
||||
const duration = this.episode.duration || 0
|
||||
return this.$elapsedPretty(duration)
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
methods: {
|
||||
handleDescriptionClick(e) {
|
||||
if (e.target.matches('span.time-marker')) {
|
||||
const time = parseInt(e.target.dataset.time)
|
||||
if (!isNaN(time)) {
|
||||
this.$eventBus.$emit('play-item', {
|
||||
episodeId: this.episodeId,
|
||||
libraryItemId: this.libraryItem.id,
|
||||
startTime: time
|
||||
})
|
||||
}
|
||||
e.preventDefault()
|
||||
}
|
||||
},
|
||||
parseDescription(description) {
|
||||
const timeMarkerLinkRegex = /<a href="#([^"]*?\b\d{1,2}:\d{1,2}(?::\d{1,2})?)">(.*?)<\/a>/g
|
||||
const timeMarkerRegex = /\b\d{1,2}:\d{1,2}(?::\d{1,2})?\b/g
|
||||
|
||||
function convertToSeconds(time) {
|
||||
const timeParts = time.split(':').map(Number)
|
||||
return timeParts.reduce((acc, part, index) => acc * 60 + part, 0)
|
||||
}
|
||||
|
||||
return description
|
||||
.replace(timeMarkerLinkRegex, (match, href, displayTime) => {
|
||||
const time = displayTime.match(timeMarkerRegex)[0]
|
||||
const seekTimeInSeconds = convertToSeconds(time)
|
||||
return `<span class="time-marker cursor-pointer text-blue-400 hover:text-blue-300" data-time="${seekTimeInSeconds}">${displayTime}</span>`
|
||||
})
|
||||
.replace(timeMarkerRegex, (match) => {
|
||||
const seekTimeInSeconds = convertToSeconds(match)
|
||||
return `<span class="time-marker cursor-pointer text-blue-400 hover:text-blue-300" data-time="${seekTimeInSeconds}">${match}</span>`
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
<template>
|
||||
<div id="lazy-episodes-table" class="w-full py-6">
|
||||
<div class="flex flex-wrap flex-col md:flex-row md:items-center mb-4">
|
||||
@@ -176,6 +175,13 @@ export default {
|
||||
return episodeProgress && !episodeProgress.isFinished
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// Swap values if sort descending
|
||||
if (this.sortDesc) {
|
||||
const temp = a
|
||||
a = b
|
||||
b = temp
|
||||
}
|
||||
|
||||
let aValue
|
||||
let bValue
|
||||
|
||||
@@ -194,10 +200,23 @@ export default {
|
||||
if (!bValue) bValue = Number.MAX_VALUE
|
||||
}
|
||||
|
||||
if (this.sortDesc) {
|
||||
return String(bValue).localeCompare(String(aValue), undefined, { numeric: true, sensitivity: 'base' })
|
||||
const primaryCompare = String(aValue).localeCompare(String(bValue), undefined, { numeric: true, sensitivity: 'base' })
|
||||
if (primaryCompare !== 0 || this.sortKey === 'publishedAt') return primaryCompare
|
||||
|
||||
// When sorting by season, secondary sort is by episode number
|
||||
if (this.sortKey === 'season') {
|
||||
const aEpisode = a.episode || ''
|
||||
const bEpisode = b.episode || ''
|
||||
|
||||
const secondaryCompare = String(aEpisode).localeCompare(String(bEpisode), undefined, { numeric: true, sensitivity: 'base' })
|
||||
if (secondaryCompare !== 0) return secondaryCompare
|
||||
}
|
||||
return String(aValue).localeCompare(String(bValue), undefined, { numeric: true, sensitivity: 'base' })
|
||||
|
||||
// Final sort by publishedAt
|
||||
let aPubDate = a.publishedAt || Number.MAX_VALUE
|
||||
let bPubDate = b.publishedAt || Number.MAX_VALUE
|
||||
|
||||
return String(aPubDate).localeCompare(String(bPubDate), undefined, { numeric: true, sensitivity: 'base' })
|
||||
})
|
||||
},
|
||||
episodesList() {
|
||||
@@ -220,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(() => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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" class="w-full" :class="inputClass" :trim-whitespace="trimWhitespace" @blur="inputBlurred" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -21,6 +21,7 @@ export default {
|
||||
type: String,
|
||||
default: 'text'
|
||||
},
|
||||
min: [String, Number],
|
||||
readonly: Boolean,
|
||||
disabled: Boolean,
|
||||
inputClass: String,
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</trix-toolbar>
|
||||
<trix-editor :toolbar="toolbarId" :contenteditable="!disabledEditor" :class="['trix-content']" ref="trix" :input="computedId" :placeholder="placeholder" @trix-change="handleContentChange" @trix-initialize="handleInitialize" @trix-focus="processTrixFocus" @trix-blur="processTrixBlur" />
|
||||
<trix-editor :toolbar="toolbarId" :contenteditable="!disabledEditor" :class="['trix-content']" ref="trix" :input="computedId" :placeholder="placeholder" @trix-change="handleContentChange" @trix-initialize="handleInitialize" @trix-focus="processTrixFocus" @trix-blur="processTrixBlur" @trix-attachment-add="handleAttachmentAdd" />
|
||||
<input type="hidden" :name="inputName" :id="computedId" :value="editorContent" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -316,6 +316,10 @@ export default {
|
||||
if (this.$refs.trix && this.$refs.trix.blur) {
|
||||
this.$refs.trix.blur()
|
||||
}
|
||||
},
|
||||
handleAttachmentAdd(event) {
|
||||
// Prevent pasting in images/any files from the browser
|
||||
event.attachment.remove()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
@@ -143,10 +143,18 @@ export default {
|
||||
localStorage.setItem('embedMetadataCodec', val)
|
||||
},
|
||||
getEncodingOptions() {
|
||||
return {
|
||||
codec: this.selectedCodec || 'aac',
|
||||
bitrate: this.selectedBitrate || '128k',
|
||||
channels: this.selectedChannels || 2
|
||||
if (this.showAdvancedView) {
|
||||
return {
|
||||
codec: this.customCodec || this.selectedCodec || 'aac',
|
||||
bitrate: this.customBitrate || this.selectedBitrate || '128k',
|
||||
channels: this.customChannels || this.selectedChannels || 2
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
codec: this.selectedCodec || 'aac',
|
||||
bitrate: this.selectedBitrate || '128k',
|
||||
channels: this.selectedChannels || 2
|
||||
}
|
||||
}
|
||||
},
|
||||
setPreset() {
|
||||
@@ -162,7 +170,7 @@ export default {
|
||||
} else {
|
||||
// Find closest bitrate rounding up
|
||||
const bitratesToMatch = [32, 64, 128, 192]
|
||||
const closestBitrate = bitratesToMatch.find((bitrate) => bitrate >= this.currentBitrate)
|
||||
const closestBitrate = bitratesToMatch.find((bitrate) => bitrate >= this.currentBitrate) || 192
|
||||
this.selectedBitrate = closestBitrate + 'k'
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -248,4 +248,4 @@ export default {
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div>
|
||||
<ui-multi-select-query-input v-model="seriesItems" text-key="displayName" :label="$strings.LabelSeries" :disabled="disabled" readonly show-edit @edit="editSeriesItem" @add="addNewSeries" />
|
||||
|
||||
<modals-edit-series-input-inner-modal v-model="showSeriesForm" :selected-series="selectedSeries" :existing-series-names="existingSeriesNames" @submit="submitSeriesForm" />
|
||||
<modals-edit-series-input-inner-modal v-model="showSeriesForm" :selected-series="selectedSeries" :existing-series-names="existingSeriesNames" :original-series-sequence="originalSeriesSequence" @submit="submitSeriesForm" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -18,6 +18,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
selectedSeries: null,
|
||||
originalSeriesSequence: null,
|
||||
showSeriesForm: false
|
||||
}
|
||||
},
|
||||
@@ -59,6 +60,7 @@ export default {
|
||||
..._series
|
||||
}
|
||||
|
||||
this.originalSeriesSequence = _series.sequence
|
||||
this.showSeriesForm = true
|
||||
},
|
||||
addNewSeries() {
|
||||
@@ -68,6 +70,7 @@ export default {
|
||||
sequence: ''
|
||||
}
|
||||
|
||||
this.originalSeriesSequence = null
|
||||
this.showSeriesForm = true
|
||||
},
|
||||
submitSeriesForm() {
|
||||
@@ -106,4 +109,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import LazyBookCard from '@/components/cards/LazyBookCard'
|
||||
import LazySeriesCard from '@/components/cards/LazySeriesCard'
|
||||
import LazyCollectionCard from '@/components/cards/LazyCollectionCard'
|
||||
import LazyPlaylistCard from '@/components/cards/LazyPlaylistCard'
|
||||
import LazyAlbumCard from '@/components/cards/LazyAlbumCard'
|
||||
import AuthorCard from '@/components/cards/AuthorCard'
|
||||
|
||||
export default {
|
||||
@@ -20,7 +19,6 @@ export default {
|
||||
if (this.entityName === 'series') return Vue.extend(LazySeriesCard)
|
||||
if (this.entityName === 'collections') return Vue.extend(LazyCollectionCard)
|
||||
if (this.entityName === 'playlists') return Vue.extend(LazyPlaylistCard)
|
||||
if (this.entityName === 'albums') return Vue.extend(LazyAlbumCard)
|
||||
if (this.entityName === 'authors') return Vue.extend(AuthorCard)
|
||||
return Vue.extend(LazyBookCard)
|
||||
},
|
||||
@@ -28,7 +26,6 @@ export default {
|
||||
if (this.entityName === 'series') return 'cards-lazy-series-card'
|
||||
if (this.entityName === 'collections') return 'cards-lazy-collection-card'
|
||||
if (this.entityName === 'playlists') return 'cards-lazy-playlist-card'
|
||||
if (this.entityName === 'albums') return 'cards-lazy-album-card'
|
||||
if (this.entityName === 'authors') return 'cards-author-card'
|
||||
return 'cards-lazy-book-card'
|
||||
},
|
||||
@@ -121,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.21.0",
|
||||
"version": "2.31.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.21.0",
|
||||
"version": "2.31.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.21.0",
|
||||
"version": "2.31.0",
|
||||
"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
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div id="page-wrapper" class="bg-bg page overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<div class="flex items-center py-4 px-2 md:px-0 max-w-7xl mx-auto">
|
||||
<div class="flex items-center py-4 px-4 max-w-7xl mx-auto">
|
||||
<nuxt-link :to="`/item/${libraryItem.id}`" class="hover:underline">
|
||||
<h1 class="text-lg lg:text-xl">{{ title }}</h1>
|
||||
</nuxt-link>
|
||||
@@ -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 justify-center py-4 px-2">
|
||||
<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" />
|
||||
<ui-btn v-if="chapters.length" color="bg-primary" small class="mx-1" @click.stop="removeAllChaptersClick">{{ $strings.ButtonRemoveAll }}</ui-btn>
|
||||
<ui-btn v-if="newChapters.length > 1" :color="showShiftTimes ? 'bg' : 'primary'" class="mx-1" small @click="showShiftTimes = !showShiftTimes">{{ $strings.ButtonShiftTimes }}</ui-btn>
|
||||
<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" />
|
||||
</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" 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">
|
||||
@@ -144,26 +191,31 @@
|
||||
<div v-if="!chapterData" class="flex flex-col items-center justify-center p-20">
|
||||
<div class="relative">
|
||||
<div class="flex items-end space-x-2">
|
||||
<ui-text-input-with-label v-model.trim="asinInput" label="ASIN" />
|
||||
<ui-dropdown v-model="regionInput" :label="$strings.LabelRegion" small :items="audibleRegions" class="w-32" />
|
||||
<ui-btn small color="bg-primary" @click="findChapters">{{ $strings.ButtonSearch }}</ui-btn>
|
||||
<ui-text-input-with-label v-model.trim="asinInput" label="ASIN" class="flex-grow" />
|
||||
<ui-dropdown v-model="regionInput" :label="$strings.LabelRegion" small :items="audibleRegions" class="w-20 max-w-20" />
|
||||
<ui-btn color="bg-primary" @click="findChapters">{{ $strings.ButtonSearch }}</ui-btn>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<ui-checkbox v-model="removeBranding" :label="$strings.LabelRemoveAudibleBranding" small checkbox-bg="bg" label-class="pl-2 text-base text-sm" @click="toggleRemoveBranding" />
|
||||
</div>
|
||||
|
||||
<div class="absolute left-0 mt-1.5 text-error text-s h-5">
|
||||
<p v-if="asinError">{{ asinError }}</p>
|
||||
<p v-if="asinError">{{ $strings.MessageAsinCheck }}</p>
|
||||
</div>
|
||||
|
||||
<div class="invisible h-5 mt-1 text-xs"></div>
|
||||
<div class="invisible mt-1 text-xs"></div>
|
||||
</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 />
|
||||
@@ -197,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>
|
||||
|
||||
@@ -261,9 +345,20 @@ export default {
|
||||
showFindChaptersModal: false,
|
||||
chapterData: null,
|
||||
asinError: null,
|
||||
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: {
|
||||
@@ -302,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
|
||||
@@ -319,9 +423,12 @@ export default {
|
||||
currentStartTime += track.duration
|
||||
}
|
||||
this.newChapters = chapters
|
||||
|
||||
this.lockedChapters = new Set()
|
||||
this.checkChapters()
|
||||
},
|
||||
toggleRemoveBranding() {
|
||||
this.removeBranding = !this.removeBranding
|
||||
},
|
||||
shiftChapterTimes() {
|
||||
if (!this.shiftAmount || isNaN(this.shiftAmount) || this.newChapters.length <= 1) {
|
||||
return
|
||||
@@ -329,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('Invalid shift amount. Last chapter start time would extend beyond the duration of this audiobook.')
|
||||
return
|
||||
}
|
||||
// Check if any unlocked chapters would be affected negatively
|
||||
const unlockedChapters = this.newChapters.filter((chap) => !this.lockedChapters.has(chap.id))
|
||||
|
||||
if (this.newChapters[0].end + amount <= 0) {
|
||||
this.$toast.error('Invalid shift amount. First chapter would have zero or negative length.')
|
||||
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)
|
||||
@@ -349,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)
|
||||
},
|
||||
@@ -363,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()
|
||||
},
|
||||
@@ -403,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) {
|
||||
@@ -417,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
|
||||
|
||||
@@ -446,6 +648,7 @@ export default {
|
||||
console.log('Audio playing')
|
||||
this.isLoadingChapter = false
|
||||
this.isPlayingChapter = true
|
||||
this.startElapsedTimeTracking()
|
||||
})
|
||||
audioEl.addEventListener('ended', () => {
|
||||
console.log('Audio ended')
|
||||
@@ -468,6 +671,10 @@ export default {
|
||||
this.selectedChapter = null
|
||||
this.isPlayingChapter = false
|
||||
this.isLoadingChapter = false
|
||||
this.stopElapsedTimeTracking()
|
||||
},
|
||||
resetChapterLookupData() {
|
||||
this.chapterData = null
|
||||
},
|
||||
saveChapters() {
|
||||
this.checkChapters()
|
||||
@@ -501,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)
|
||||
}
|
||||
@@ -518,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
|
||||
}
|
||||
})
|
||||
@@ -530,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 {
|
||||
@@ -540,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
|
||||
|
||||
@@ -567,8 +785,8 @@ export default {
|
||||
if (data.error) {
|
||||
this.asinError = this.$getString(data.stringKey)
|
||||
} else {
|
||||
console.log('Chapter data', data)
|
||||
this.chapterData = data
|
||||
console.log('Chapter data', { ...data })
|
||||
this.chapterData = this.removeBranding ? this.removeBrandingFromData(data) : data
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -578,6 +796,42 @@ export default {
|
||||
this.showFindChaptersModal = false
|
||||
})
|
||||
},
|
||||
removeBrandingFromData(data) {
|
||||
if (!data) return data
|
||||
try {
|
||||
const introDuration = data.brandIntroDurationMs
|
||||
const outroDuration = data.brandOutroDurationMs
|
||||
|
||||
for (let i = 0; i < data.chapters.length; i++) {
|
||||
const chapter = data.chapters[i]
|
||||
if (chapter.startOffsetMs < introDuration) {
|
||||
// This should never happen, as the intro is not longer than the first chapter
|
||||
// If this happens set to the next second
|
||||
// Will be 0 for the first chapter anayways
|
||||
chapter.startOffsetMs = i * 1000
|
||||
chapter.startOffsetSec = i
|
||||
} else {
|
||||
chapter.startOffsetMs -= introDuration
|
||||
chapter.startOffsetSec = Math.floor(chapter.startOffsetMs / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
const lastChapter = data.chapters[data.chapters.length - 1]
|
||||
// If there is an outro that's in the outro duration, remove it
|
||||
if (lastChapter && lastChapter.lengthMs <= outroDuration) {
|
||||
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
|
||||
}
|
||||
},
|
||||
resetChapters() {
|
||||
const payload = {
|
||||
message: this.$strings.MessageResetChaptersConfirm,
|
||||
@@ -602,6 +856,7 @@ export default {
|
||||
}
|
||||
]
|
||||
}
|
||||
this.lockedChapters = new Set()
|
||||
this.checkChapters()
|
||||
},
|
||||
removeAllChaptersClick() {
|
||||
@@ -626,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)
|
||||
}
|
||||
@@ -643,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) {
|
||||
@@ -650,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() {
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
<div id="page-wrapper" class="bg-bg page p-8 overflow-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<div class="flex items-center justify-center mb-6">
|
||||
<div class="w-full max-w-2xl">
|
||||
<p class="text-2xl mb-2">{{ $strings.HeaderAudiobookTools }}</p>
|
||||
<div class="flex items-center mb-4">
|
||||
<nuxt-link :to="`/item/${libraryItem.id}`" class="hover:underline">
|
||||
<h1 class="text-lg lg:text-xl">{{ mediaMetadata.title }}</h1>
|
||||
</nuxt-link>
|
||||
<button class="w-7 h-7 flex items-center justify-center mx-4 hover:scale-110 duration-100 transform text-gray-200 hover:text-white" @click="editItem">
|
||||
<span class="material-symbols text-base">edit</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full max-w-2xl">
|
||||
<div class="flex justify-end">
|
||||
@@ -13,7 +20,7 @@
|
||||
|
||||
<div class="flex justify-center mb-2">
|
||||
<div class="w-full max-w-2xl">
|
||||
<p class="text-xl">{{ $strings.HeaderMetadataToEmbed }}</p>
|
||||
<p class="text-lg">{{ $strings.HeaderMetadataToEmbed }}</p>
|
||||
</div>
|
||||
<div class="w-full max-w-2xl"></div>
|
||||
</div>
|
||||
@@ -21,14 +28,14 @@
|
||||
<div class="flex justify-center flex-wrap lg:flex-nowrap gap-4">
|
||||
<div class="w-full max-w-2xl border border-white/10 bg-bg">
|
||||
<div class="flex py-2 px-4">
|
||||
<div class="w-1/3 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelMetaTag }}</div>
|
||||
<div class="w-2/3 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelValue }}</div>
|
||||
<div class="w-28 min-w-28 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelMetaTag }}</div>
|
||||
<div class="grow text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelValue }}</div>
|
||||
</div>
|
||||
<div class="w-full max-h-72 overflow-auto">
|
||||
<template v-for="(value, key, index) in metadataObject">
|
||||
<div :key="key" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary/25' : ''">
|
||||
<div class="w-1/3 font-semibold">{{ key }}</div>
|
||||
<div class="w-2/3">
|
||||
<div class="w-28 min-w-28 font-semibold">{{ key }}</div>
|
||||
<div class="grow">
|
||||
{{ value }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -38,18 +45,18 @@
|
||||
<div class="w-full max-w-2xl border border-white/10 bg-bg">
|
||||
<div class="flex py-2 px-4 bg-primary/25">
|
||||
<div class="grow text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelChapterTitle }}</div>
|
||||
<div class="w-24 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelStart }}</div>
|
||||
<div class="w-24 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelEnd }}</div>
|
||||
<div class="w-16 min-w-16 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelStart }}</div>
|
||||
<div class="w-16 min-w-16 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelEnd }}</div>
|
||||
</div>
|
||||
<div class="w-full max-h-72 overflow-auto">
|
||||
<p v-if="!metadataChapters.length" class="py-5 text-center text-gray-200">{{ $strings.MessageNoChapters }}</p>
|
||||
<template v-for="(chapter, index) in metadataChapters">
|
||||
<div :key="index" class="flex py-1 px-4 text-sm" :class="index % 2 === 1 ? 'bg-primary/25' : ''">
|
||||
<div class="grow font-semibold">{{ chapter.title }}</div>
|
||||
<div class="w-24">
|
||||
<div class="w-16 min-w-16">
|
||||
{{ $secondsToTimestamp(chapter.start) }}
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<div class="w-16 min-w-16">
|
||||
{{ $secondsToTimestamp(chapter.end) }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -266,9 +273,6 @@ export default {
|
||||
audioFiles() {
|
||||
return (this.media.audioFiles || []).filter((af) => !af.exclude)
|
||||
},
|
||||
isSingleM4b() {
|
||||
return this.audioFiles.length === 1 && this.audioFiles[0].metadata.ext.toLowerCase() === '.m4b'
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
@@ -276,14 +280,10 @@ export default {
|
||||
return this.media.chapters || []
|
||||
},
|
||||
availableTools() {
|
||||
if (this.isSingleM4b) {
|
||||
return [{ value: 'embed', text: this.$strings.LabelToolsEmbedMetadata }]
|
||||
} else {
|
||||
return [
|
||||
{ value: 'embed', text: this.$strings.LabelToolsEmbedMetadata },
|
||||
{ value: 'm4b', text: this.$strings.LabelToolsM4bEncoder }
|
||||
]
|
||||
}
|
||||
return [
|
||||
{ value: 'embed', text: this.$strings.LabelToolsEmbedMetadata },
|
||||
{ value: 'm4b', text: this.$strings.LabelToolsM4bEncoder }
|
||||
]
|
||||
},
|
||||
taskFailed() {
|
||||
return this.isTaskFinished && this.task.isFailed
|
||||
@@ -356,6 +356,8 @@ export default {
|
||||
|
||||
const encodeOptions = this.$refs.encoderOptionsCard.getEncodingOptions()
|
||||
|
||||
this.encodingOptions = encodeOptions
|
||||
|
||||
const queryParams = new URLSearchParams(encodeOptions)
|
||||
|
||||
this.processing = true
|
||||
@@ -431,10 +433,24 @@ export default {
|
||||
},
|
||||
taskUpdated(task) {
|
||||
this.processing = !task.isFinished
|
||||
},
|
||||
editItem() {
|
||||
this.$store.commit('showEditModal', this.libraryItem)
|
||||
},
|
||||
libraryItemUpdated(libraryItem) {
|
||||
if (libraryItem.id === this.libraryItem.id) {
|
||||
this.libraryItem = libraryItem
|
||||
this.fetchMetadataEmbedObject()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
|
||||
this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
@@ -122,7 +122,8 @@
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
<div class="w-full flex items-center justify-end p-4">
|
||||
<div class="w-full flex items-center justify-between p-4">
|
||||
<p v-if="enableOpenIDAuth" class="text-sm text-warning">{{ $strings.MessageAuthenticationOIDCChangesRestart }}</p>
|
||||
<ui-btn color="bg-success" :padding-x="8" small class="text-base" :loading="savingSettings" @click="saveSettings">{{ $strings.ButtonSave }}</ui-btn>
|
||||
</div>
|
||||
</app-settings-content>
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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,82 @@
|
||||
</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" 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">{{ $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" />
|
||||
@@ -250,10 +252,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
|
||||
|
||||
@@ -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,41 @@
|
||||
<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>
|
||||
</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>
|
||||
<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" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||
</td>
|
||||
<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 +100,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: {
|
||||
|
||||
@@ -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']
|
||||
@@ -819,6 +819,17 @@ export default {
|
||||
-webkit-line-clamp: 4;
|
||||
max-height: calc(6 * 1lh);
|
||||
}
|
||||
|
||||
/* Safari-specific fix for the description clamping */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
#item-description {
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
max-height: calc(6 * 1lh);
|
||||
}
|
||||
}
|
||||
|
||||
#item-description.show-full {
|
||||
-webkit-line-clamp: unset;
|
||||
max-height: 999rem;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -22,6 +22,7 @@ export default {
|
||||
})
|
||||
results = {
|
||||
podcasts: results?.podcast || [],
|
||||
episodes: results?.episodes || [],
|
||||
books: results?.book || [],
|
||||
authors: results?.authors || [],
|
||||
series: results?.series || [],
|
||||
@@ -61,6 +62,7 @@ export default {
|
||||
})
|
||||
this.results = {
|
||||
podcasts: results?.podcast || [],
|
||||
episodes: results?.episodes || [],
|
||||
books: results?.book || [],
|
||||
authors: results?.authors || [],
|
||||
series: results?.series || [],
|
||||
|
||||
+40
-8
@@ -40,6 +40,15 @@
|
||||
|
||||
<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" />
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -359,15 +380,14 @@ export default {
|
||||
// Check if path already exists before starting upload
|
||||
// uploading fails if path already exists
|
||||
for (const item of items) {
|
||||
const filepath = Path.join(this.selectedFolder.fullPath, item.directory)
|
||||
const exists = await this.$axios
|
||||
.$post(`/api/filesystem/pathexists`, { filepath, directory: item.directory, folderPath: this.selectedFolder.fullPath })
|
||||
.$post(`/api/filesystem/pathexists`, { directory: item.directory, folderPath: this.selectedFolder.fullPath })
|
||||
.then((data) => {
|
||||
if (data.exists) {
|
||||
if (data.libraryItemTitle) {
|
||||
this.$toast.error(this.$getString('ToastUploaderItemExistsInSubdirectoryError', [data.libraryItemTitle]))
|
||||
} else {
|
||||
this.$toast.error(this.$getString('ToastUploaderFilepathExistsError', [filepath]))
|
||||
this.$toast.error(this.$getString('ToastUploaderFilepathExistsError', [Path.join(this.selectedFolder.fullPath, item.directory)]))
|
||||
}
|
||||
}
|
||||
return data.exists
|
||||
@@ -395,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)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export default class AudioTrack {
|
||||
constructor(track, sessionId, userToken, routerBasePath) {
|
||||
constructor(track, sessionId, routerBasePath) {
|
||||
this.index = track.index || 0
|
||||
this.startOffset = track.startOffset || 0 // Total time of all previous tracks
|
||||
this.duration = track.duration || 0
|
||||
@@ -11,7 +11,7 @@ export default class AudioTrack {
|
||||
this.sessionId = sessionId
|
||||
this.routerBasePath = routerBasePath || ''
|
||||
if (this.contentUrl?.startsWith('/hls')) {
|
||||
this.sessionTrackUrl = `${this.contentUrl}?token=${userToken}`
|
||||
this.sessionTrackUrl = this.contentUrl
|
||||
} else {
|
||||
this.sessionTrackUrl = `/public/session/${sessionId}/track/${this.index}`
|
||||
}
|
||||
|
||||
@@ -37,9 +37,6 @@ export default class PlayerHandler {
|
||||
get isPlayingLocalItem() {
|
||||
return this.libraryItem && this.player instanceof LocalAudioPlayer
|
||||
}
|
||||
get userToken() {
|
||||
return this.ctx.$store.getters['user/getToken']
|
||||
}
|
||||
get playerPlaying() {
|
||||
return this.playerState === 'PLAYING'
|
||||
}
|
||||
@@ -226,7 +223,7 @@ export default class PlayerHandler {
|
||||
|
||||
console.log('[PlayerHandler] Preparing Session', session)
|
||||
|
||||
var audioTracks = session.audioTracks.map((at) => new AudioTrack(at, session.id, this.userToken, this.ctx.$config.routerBasePath))
|
||||
var audioTracks = session.audioTracks.map((at) => new AudioTrack(at, session.id, this.ctx.$config.routerBasePath))
|
||||
|
||||
this.ctx.playerLoading = true
|
||||
this.isHlsTranscode = true
|
||||
|
||||
+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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { supplant } from './utils'
|
||||
const defaultCode = 'en-us'
|
||||
|
||||
const languageCodeMap = {
|
||||
ar: { label: 'عربي', dateFnsLocale: 'ar' },
|
||||
bg: { label: 'Български', dateFnsLocale: 'bg' },
|
||||
bn: { label: 'বাংলা', dateFnsLocale: 'bn' },
|
||||
ca: { label: 'Català', dateFnsLocale: 'ca' },
|
||||
@@ -21,6 +22,7 @@ const languageCodeMap = {
|
||||
it: { label: 'Italiano', dateFnsLocale: 'it' },
|
||||
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' },
|
||||
@@ -28,6 +30,7 @@ const languageCodeMap = {
|
||||
ru: { label: 'Русский', dateFnsLocale: 'ru' },
|
||||
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' },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
-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) => {
|
||||
@@ -58,6 +59,9 @@ export const getters = {
|
||||
getUserCanAccessAllLibraries: (state) => {
|
||||
return !!state.user?.permissions?.accessAllLibraries
|
||||
},
|
||||
getUserCanAccessExplicitContent: (state) => {
|
||||
return !!state.user?.permissions?.accessExplicitContent
|
||||
},
|
||||
getLibrariesAccessible: (state, getters) => {
|
||||
if (!state.user) return []
|
||||
if (getters.getUserCanAccessAllLibraries) return []
|
||||
@@ -88,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']
|
||||
@@ -142,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
|
||||
|
||||
+909
-7
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user