mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-12-31 11:38:47 -05:00
Compare commits
752 Commits
fix_exclud
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
122fc34a75 | ||
|
|
e5c0a9d22c | ||
|
|
3bf136a20b | ||
|
|
b387d9484a | ||
|
|
e8668d9f22 | ||
|
|
f3e90bd420 | ||
|
|
4bf15bbffd | ||
|
|
04eb3bc437 | ||
|
|
81e96df9c5 | ||
|
|
44aff23e1b | ||
|
|
cc48d9f26d | ||
|
|
ac08e897ee | ||
|
|
3c2eec8279 | ||
|
|
7b37c98e88 | ||
|
|
088353ae26 | ||
|
|
e003544edd | ||
|
|
076ece6fe7 | ||
|
|
14f72ab7d4 | ||
|
|
ebcb122eb8 | ||
|
|
626596b192 | ||
|
|
10a4777ddf | ||
|
|
0ecbb1c3f4 | ||
|
|
dc2398a072 | ||
|
|
c1e21d31ee | ||
|
|
70e6efc3d0 | ||
|
|
092c504eb1 | ||
|
|
f7d7c9a4f5 | ||
|
|
8bdcabf973 | ||
|
|
646c861bcc | ||
|
|
ee60169995 | ||
|
|
afb4108c30 | ||
|
|
2e2d857ce0 | ||
|
|
ed5766b4ab | ||
|
|
a33e87db99 | ||
|
|
5de942aefb | ||
|
|
bcfe1e9647 | ||
|
|
503f4611b2 | ||
|
|
648983708e | ||
|
|
991d25f628 | ||
|
|
d2a7c3c381 | ||
|
|
219a9fc6d5 | ||
|
|
ba2259d174 | ||
|
|
d7bfccdc4a | ||
|
|
5f1edcb609 | ||
|
|
329e3c7179 | ||
|
|
919ea32416 | ||
|
|
3b6419bc1b | ||
|
|
d4fdb47c7f | ||
|
|
cee9b9d8e3 | ||
|
|
9441346b0a | ||
|
|
6b8464c270 | ||
|
|
d12f727603 | ||
|
|
1552c250df | ||
|
|
623c2fba12 | ||
|
|
be27908d44 | ||
|
|
7a39d581a1 | ||
|
|
53a416fd28 | ||
|
|
7393c03218 | ||
|
|
594589da3d | ||
|
|
44d7deae99 | ||
|
|
ff9e87c4d5 | ||
|
|
c2fd87d55c | ||
|
|
27843c3f9b | ||
|
|
0ec2ced011 | ||
|
|
552ed43243 | ||
|
|
0606738b38 | ||
|
|
a5d2c1bd64 | ||
|
|
d8e272e091 | ||
|
|
3e9ca51088 | ||
|
|
8758c62ae2 | ||
|
|
db9019a94f | ||
|
|
39b8b9df4f | ||
|
|
a36f097095 | ||
|
|
ae0ccb1b47 | ||
|
|
f178841e57 | ||
|
|
568b154e8a | ||
|
|
8c5678b573 | ||
|
|
e51c7b2be1 | ||
|
|
d460757df4 | ||
|
|
cd295c03ca | ||
|
|
38dd1beff7 | ||
|
|
61b72aff9d | ||
|
|
9eda4e36fa | ||
|
|
a05cb170a2 | ||
|
|
c75d976320 | ||
|
|
ab9b798bfa | ||
|
|
40783c8644 | ||
|
|
a0137fcb42 | ||
|
|
9fdda6be62 | ||
|
|
b0c073dd7e | ||
|
|
b83e2836f6 | ||
|
|
f91be18527 | ||
|
|
c1f4e4120e | ||
|
|
b42c7421b0 | ||
|
|
82f512d405 | ||
|
|
01a833ea59 | ||
|
|
f1c39e8587 | ||
|
|
5e68936c20 | ||
|
|
a4c9b062c1 | ||
|
|
763d8810e3 | ||
|
|
3316505d1c | ||
|
|
2cf6e8a5fe | ||
|
|
961d066bdd | ||
|
|
372c9a5322 | ||
|
|
f77de1743e | ||
|
|
a5750deaaf | ||
|
|
0c7b738b7c | ||
|
|
c3c9e7731d | ||
|
|
d3b5612fc0 | ||
|
|
96ef0129ed | ||
|
|
85546b7dd7 | ||
|
|
d59714d804 | ||
|
|
96693659bf | ||
|
|
ee2d8d1f71 | ||
|
|
f03b0915eb | ||
|
|
a92ba564bd | ||
|
|
e684a8dc43 | ||
|
|
6db6b862e6 | ||
|
|
57c7b123f0 | ||
|
|
fd593caafc | ||
|
|
d0a3f74710 | ||
|
|
b1921e7034 | ||
|
|
538a5065a4 | ||
|
|
166e0442a0 | ||
|
|
816a47a4ba | ||
|
|
141211590f | ||
|
|
b01e7570d3 | ||
|
|
0a8662d198 | ||
|
|
0a4de61eff | ||
|
|
0a82d6a41b | ||
|
|
3f6162f53c | ||
|
|
888190a6be | ||
|
|
ce4ff4f894 | ||
|
|
1da3ab7fdc | ||
|
|
4f30cbf2f6 | ||
|
|
a87ea32715 | ||
|
|
feed827223 | ||
|
|
797dba2448 | ||
|
|
f0acbb2e81 | ||
|
|
fc06aa2c78 | ||
|
|
4224f44259 | ||
|
|
2592467d09 | ||
|
|
37beb7b37c | ||
|
|
cafd92e206 | ||
|
|
3e876e3383 | ||
|
|
29752798f3 | ||
|
|
8c86ca4ea5 | ||
|
|
00c62fa494 | ||
|
|
6c7f3c7e77 | ||
|
|
aec8acbdd7 | ||
|
|
6e19ad7777 | ||
|
|
3aa95fec11 | ||
|
|
37dd46d31f | ||
|
|
54a996634e | ||
|
|
54a5e368c2 | ||
|
|
2d313851d2 | ||
|
|
eb00b19457 | ||
|
|
bbae9acc2d | ||
|
|
a4e8f01f0e | ||
|
|
6bdf402da8 | ||
|
|
80b0e3546e | ||
|
|
161f3cb177 | ||
|
|
4a4d4a8f17 | ||
|
|
b21046027c | ||
|
|
3a163e1746 | ||
|
|
3c4e80f1c1 | ||
|
|
2f3036faba | ||
|
|
3934461c46 | ||
|
|
123351e08a | ||
|
|
1280ddfe74 | ||
|
|
7e89b97a6d | ||
|
|
20de2ea388 | ||
|
|
dbb5ee79ac | ||
|
|
c6dabd2620 | ||
|
|
26f949b9ba | ||
|
|
7630dbdcb7 | ||
|
|
a164c17d38 | ||
|
|
03da194953 | ||
|
|
9ce6de3100 | ||
|
|
e040396b20 | ||
|
|
bcbec67fec | ||
|
|
1543021685 | ||
|
|
577e6aaec9 | ||
|
|
77579acfd4 | ||
|
|
9ca98ca750 | ||
|
|
feb225d3a6 | ||
|
|
e501aa4f1e | ||
|
|
104f6e6c58 | ||
|
|
552d8ae3b8 | ||
|
|
a41e9bae5d | ||
|
|
a456865ec0 | ||
|
|
85d5531bc1 | ||
|
|
4b840f9c97 | ||
|
|
b9510a69fe | ||
|
|
d737a66af2 | ||
|
|
576d18d8d6 | ||
|
|
d238b02bd2 | ||
|
|
c6cb13ed39 | ||
|
|
44c5dce8aa | ||
|
|
b726bee4e5 | ||
|
|
b07e449043 | ||
|
|
9273e61f1e | ||
|
|
1b4a7acf13 | ||
|
|
68c1395bdf | ||
|
|
a007a9ec98 | ||
|
|
8b33b5e383 | ||
|
|
c81b762d52 | ||
|
|
c53a5c5a0b | ||
|
|
83af75a582 | ||
|
|
60389a3bf3 | ||
|
|
20cceb3a8f | ||
|
|
7562fb2c21 | ||
|
|
c7647aafd7 | ||
|
|
4a73247e5c | ||
|
|
326086c197 | ||
|
|
5ff5245476 | ||
|
|
856cf180a5 | ||
|
|
fbe9971a8b | ||
|
|
6ea70608a1 | ||
|
|
ba7160c305 | ||
|
|
7d048b7a50 | ||
|
|
afab429c75 | ||
|
|
50e2fe7fd2 | ||
|
|
c7c21cc137 | ||
|
|
7e4c7a7e3b | ||
|
|
40babc9650 | ||
|
|
7a94f014ea | ||
|
|
32adb1bafd | ||
|
|
f9a6239049 | ||
|
|
8dee1ec942 | ||
|
|
58e43cc6a7 | ||
|
|
b8999fbc37 | ||
|
|
0dda4b6b27 | ||
|
|
817f2f6915 | ||
|
|
77fc6bba1a | ||
|
|
c66d652a53 | ||
|
|
86bddba5c3 | ||
|
|
7779fd2972 | ||
|
|
05a4577792 | ||
|
|
56dc042282 | ||
|
|
95973243a6 | ||
|
|
18ad23d016 | ||
|
|
e258f122f1 | ||
|
|
18200a8f01 | ||
|
|
9c47f404c9 | ||
|
|
2f6de71a3a | ||
|
|
deb121c523 | ||
|
|
320e4dfb47 | ||
|
|
6194c48549 | ||
|
|
6aa9ecaaba | ||
|
|
b3d020b89f | ||
|
|
e196a6e5ca | ||
|
|
73cf22b499 | ||
|
|
ac7464ce7e | ||
|
|
84e742f2a5 | ||
|
|
a1e882cbf1 | ||
|
|
09121acbd5 | ||
|
|
5b9df84ba3 | ||
|
|
266db491aa | ||
|
|
c7a317a87b | ||
|
|
b027f3bda1 | ||
|
|
cea991b82f | ||
|
|
7e2b51e6d2 | ||
|
|
8f310b6bf0 | ||
|
|
b2a5fb46f1 | ||
|
|
6d7639853b | ||
|
|
3a16acbba4 | ||
|
|
027e1efaca | ||
|
|
d1fabba86b | ||
|
|
b290a4ada3 | ||
|
|
bb477c617e | ||
|
|
9238c38842 | ||
|
|
d268516fcb | ||
|
|
d353cff1ae | ||
|
|
604f17f60b | ||
|
|
3911a7273b | ||
|
|
138bb563b8 | ||
|
|
3801ef062a | ||
|
|
e4b9ac5446 | ||
|
|
9987d219f8 | ||
|
|
dc7045c562 | ||
|
|
2cc6e56bd1 | ||
|
|
a89a24e48e | ||
|
|
a968aca304 | ||
|
|
8d1f460640 | ||
|
|
553ffd1934 | ||
|
|
fd4932cdbb | ||
|
|
dcaca43817 | ||
|
|
0eed4e82f9 | ||
|
|
2ed2328401 | ||
|
|
8b260c8bc6 | ||
|
|
7dcb9b98a0 | ||
|
|
311ac7104e | ||
|
|
2c45b28d48 | ||
|
|
b53613f82c | ||
|
|
751371abb8 | ||
|
|
6365c02875 | ||
|
|
fb3834156b | ||
|
|
c03f3f722d | ||
|
|
a06f48ca29 | ||
|
|
9d79552dda | ||
|
|
ed98614b6f | ||
|
|
09dd2cc79c | ||
|
|
e87237048a | ||
|
|
d71968fd80 | ||
|
|
f83c605ae1 | ||
|
|
4325f470dd | ||
|
|
800ecf8e82 | ||
|
|
5cb143d50b | ||
|
|
798c73c66c | ||
|
|
0fa7c46274 | ||
|
|
c2d420ec70 | ||
|
|
152daf7bf3 | ||
|
|
8d99249e50 | ||
|
|
c6724ba353 | ||
|
|
a519d44666 | ||
|
|
7e8bf977cc | ||
|
|
4018be6330 | ||
|
|
99a3867ce9 | ||
|
|
2116f60133 | ||
|
|
794f0ef42a | ||
|
|
3e423839a1 | ||
|
|
2773c8c4a9 | ||
|
|
e510174f12 | ||
|
|
08c9e8d47d | ||
|
|
1908ec3df5 | ||
|
|
df3878d4ca | ||
|
|
1097de6f1f | ||
|
|
e408070b19 | ||
|
|
af67c2e86f | ||
|
|
6a52d2a968 | ||
|
|
3337b3af18 | ||
|
|
835d2c7f36 | ||
|
|
03f91099e0 | ||
|
|
c04afd0787 | ||
|
|
b03bd79f5d | ||
|
|
5ef632a7eb | ||
|
|
79b4042e8e | ||
|
|
8f718ef91c | ||
|
|
4053b20623 | ||
|
|
c4d654635f | ||
|
|
ef5d0ffa48 | ||
|
|
6a826cdb36 | ||
|
|
1d837f5f21 | ||
|
|
80873b379c | ||
|
|
82a8f8f126 | ||
|
|
4725a466da | ||
|
|
031edc870c | ||
|
|
625e2445b5 | ||
|
|
1640af2f1c | ||
|
|
c76f76cc27 | ||
|
|
74af212293 | ||
|
|
e04efb9c6a | ||
|
|
ee17e7a555 | ||
|
|
694a852c07 | ||
|
|
18068bb261 | ||
|
|
71257f6c6c | ||
|
|
4d70929d2e | ||
|
|
578e9559e4 | ||
|
|
894ea0b80a | ||
|
|
e54571f011 | ||
|
|
77d7a50b99 | ||
|
|
32da0f1224 | ||
|
|
2054accdc9 | ||
|
|
7d8b857c77 | ||
|
|
0107cb4782 | ||
|
|
f273eee807 | ||
|
|
4af21b079a | ||
|
|
c9eaf2db2d | ||
|
|
cae1560344 | ||
|
|
a5fb0d9cdb | ||
|
|
53c80d9798 | ||
|
|
832165716b | ||
|
|
d9f2d8bf1d | ||
|
|
a7a3a56509 | ||
|
|
4082fadf90 | ||
|
|
93160b83bf | ||
|
|
472240f994 | ||
|
|
c3f0fb8e5e | ||
|
|
b156ebeb9f | ||
|
|
e4c775c847 | ||
|
|
45e8e72759 | ||
|
|
0ae7340889 | ||
|
|
8c38987d92 | ||
|
|
878f0787ba | ||
|
|
880d85eaef | ||
|
|
f7aaebc1de | ||
|
|
d96ebbe82d | ||
|
|
70d67156f0 | ||
|
|
f293b317be | ||
|
|
1f23794f88 | ||
|
|
e6bfd118f6 | ||
|
|
1166400ab1 | ||
|
|
55f0ac871b | ||
|
|
3584f6e24f | ||
|
|
23bf2594c8 | ||
|
|
8fb460ce05 | ||
|
|
8c4bbfd6a2 | ||
|
|
742961e0b8 | ||
|
|
5b6807892f | ||
|
|
b911a25c57 | ||
|
|
53110674e4 | ||
|
|
f963cd4753 | ||
|
|
0dccc3bcae | ||
|
|
5b4fd5b254 | ||
|
|
bdb9d3caeb | ||
|
|
9aca824b59 | ||
|
|
8e891805eb | ||
|
|
2760517445 | ||
|
|
889ee33320 | ||
|
|
4f65801713 | ||
|
|
3e75acd4ef | ||
|
|
3e8fe2ef60 | ||
|
|
0bc441de20 | ||
|
|
a8c2f0d4c8 | ||
|
|
b59da8bd0c | ||
|
|
77cb4f75c6 | ||
|
|
9cf1711fae | ||
|
|
f472116dc3 | ||
|
|
c7eb9d7799 | ||
|
|
c66380eaeb | ||
|
|
1bebb22705 | ||
|
|
4e96649fe3 | ||
|
|
a21cec806e | ||
|
|
8a3b8d2249 | ||
|
|
581277914c | ||
|
|
e678fe6e2f | ||
|
|
3845940245 | ||
|
|
6c63e2131c | ||
|
|
e25e2b238f | ||
|
|
99110f587a | ||
|
|
b553e959e2 | ||
|
|
f7b94a4b6d | ||
|
|
e9a705587a | ||
|
|
264ae928a9 | ||
|
|
f5248a9f00 | ||
|
|
3473ff594a | ||
|
|
20bb6e13b5 | ||
|
|
a05d32b1d7 | ||
|
|
c6b3521cb6 | ||
|
|
2444504c6a | ||
|
|
d38532c07a | ||
|
|
4f7831611f | ||
|
|
d09db19cd5 | ||
|
|
030e43f382 | ||
|
|
f081a7fdc1 | ||
|
|
f0d5f46199 | ||
|
|
0b8f6db45e | ||
|
|
806c0a2991 | ||
|
|
7d6d3e6687 | ||
|
|
ad07ed7e25 | ||
|
|
d3402e30c2 | ||
|
|
25fe4dee3a | ||
|
|
3c21c82ce1 | ||
|
|
3c8876a37d | ||
|
|
fba70c9831 | ||
|
|
27e40d16fd | ||
|
|
448cbf8530 | ||
|
|
f1153f9da5 | ||
|
|
d09a21d922 | ||
|
|
62afa3c3ee | ||
|
|
85446be0e5 | ||
|
|
018ca8e7ee | ||
|
|
f02453ac92 | ||
|
|
84b77f4c7f | ||
|
|
d41276ba8c | ||
|
|
576d7dc024 | ||
|
|
6d2b1df560 | ||
|
|
8255e4308c | ||
|
|
794adf0292 | ||
|
|
f2e0b9762c | ||
|
|
7d0def0edb | ||
|
|
0653572396 | ||
|
|
d9a3750667 | ||
|
|
9c0c7b6b08 | ||
|
|
df1391d93f | ||
|
|
bf6d81b333 | ||
|
|
8775e55762 | ||
|
|
d0d152c20d | ||
|
|
4ff7355262 | ||
|
|
6cc7a44a22 | ||
|
|
ad092ef8f8 | ||
|
|
4102ed8be4 | ||
|
|
691f291843 | ||
|
|
ac381854e5 | ||
|
|
9c8900560c | ||
|
|
d9cfcc86e7 | ||
|
|
ce803dd6de | ||
|
|
97afd22f81 | ||
|
|
e24eaab3f1 | ||
|
|
e201247d69 | ||
|
|
a24dae5262 | ||
|
|
e59babdf24 | ||
|
|
8dbe1e4e5d | ||
|
|
cdc37ddb0f | ||
|
|
f127a7beb5 | ||
|
|
df60aeb456 | ||
|
|
30c327d92a | ||
|
|
596bddf791 | ||
|
|
44ff90a6f2 | ||
|
|
293851d931 | ||
|
|
8b995a179d | ||
|
|
4d32a22de9 | ||
|
|
af1ff12dbb | ||
|
|
d96ed01ce4 | ||
|
|
7610e97f0f | ||
|
|
4f5123e842 | ||
|
|
d102065d02 | ||
|
|
34315d4c10 | ||
|
|
276a179446 | ||
|
|
4462d32e98 | ||
|
|
9722674072 | ||
|
|
35bb77c9c2 | ||
|
|
cf6f49ce75 | ||
|
|
d614373c64 | ||
|
|
b9969c78a6 | ||
|
|
fbf482d6b6 | ||
|
|
dd74d0a726 | ||
|
|
b13b80e011 | ||
|
|
e384863148 | ||
|
|
9c44fc0d01 | ||
|
|
d21fe49ce2 | ||
|
|
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 | ||
|
|
d9531166b6 | ||
|
|
336de49d8d | ||
|
|
3cc527484d | ||
|
|
45987ffd63 | ||
|
|
1a1ef9c378 | ||
|
|
342d100f3e | ||
|
|
e0b90c6813 | ||
|
|
2706a9c4aa | ||
|
|
2cc9d1b7f8 | ||
|
|
2b7268c952 | ||
|
|
e097fe1e88 | ||
|
|
6819c0b108 | ||
|
|
58cd751b43 | ||
|
|
9f834a5345 | ||
|
|
a1074e69ac | ||
|
|
65aec6a099 | ||
|
|
38957d4f32 | ||
|
|
a2dc76e190 | ||
|
|
dc3c978f8d | ||
|
|
13fac2d5bc | ||
|
|
fd0af6b2dd |
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -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}}'
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,3 +23,4 @@ sw.*
|
||||
.DS_STORE
|
||||
.idea/*
|
||||
tailwind.compiled.css
|
||||
tailwind.config.js
|
||||
|
||||
50
Dockerfile
50
Dockerfile
@@ -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"]
|
||||
|
||||
@@ -22,7 +22,7 @@ add_user() {
|
||||
declare -r descr="${4:-No description}"
|
||||
declare -r shell="${5:-/bin/false}"
|
||||
|
||||
if ! getent passwd | grep -q "^$user:"; then
|
||||
if ! getent passwd "$user" 2>&1 >/dev/null; then
|
||||
echo "Creating system user: $user in $group with $descr and shell $shell"
|
||||
useradd $uid_flags --gid $group --no-create-home --system --shell $shell -c "$descr" $user
|
||||
fi
|
||||
@@ -39,7 +39,7 @@ add_group() {
|
||||
declare -r gid_flags="--gid $gid"
|
||||
fi
|
||||
|
||||
if ! getent group | grep -q "^$group:" ; then
|
||||
if ! getent group "$group" 2>&1 >/dev/null; then
|
||||
echo "Creating system group: $group"
|
||||
groupadd $gid_flags --system $group
|
||||
fi
|
||||
|
||||
@@ -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>
|
||||
@@ -78,7 +78,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Error widget -->
|
||||
<ui-tooltip cy-id="ErrorTooltip" v-if="showError" :text="errorText" class="absolute bottom-4e left-0 z-10">
|
||||
<ui-tooltip cy-id="ErrorTooltip" v-if="showError" :text="errorText" plaintext class="absolute bottom-4e left-0 z-10">
|
||||
<div :style="{ height: 1.5 + 'em', width: 2.5 + 'em' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
|
||||
<span class="material-symbols text-red-100 pr-1e" :style="{ fontSize: 0.875 + 'em' }">priority_high</span>
|
||||
</div>
|
||||
@@ -101,7 +101,8 @@
|
||||
<!-- Podcast Episode # -->
|
||||
<div cy-id="podcastEpisodeNumber" v-if="recentEpisodeNumber !== null && !isHovering && !isSelectionMode && !processing" class="absolute rounded-lg bg-black/90 box-shadow-md z-10" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }">
|
||||
<p :style="{ fontSize: 0.8 + 'em' }">
|
||||
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
|
||||
Episode
|
||||
<span v-if="recentEpisodeNumber">#{{ recentEpisodeNumber }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -120,12 +121,12 @@
|
||||
<!-- Alternative bookshelf title/author/sort -->
|
||||
<div cy-id="detailBottom" :id="`description-area-${index}`" v-if="isAlternativeBookshelfView || isAuthorBookshelfView" dir="auto" class="relative mt-2e mb-2e left-0 z-50 w-full">
|
||||
<div :style="{ fontSize: 0.9 + 'em' }">
|
||||
<ui-tooltip v-if="displayTitle" :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
|
||||
<ui-tooltip v-if="displayTitle" :text="displayTitle" plaintext :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
|
||||
<p cy-id="title" ref="displayTitle" class="truncate">{{ displayTitle }}</p>
|
||||
<widgets-explicit-indicator cy-id="explicitIndicator" v-if="isExplicit" />
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<ui-tooltip v-if="showSubtitles" :text="displaySubtitle" :disabled="!displaySubtitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
|
||||
<ui-tooltip v-if="showSubtitles" :text="displaySubtitle" plaintext :disabled="!displaySubtitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
|
||||
<p cy-id="subtitle" class="truncate" ref="displaySubtitle" :style="{ fontSize: 0.6 + 'em' }">{{ displaySubtitle }}</p>
|
||||
</ui-tooltip>
|
||||
<p cy-id="line2" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displayLineTwo || ' ' }}</p>
|
||||
@@ -198,7 +199,10 @@ export default {
|
||||
return this.store.getters['user/getSizeMultiplier']
|
||||
},
|
||||
dateFormat() {
|
||||
return this.store.state.serverSettings.dateFormat
|
||||
return this.store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
timeFormat() {
|
||||
return this.store.getters['getServerSetting']('timeFormat')
|
||||
},
|
||||
_libraryItem() {
|
||||
return this.libraryItem || {}
|
||||
@@ -345,6 +349,18 @@ export default {
|
||||
if (this.mediaMetadata.publishedYear) return this.$getString('LabelPublishedDate', [this.mediaMetadata.publishedYear])
|
||||
return '\u00A0'
|
||||
}
|
||||
if (this.orderBy === 'progress') {
|
||||
if (!this.userProgressLastUpdated) return '\u00A0'
|
||||
return this.$getString('LabelLastProgressDate', [this.$formatDatetime(this.userProgressLastUpdated, this.dateFormat, this.timeFormat)])
|
||||
}
|
||||
if (this.orderBy === 'progress.createdAt') {
|
||||
if (!this.userProgressStartedDate) return '\u00A0'
|
||||
return this.$getString('LabelStartedDate', [this.$formatDatetime(this.userProgressStartedDate, this.dateFormat, this.timeFormat)])
|
||||
}
|
||||
if (this.orderBy === 'progress.finishedAt') {
|
||||
if (!this.userProgressFinishedDate) return '\u00A0'
|
||||
return this.$getString('LabelFinishedDate', [this.$formatDatetime(this.userProgressFinishedDate, this.dateFormat, this.timeFormat)])
|
||||
}
|
||||
return null
|
||||
},
|
||||
episodeProgress() {
|
||||
@@ -377,6 +393,18 @@ export default {
|
||||
let progressPercent = this.itemIsFinished ? 1 : this.booksInSeries ? this.seriesProgressPercent : this.useEBookProgress ? this.userProgress?.ebookProgress || 0 : this.userProgress?.progress || 0
|
||||
return Math.max(Math.min(1, progressPercent), 0)
|
||||
},
|
||||
userProgressLastUpdated() {
|
||||
if (!this.userProgress) return null
|
||||
return this.userProgress.lastUpdate
|
||||
},
|
||||
userProgressStartedDate() {
|
||||
if (!this.userProgress) return null
|
||||
return this.userProgress.startedAt
|
||||
},
|
||||
userProgressFinishedDate() {
|
||||
if (!this.userProgress) return null
|
||||
return this.userProgress.finishedAt
|
||||
},
|
||||
itemIsFinished() {
|
||||
if (this.booksInSeries) return this.seriesIsFinished
|
||||
return this.userProgress ? !!this.userProgress.isFinished : false
|
||||
@@ -760,11 +788,11 @@ export default {
|
||||
},
|
||||
showEditModalFiles() {
|
||||
// More menu func
|
||||
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'files' })
|
||||
this.$emit('edit', this.libraryItem, 'files')
|
||||
},
|
||||
showEditModalMatch() {
|
||||
// More menu func
|
||||
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' })
|
||||
this.$emit('edit', this.libraryItem, 'match')
|
||||
},
|
||||
sendToDevice(deviceName) {
|
||||
// More menu func
|
||||
|
||||
@@ -71,7 +71,7 @@ export default {
|
||||
return this.height * this.sizeMultiplier
|
||||
},
|
||||
dateFormat() {
|
||||
return this.store.state.serverSettings.dateFormat
|
||||
return this.store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
labelFontSize() {
|
||||
if (this.width < 160) return 0.75
|
||||
|
||||
@@ -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',
|
||||
|
||||
60
client/components/modals/ApiKeyCreatedModal.vue
Normal file
60
client/components/modals/ApiKeyCreatedModal.vue
Normal file
@@ -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>
|
||||
198
client/components/modals/ApiKeyModal.vue
Normal file
198
client/components/modals/ApiKeyModal.vue
Normal file
@@ -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 [
|
||||
{
|
||||
|
||||
@@ -97,7 +97,10 @@ export default {
|
||||
...playlist
|
||||
}
|
||||
})
|
||||
.sort((a, b) => (a.isItemIncluded ? -1 : 1))
|
||||
.sort((a, b) => {
|
||||
if (a.isItemIncluded !== b.isItemIncluded) return a.isItemIncluded ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
},
|
||||
isBatch() {
|
||||
return this.selectedPlaylistItems.length > 1
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -74,6 +74,9 @@ export default {
|
||||
currentChapterStart() {
|
||||
if (!this.currentChapter) return 0
|
||||
return this.currentChapter.start
|
||||
},
|
||||
isMobile() {
|
||||
return this.$store.state.globals.isMobile
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -145,6 +148,9 @@ export default {
|
||||
})
|
||||
},
|
||||
mousemoveTrack(e) {
|
||||
if (this.isMobile) {
|
||||
return
|
||||
}
|
||||
const offsetX = e.offsetX
|
||||
|
||||
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
|
||||
@@ -198,6 +204,7 @@ export default {
|
||||
setTrackWidth() {
|
||||
if (this.$refs.track) {
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
this.trackOffsetLeft = this.$refs.track.getBoundingClientRect().left
|
||||
} else {
|
||||
console.error('Track not loaded', this.$refs)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div :key="n" class="absolute pointer-events-none left-0 h-px bg-white/10" :style="{ top: n * lineSpacing - lineSpacing / 2 + 'px', width: '360px', marginLeft: '24px' }" />
|
||||
|
||||
<div :key="`dot-${n}`" class="absolute z-10" :style="{ left: points[n - 1].x + 'px', bottom: points[n - 1].y + 'px' }">
|
||||
<ui-tooltip :text="last7DaysOfListening[n - 1].minutesListening" direction="top">
|
||||
<ui-tooltip :text="last7DaysOfListening[n - 1].minutesListening" plaintext direction="top">
|
||||
<div class="h-2 w-2 bg-yellow-400 hover:bg-yellow-300 rounded-full transform duration-150 transition-transform hover:scale-125" />
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
@@ -186,10 +186,16 @@ export default {
|
||||
daysInARow() {
|
||||
var count = 0
|
||||
while (true) {
|
||||
var _date = this.$addDaysToToday(count * -1)
|
||||
var datestr = this.$formatJsDate(_date, 'yyyy-MM-dd')
|
||||
const _date = this.$addDaysToToday(count * -1 - 1)
|
||||
const datestr = this.$formatJsDate(_date, 'yyyy-MM-dd')
|
||||
|
||||
if (!this.listeningStatsDays[datestr] || this.listeningStatsDays[datestr] === 0) {
|
||||
// don't require listening today to count towards days in a row, but do count it if already listened today
|
||||
const today = this.$formatJsDate(new Date(), 'yyyy-MM-dd')
|
||||
if (this.listeningStatsDays[today]) {
|
||||
count++
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
count++
|
||||
|
||||
@@ -152,7 +152,7 @@ export default {
|
||||
|
||||
this.showingTooltipIndex = index
|
||||
this.tooltipEl.style.display = 'block'
|
||||
this.tooltipTextEl.innerHTML = block.value ? `<strong>${this.$elapsedPretty(block.value, true)} listening</strong> on ${block.datePretty}` : `No listening sessions on ${block.datePretty}`
|
||||
this.tooltipTextEl.innerHTML = block.value ? this.$getString('MessageHeatmapListeningTimeTooltip', [this.$elapsedPrettyLocalized(block.value, true), block.datePretty]) : this.$getString('MessageHeatmapNoListeningSessions', [block.datePretty])
|
||||
|
||||
const calculateRect = this.tooltipEl.getBoundingClientRect()
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap justify-center mt-6">
|
||||
<div class="flex p-2">
|
||||
<svg class="h-14 w-14" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M9 3V18H12V3H9M12 5L16 18L19 17L15 4L12 5M5 5V18H8V5H5M3 19V21H21V19H3Z" />
|
||||
</svg>
|
||||
<span class="material-symbols text-5xl py-1">newsstand</span>
|
||||
<div class="px-1">
|
||||
<p class="text-4.5xl leading-none font-bold">{{ $formatNumber(totalItems) }}</p>
|
||||
<p class="text-xs md:text-sm text-white/80">{{ $strings.LabelStatsItemsInLibrary }}</p>
|
||||
@@ -19,9 +17,7 @@
|
||||
</div>
|
||||
|
||||
<div v-if="isBookLibrary" class="flex p-2">
|
||||
<svg class="h-14 w-14" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,6A2,2 0 0,0 10,8A2,2 0 0,0 12,10A2,2 0 0,0 14,8A2,2 0 0,0 12,6M12,13C14.67,13 20,14.33 20,17V20H4V17C4,14.33 9.33,13 12,13M12,14.9C9.03,14.9 5.9,16.36 5.9,17V18.1H18.1V17C18.1,16.36 14.97,14.9 12,14.9Z" />
|
||||
</svg>
|
||||
<span class="material-symbols text-5xl py-1">person</span>
|
||||
<div class="px-1">
|
||||
<p class="text-4.5xl leading-none font-bold">{{ $formatNumber(totalAuthors) }}</p>
|
||||
<p class="text-xs md:text-sm text-white/80">{{ $strings.LabelStatsAuthors }}</p>
|
||||
|
||||
@@ -164,14 +164,15 @@ export default {
|
||||
beforeMount() {
|
||||
this.yearInReviewYear = new Date().getFullYear()
|
||||
|
||||
// When not December show previous year
|
||||
if (new Date().getMonth() < 11) {
|
||||
this.availableYears = this.getAvailableYears()
|
||||
const availableYearValues = this.availableYears.map((y) => y.value)
|
||||
|
||||
// When not December show previous year if data is available
|
||||
if (new Date().getMonth() < 11 && availableYearValues.includes(this.yearInReviewYear - 1)) {
|
||||
this.yearInReviewYear--
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.availableYears = this.getAvailableYears()
|
||||
|
||||
if (typeof navigator.share !== 'undefined' && navigator.share) {
|
||||
this.showShareButton = true
|
||||
} else {
|
||||
|
||||
177
client/components/tables/ApiKeysTable.vue
Normal file
177
client/components/tables/ApiKeysTable.vue
Normal file
@@ -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(() => {
|
||||
@@ -276,7 +278,7 @@ export default {
|
||||
})
|
||||
},
|
||||
insertNewItem(item) {
|
||||
this.selected.push(item)
|
||||
if (!this.selected.includes(item)) this.selected.push(item)
|
||||
this.$emit('input', this.selected)
|
||||
this.$emit('newItem', item)
|
||||
this.textInput = null
|
||||
|
||||
@@ -85,9 +85,6 @@ export default {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
wrapperClass() {
|
||||
var classes = []
|
||||
if (this.disabled) classes.push('bg-black-300')
|
||||
@@ -290,7 +287,7 @@ export default {
|
||||
})
|
||||
},
|
||||
insertNewItem(item) {
|
||||
this.selected.push(item)
|
||||
if (!this.selected.find((i) => i.name === item.name)) this.selected.push(item)
|
||||
this.$emit('input', this.selected)
|
||||
this.$emit('newItem', item)
|
||||
this.textInput = null
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
<template>
|
||||
<button :aria-label="isRead ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" :class="borderless ? '' : 'bg-primary border border-gray-600'" @click="clickBtn">
|
||||
<div class="w-5 h-5 text-white relative">
|
||||
<svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)">
|
||||
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-7 19.6l-7-4.66V3h14v12.93l-7 4.67zm-2.01-7.42l-2.58-2.59L6 12l4 4 8-8-1.42-1.42z" />
|
||||
</svg>
|
||||
<div class="w-5 h-5 relative">
|
||||
<span v-if="isRead" class="material-symbols fill text-xl text-success">beenhere</span>
|
||||
<span v-else class="material-symbols text-xl text-white">beenhere</span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="relative w-full">
|
||||
<p v-if="label" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||
<p v-if="label && !labelHidden" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||
<button ref="buttonWrapper" type="button" :aria-label="longLabel" :disabled="disabled" class="relative w-full border rounded-sm shadow-xs pl-3 pr-8 py-2 text-left sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||
<span class="flex items-center">
|
||||
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span>
|
||||
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small, 'text-gray-400': !selectedText }">{{ selectedText || placeholder }}</span>
|
||||
<span v-if="selectedSubtext">: </span>
|
||||
<span v-if="selectedSubtext" class="font-normal block truncate font-sans text-sm text-gray-400">{{ selectedSubtext }}</span>
|
||||
</span>
|
||||
@@ -36,10 +36,15 @@ export default {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
labelHidden: Boolean,
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
disabled: Boolean,
|
||||
small: Boolean,
|
||||
menuMaxHeight: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="inline-flex toggle-btn-wrapper shadow-md">
|
||||
<button v-for="item in items" :key="item.value" type="button" class="toggle-btn outline-hidden relative border border-gray-600 px-4 py-1" :class="{ selected: item.value === value }" @click.stop="clickBtn(item.value)">
|
||||
<button v-for="item in items" :key="item.value" type="button" :disabled="disabled" class="toggle-btn outline-hidden relative border border-gray-600 px-4 py-1" :class="{ selected: item.value === value }" @click.stop="clickBtn(item.value)">
|
||||
{{ item.text }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -9,13 +9,17 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: String,
|
||||
value: [String, Number],
|
||||
/**
|
||||
* [{ "text", "", "value": "" }]
|
||||
*/
|
||||
items: {
|
||||
type: Array,
|
||||
default: Object
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -76,10 +80,19 @@ export default {
|
||||
.toggle-btn.selected {
|
||||
color: white;
|
||||
}
|
||||
.toggle-btn.selected:disabled {
|
||||
color: white;
|
||||
}
|
||||
.toggle-btn.selected::before {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
button.toggle-btn.selected:disabled::before {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
button.toggle-btn:disabled::before {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
button.toggle-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -22,7 +22,8 @@ export default {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
disabled: Boolean
|
||||
disabled: Boolean,
|
||||
plaintext: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -46,7 +47,11 @@ export default {
|
||||
methods: {
|
||||
updateText() {
|
||||
if (this.tooltip) {
|
||||
this.tooltip.innerHTML = this.text
|
||||
if (this.plaintext) {
|
||||
this.tooltip.textContent = this.text
|
||||
} else {
|
||||
this.tooltip.innerHTML = this.text
|
||||
}
|
||||
this.setTooltipPosition(this.tooltip)
|
||||
}
|
||||
},
|
||||
@@ -58,7 +63,11 @@ export default {
|
||||
tooltip.className = 'tooltip-wrapper absolute px-2 py-1 text-white text-xs rounded-sm shadow-lg max-w-xs text-center hidden sm:block'
|
||||
tooltip.style.zIndex = 100
|
||||
tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)'
|
||||
tooltip.innerHTML = this.text
|
||||
if (this.plaintext) {
|
||||
tooltip.textContent = this.text
|
||||
} else {
|
||||
tooltip.innerHTML = this.text
|
||||
}
|
||||
tooltip.addEventListener('mouseover', this.cancelHide)
|
||||
tooltip.addEventListener('mouseleave', this.hideTooltip)
|
||||
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
219
client/components/widgets/EncoderOptionsCard.vue
Normal file
219
client/components/widgets/EncoderOptionsCard.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<div class="w-full py-2">
|
||||
<div class="flex -mb-px">
|
||||
<button type="button" :disabled="disabled" class="w-1/2 h-8 rounded-tl-md relative border border-black-200 flex items-center justify-center disabled:cursor-not-allowed" :class="!showAdvancedView ? 'text-white bg-bg hover:bg-bg/60 border-b-bg' : 'text-gray-400 hover:text-gray-300 bg-primary/70 hover:bg-primary/60'" @click="showAdvancedView = false">
|
||||
<p class="text-sm">{{ $strings.HeaderPresets }}</p>
|
||||
</button>
|
||||
<button type="button" :disabled="disabled" class="w-1/2 h-8 rounded-tr-md relative border border-black-200 flex items-center justify-center -ml-px disabled:cursor-not-allowed" :class="showAdvancedView ? 'text-white bg-bg hover:bg-bg/60 border-b-bg' : 'text-gray-400 hover:text-gray-300 bg-primary/70 hover:bg-primary/60'" @click="showAdvancedView = true">
|
||||
<p class="text-sm">{{ $strings.HeaderAdvanced }}</p>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-4 md:p-8 border border-black-200 rounded-b-md mr-px bg-bg">
|
||||
<template v-if="!showAdvancedView">
|
||||
<div class="flex flex-wrap gap-4 sm:gap-8 justify-start sm:justify-center">
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<p class="text-sm w-40">{{ $strings.LabelCodec }}</p>
|
||||
<ui-toggle-btns v-model="selectedCodec" :items="codecItems" :disabled="disabled" />
|
||||
<p class="text-xs text-gray-300">
|
||||
{{ $strings.LabelCurrently }} <span class="text-white">{{ currentCodec }}</span> <span v-if="isCodecsDifferent" class="text-warning">(mixed)</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<p class="text-sm w-40">{{ $strings.LabelBitrate }}</p>
|
||||
<ui-toggle-btns v-model="selectedBitrate" :items="bitrateItems" :disabled="disabled" />
|
||||
<p class="text-xs text-gray-300">
|
||||
{{ $strings.LabelCurrently }} <span class="text-white">{{ currentBitrate }} KB/s</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<p class="text-sm w-40">{{ $strings.LabelChannels }}</p>
|
||||
<ui-toggle-btns v-model="selectedChannels" :items="channelsItems" :disabled="disabled" />
|
||||
<p class="text-xs text-gray-300">
|
||||
{{ $strings.LabelCurrently }} <span class="text-white">{{ currentChannels }} ({{ currentChanelLayout }})</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>
|
||||
<div class="flex flex-wrap gap-4 sm:gap-8 justify-start sm:justify-center mb-4">
|
||||
<div class="w-40">
|
||||
<ui-text-input-with-label v-model="customCodec" :label="$strings.LabelAudioCodec" :disabled="disabled" @input="customCodecChanged" />
|
||||
</div>
|
||||
<div class="w-40">
|
||||
<ui-text-input-with-label v-model="customBitrate" :label="$strings.LabelAudioBitrate" :disabled="disabled" @input="customBitrateChanged" />
|
||||
</div>
|
||||
<div class="w-40">
|
||||
<ui-text-input-with-label v-model="customChannels" :label="$strings.LabelAudioChannels" type="number" :disabled="disabled" @input="customChannelsChanged" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs sm:text-sm text-warning sm:text-center">{{ $strings.LabelEncodingWarningAdvancedSettings }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
audioTracks: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showAdvancedView: false,
|
||||
selectedCodec: 'aac',
|
||||
selectedBitrate: '128k',
|
||||
selectedChannels: 2,
|
||||
customCodec: 'aac',
|
||||
customBitrate: '128k',
|
||||
customChannels: 2,
|
||||
currentCodec: '',
|
||||
currentBitrate: '',
|
||||
currentChannels: '',
|
||||
currentChanelLayout: '',
|
||||
isCodecsDifferent: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
codecItems() {
|
||||
return [
|
||||
{
|
||||
text: 'Copy',
|
||||
value: 'copy'
|
||||
},
|
||||
{
|
||||
text: 'AAC',
|
||||
value: 'aac'
|
||||
},
|
||||
{
|
||||
text: 'OPUS',
|
||||
value: 'opus'
|
||||
}
|
||||
]
|
||||
},
|
||||
bitrateItems() {
|
||||
return [
|
||||
{
|
||||
text: '32k',
|
||||
value: '32k'
|
||||
},
|
||||
{
|
||||
text: '64k',
|
||||
value: '64k'
|
||||
},
|
||||
{
|
||||
text: '128k',
|
||||
value: '128k'
|
||||
},
|
||||
{
|
||||
text: '192k',
|
||||
value: '192k'
|
||||
}
|
||||
]
|
||||
},
|
||||
channelsItems() {
|
||||
return [
|
||||
{
|
||||
text: '1 (mono)',
|
||||
value: 1
|
||||
},
|
||||
{
|
||||
text: '2 (stereo)',
|
||||
value: 2
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
customBitrateChanged(val) {
|
||||
localStorage.setItem('embedMetadataBitrate', val)
|
||||
},
|
||||
customChannelsChanged(val) {
|
||||
localStorage.setItem('embedMetadataChannels', val)
|
||||
},
|
||||
customCodecChanged(val) {
|
||||
localStorage.setItem('embedMetadataCodec', val)
|
||||
},
|
||||
getEncodingOptions() {
|
||||
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() {
|
||||
// If already AAC and not mixed, set copy
|
||||
if (this.currentCodec === 'aac' && !this.isCodecsDifferent) {
|
||||
this.selectedCodec = 'copy'
|
||||
} else {
|
||||
this.selectedCodec = 'aac'
|
||||
}
|
||||
|
||||
if (!this.currentBitrate) {
|
||||
this.selectedBitrate = '128k'
|
||||
} else {
|
||||
// Find closest bitrate rounding up
|
||||
const bitratesToMatch = [32, 64, 128, 192]
|
||||
const closestBitrate = bitratesToMatch.find((bitrate) => bitrate >= this.currentBitrate) || 192
|
||||
this.selectedBitrate = closestBitrate + 'k'
|
||||
}
|
||||
|
||||
if (!this.currentChannels || isNaN(this.currentChannels)) {
|
||||
this.selectedChannels = 2
|
||||
} else {
|
||||
// Either 1 or 2
|
||||
this.selectedChannels = Math.max(Math.min(Number(this.currentChannels), 2), 1)
|
||||
}
|
||||
},
|
||||
setCurrentValues() {
|
||||
if (this.audioTracks.length === 0) return
|
||||
|
||||
this.currentChannels = this.audioTracks[0].channels
|
||||
this.currentChanelLayout = this.audioTracks[0].channelLayout
|
||||
this.currentCodec = this.audioTracks[0].codec
|
||||
|
||||
let totalBitrate = 0
|
||||
for (const track of this.audioTracks) {
|
||||
const trackBitrate = !isNaN(track.bitRate) ? track.bitRate : 0
|
||||
totalBitrate += trackBitrate
|
||||
|
||||
if (track.channels > this.currentChannels) this.currentChannels = track.channels
|
||||
if (track.codec !== this.currentCodec) {
|
||||
console.warn('Audio track codec is different from the first track', track.codec)
|
||||
this.isCodecsDifferent = true
|
||||
}
|
||||
}
|
||||
|
||||
this.currentBitrate = Math.round(totalBitrate / this.audioTracks.length / 1000)
|
||||
},
|
||||
init() {
|
||||
this.customBitrate = localStorage.getItem('embedMetadataBitrate') || '128k'
|
||||
this.customChannels = localStorage.getItem('embedMetadataChannels') || 2
|
||||
this.customCodec = localStorage.getItem('embedMetadataCodec') || 'aac'
|
||||
|
||||
this.setCurrentValues()
|
||||
|
||||
this.setPreset()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -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
|
||||
|
||||
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.21.0",
|
||||
"version": "2.32.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.21.0",
|
||||
"version": "2.32.1",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.21.0",
|
||||
"version": "2.32.1",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
|
||||
@@ -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" plaintext direction="left">
|
||||
<button class="w-7 h-7 rounded-full flex items-center justify-center text-error">
|
||||
<span class="material-symbols text-lg">error_outline</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center mt-4 mb-2">
|
||||
<div class="w-8 min-w-8 md:w-12 md:min-w-12"></div>
|
||||
<div class="w-38 min-w-38 md:w-40 md:min-w-40 px-1"></div>
|
||||
<div class="flex items-center gap-2 grow px-1">
|
||||
<ui-text-input v-model="bulkChapterInput" :placeholder="$strings.PlaceholderBulkChapterInput" class="text-xs grow min-w-52" @keyup.enter="handleBulkChapterAdd" />
|
||||
</div>
|
||||
<div class="w-39 min-w-39 px-1 py-1">
|
||||
<ui-tooltip :text="$strings.TooltipAddChapters" direction="bottom" class="inline-block align-middle">
|
||||
<button class="w-5 h-5 rounded-full flex items-center justify-center text-gray-300 hover:text-success transform hover:scale-110 duration-150 flex-shrink-0" :aria-label="$strings.TooltipAddChapters" :class="{ 'opacity-50 cursor-not-allowed': !bulkChapterInput.trim() }" :disabled="!bulkChapterInput.trim()" @click="handleBulkChapterAdd">
|
||||
<span class="material-symbols text-lg">add</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-xl py-4 px-2">
|
||||
<div class="w-full max-w-3xl min-[1120px]:max-w-xl py-4 px-2">
|
||||
<div class="flex items-center mb-4 py-1">
|
||||
<p class="text-lg font-semibold">{{ $strings.HeaderAudioTracks }}</p>
|
||||
<div class="grow" />
|
||||
@@ -110,23 +160,19 @@
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
|
||||
<div class="grow">{{ $strings.LabelFilename }}</div>
|
||||
<div class="grow min-[1120px]:max-w-64 xl:max-w-sm">{{ $strings.LabelFilename }}</div>
|
||||
<div class="w-20">{{ $strings.LabelDuration }}</div>
|
||||
<div class="w-20 hidden md:block text-center">{{ $strings.HeaderChapters }}</div>
|
||||
</div>
|
||||
<template v-for="track in audioTracks">
|
||||
<div :key="track.ino" class="flex items-center py-2" :class="currentTrackIndex === track.index && isPlayingChapter ? 'bg-success/10' : ''">
|
||||
<div class="grow max-w-[calc(100%-80px)] pr-2">
|
||||
<p class="text-xs truncate max-w-sm">{{ track.metadata.filename }}</p>
|
||||
</div>
|
||||
<div class="w-20" style="min-width: 80px">
|
||||
<p class="text-xs font-mono text-gray-200">{{ $secondsToTimestamp(Math.round(track.duration), false, true) }}</p>
|
||||
</div>
|
||||
<div class="w-20 hidden md:flex justify-center" style="min-width: 80px">
|
||||
<span v-if="(track.chapters || []).length" class="material-symbols text-success text-sm">check</span>
|
||||
</div>
|
||||
<div v-for="track in audioTracks" :key="track.ino" class="flex items-center py-2" :class="currentTrackIndex === track.index && isPlayingChapter ? 'bg-success/10' : ''">
|
||||
<div class="pr-2 grow min-[1120px]:max-w-64 xl:max-w-sm">
|
||||
<p class="text-xs truncate">{{ track.metadata.filename }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="w-20" style="min-width: 80px">
|
||||
<p class="text-xs font-mono text-gray-200">{{ $secondsToTimestamp(Math.round(track.duration), false, true) }}</p>
|
||||
</div>
|
||||
<div class="w-20 hidden md:flex justify-center" style="min-width: 80px"><span v-if="(track.chapters || []).length" class="material-symbols text-success text-sm">check</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -134,6 +180,7 @@
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
|
||||
<!-- audible chapter lookup modal -->
|
||||
<modals-modal v-model="showFindChaptersModal" name="edit-book" :width="500" :processing="findingChapters">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
|
||||
@@ -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,43 +20,43 @@
|
||||
|
||||
<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>
|
||||
|
||||
<div class="flex justify-center flex-wrap">
|
||||
<div class="w-full max-w-2xl border border-white/10 bg-bg mx-2">
|
||||
<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>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full max-w-2xl border border-white/10 bg-bg mx-2">
|
||||
<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>
|
||||
@@ -77,10 +84,6 @@
|
||||
</div>
|
||||
<!-- m4b embed action buttons -->
|
||||
<div v-else class="w-full flex items-center mb-4">
|
||||
<button :disabled="processing" class="text-sm uppercase text-gray-200 flex items-center pt-px pl-1 pr-2 hover:bg-white/5 rounded-md" @click="showEncodeOptions = !showEncodeOptions">
|
||||
<span class="material-symbols text-xl">{{ showEncodeOptions || usingCustomEncodeOptions ? 'check_box' : 'check_box_outline_blank' }}</span> <span class="pl-1">{{ $strings.LabelUseAdvancedOptions }}</span>
|
||||
</button>
|
||||
|
||||
<div class="grow" />
|
||||
|
||||
<ui-btn v-if="!isTaskFinished && processing" color="bg-error" :loading="isCancelingEncode" class="mr-2" @click.stop="cancelEncodeClick">{{ $strings.ButtonCancelEncode }}</ui-btn>
|
||||
@@ -89,18 +92,16 @@
|
||||
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageM4BFinished }}</p>
|
||||
</div>
|
||||
|
||||
<!-- advanced encoding options -->
|
||||
<div v-if="isM4BTool" class="overflow-hidden">
|
||||
<transition name="slide">
|
||||
<div v-if="showEncodeOptions || usingCustomEncodeOptions" class="mb-4 pb-4 border-b border-white/10">
|
||||
<div class="flex flex-wrap -mx-2">
|
||||
<ui-text-input-with-label ref="bitrateInput" v-model="encodingOptions.bitrate" :disabled="processing || isTaskFinished" :label="$strings.LabelAudioBitrate" class="m-2 max-w-40" @input="bitrateChanged" />
|
||||
<ui-text-input-with-label ref="channelsInput" v-model="encodingOptions.channels" :disabled="processing || isTaskFinished" :label="$strings.LabelAudioChannels" class="m-2 max-w-40" @input="channelsChanged" />
|
||||
<ui-text-input-with-label ref="codecInput" v-model="encodingOptions.codec" :disabled="processing || isTaskFinished" :label="$strings.LabelAudioCodec" class="m-2 max-w-40" @input="codecChanged" />
|
||||
</div>
|
||||
<p class="text-sm text-warning">{{ $strings.LabelEncodingWarningAdvancedSettings }}</p>
|
||||
</div>
|
||||
</transition>
|
||||
<!-- show encoding options for running task -->
|
||||
<div v-if="encodeTaskHasEncodingOptions" class="mb-4 pb-4 border-b border-white/10">
|
||||
<div class="flex flex-wrap -mx-2">
|
||||
<ui-text-input-with-label ref="bitrateInput" v-model="encodingOptions.bitrate" readonly :label="$strings.LabelAudioBitrate" class="m-2 max-w-40" @input="bitrateChanged" />
|
||||
<ui-text-input-with-label ref="channelsInput" v-model="encodingOptions.channels" readonly :label="$strings.LabelAudioChannels" class="m-2 max-w-40" @input="channelsChanged" />
|
||||
<ui-text-input-with-label ref="codecInput" v-model="encodingOptions.codec" readonly :label="$strings.LabelAudioCodec" class="m-2 max-w-40" @input="codecChanged" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="isM4BTool" class="mb-4">
|
||||
<widgets-encoder-options-card ref="encoderOptionsCard" :audio-tracks="audioFiles" :disabled="processing || isTaskFinished" />
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
@@ -146,19 +147,29 @@
|
||||
<div class="flex py-2 px-4 bg-primary/25">
|
||||
<div class="w-10 text-xs font-semibold text-gray-200">#</div>
|
||||
<div class="grow text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelFilename }}</div>
|
||||
<div class="w-20 text-xs font-semibold uppercase text-gray-200 hidden lg:block">{{ $strings.LabelChannels }}</div>
|
||||
<div class="w-16 text-xs font-semibold uppercase text-gray-200 hidden md:block">{{ $strings.LabelCodec }}</div>
|
||||
<div class="w-16 text-xs font-semibold uppercase text-gray-200 hidden md:block">{{ $strings.LabelBitrate }}</div>
|
||||
<div class="w-16 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelSize }}</div>
|
||||
<div class="w-24"></div>
|
||||
</div>
|
||||
<template v-for="file in audioFiles">
|
||||
<div :key="file.index" class="flex py-2 px-4 text-sm" :class="file.index % 2 === 0 ? 'bg-primary/25' : ''">
|
||||
<div class="w-10">{{ file.index }}</div>
|
||||
<div :key="file.index" class="flex py-2 px-4 text-xs sm:text-sm" :class="file.index % 2 === 0 ? 'bg-primary/25' : ''">
|
||||
<div class="w-10 min-w-10">{{ file.index }}</div>
|
||||
<div class="grow">
|
||||
{{ file.metadata.filename }}
|
||||
</div>
|
||||
<div class="w-16 font-mono text-gray-200">
|
||||
<div class="w-20 min-w-20 text-gray-200 hidden lg:block">{{ file.channels || 'unknown' }} ({{ file.channelLayout || 'unknown' }})</div>
|
||||
<div class="w-16 min-w-16 text-gray-200 hidden md:block">
|
||||
{{ file.codec || 'unknown' }}
|
||||
</div>
|
||||
<div class="w-16 min-w-16 text-gray-200 hidden md:block">
|
||||
{{ $bytesPretty(file.bitRate || 0, 0) }}
|
||||
</div>
|
||||
<div class="w-16 min-w-16 text-gray-200">
|
||||
{{ $bytesPretty(file.metadata.size) }}
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<div class="w-24 min-w-24">
|
||||
<div class="flex justify-center">
|
||||
<span v-if="audioFilesFinished[file.ino]" class="material-symbols text-xl text-success leading-none">check_circle</span>
|
||||
<div v-else-if="audioFilesEncoding[file.ino]">
|
||||
@@ -214,7 +225,6 @@ export default {
|
||||
metadataObject: null,
|
||||
selectedTool: 'embed',
|
||||
isCancelingEncode: false,
|
||||
showEncodeOptions: false,
|
||||
shouldBackupAudioFiles: true,
|
||||
encodingOptions: {
|
||||
bitrate: '128k',
|
||||
@@ -263,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
|
||||
},
|
||||
@@ -273,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
|
||||
@@ -314,8 +317,8 @@ export default {
|
||||
isMetadataEmbedQueued() {
|
||||
return this.queuedEmbedLIds.some((lid) => lid === this.libraryItemId)
|
||||
},
|
||||
usingCustomEncodeOptions() {
|
||||
return this.isM4BTool && this.encodeTask && this.encodeTask.data.encodeOptions && Object.keys(this.encodeTask.data.encodeOptions).length > 0
|
||||
encodeTaskHasEncodingOptions() {
|
||||
return this.isM4BTool && !!this.encodeTask?.data.encodeOptions && Object.keys(this.encodeTask.data.encodeOptions).length > 0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -351,19 +354,15 @@ export default {
|
||||
if (this.$refs.channelsInput) this.$refs.channelsInput.blur()
|
||||
if (this.$refs.codecInput) this.$refs.codecInput.blur()
|
||||
|
||||
let queryStr = ''
|
||||
if (this.showEncodeOptions) {
|
||||
const options = []
|
||||
if (this.encodingOptions.bitrate) options.push(`bitrate=${this.encodingOptions.bitrate}`)
|
||||
if (this.encodingOptions.channels) options.push(`channels=${this.encodingOptions.channels}`)
|
||||
if (this.encodingOptions.codec) options.push(`codec=${this.encodingOptions.codec}`)
|
||||
if (options.length) {
|
||||
queryStr = `?${options.join('&')}`
|
||||
}
|
||||
}
|
||||
const encodeOptions = this.$refs.encoderOptionsCard.getEncodingOptions()
|
||||
|
||||
this.encodingOptions = encodeOptions
|
||||
|
||||
const queryParams = new URLSearchParams(encodeOptions)
|
||||
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post(`/api/tools/item/${this.libraryItemId}/encode-m4b${queryStr}`)
|
||||
.$post(`/api/tools/item/${this.libraryItemId}/encode-m4b?${queryParams.toString()}`)
|
||||
.then(() => {
|
||||
console.log('Ab m4b merge started')
|
||||
})
|
||||
@@ -416,14 +415,10 @@ export default {
|
||||
const shouldBackupAudioFiles = localStorage.getItem('embedMetadataShouldBackup')
|
||||
this.shouldBackupAudioFiles = shouldBackupAudioFiles != 0
|
||||
|
||||
if (this.usingCustomEncodeOptions) {
|
||||
if (this.encodeTaskHasEncodingOptions) {
|
||||
if (this.encodeTask.data.encodeOptions.bitrate) this.encodingOptions.bitrate = this.encodeTask.data.encodeOptions.bitrate
|
||||
if (this.encodeTask.data.encodeOptions.channels) this.encodingOptions.channels = this.encodeTask.data.encodeOptions.channels
|
||||
if (this.encodeTask.data.encodeOptions.codec) this.encodingOptions.codec = this.encodeTask.data.encodeOptions.codec
|
||||
} else {
|
||||
this.encodingOptions.bitrate = localStorage.getItem('embedMetadataBitrate') || '128k'
|
||||
this.encodingOptions.channels = localStorage.getItem('embedMetadataChannels') || '2'
|
||||
this.encodingOptions.codec = localStorage.getItem('embedMetadataCodec') || 'aac'
|
||||
}
|
||||
},
|
||||
fetchMetadataEmbedObject() {
|
||||
@@ -438,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
|
||||
|
||||
84
client/pages/config/api-keys/index.vue
Normal file
84
client/pages/config/api-keys/index.vue
Normal file
@@ -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,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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="w-full h-dvh max-h-dvh overflow-hidden" :style="{ backgroundColor: coverRgb }">
|
||||
<div class="w-full max-w-full h-dvh max-h-dvh overflow-hidden" :style="{ backgroundColor: coverRgb }">
|
||||
<div class="w-screen h-screen absolute inset-0 pointer-events-none" style="background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(38, 38, 38, 1) 80%)"></div>
|
||||
<div class="absolute inset-0 w-screen h-dvh flex items-center justify-center z-10">
|
||||
<div class="w-full p-2 sm:p-4 md:p-8">
|
||||
@@ -335,8 +335,11 @@ export default {
|
||||
}
|
||||
},
|
||||
resize() {
|
||||
this.windowWidth = window.innerWidth
|
||||
this.windowHeight = window.innerHeight
|
||||
setTimeout(() => {
|
||||
this.windowWidth = window.innerWidth
|
||||
this.windowHeight = window.innerHeight
|
||||
this.$store.commit('globals/updateWindowSize', { width: window.innerWidth, height: window.innerHeight })
|
||||
}, 100)
|
||||
},
|
||||
playerError(error) {
|
||||
console.error('Player error', error)
|
||||
|
||||
@@ -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, 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
|
||||
@@ -8,28 +8,29 @@ export default class AudioTrack {
|
||||
this.mimeType = track.mimeType
|
||||
this.metadata = track.metadata || {}
|
||||
|
||||
this.userToken = userToken
|
||||
this.sessionId = sessionId
|
||||
this.routerBasePath = routerBasePath || ''
|
||||
if (this.contentUrl?.startsWith('/hls')) {
|
||||
this.sessionTrackUrl = this.contentUrl
|
||||
} else {
|
||||
this.sessionTrackUrl = `/public/session/${sessionId}/track/${this.index}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for CastPlayer
|
||||
*/
|
||||
get fullContentUrl() {
|
||||
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return `${process.env.serverUrl}${this.contentUrl}?token=${this.userToken}`
|
||||
return `${process.env.serverUrl}${this.sessionTrackUrl}`
|
||||
}
|
||||
return `${window.location.origin}${this.routerBasePath}${this.contentUrl}?token=${this.userToken}`
|
||||
return `${window.location.origin}${this.routerBasePath}${this.sessionTrackUrl}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for LocalPlayer
|
||||
*/
|
||||
get relativeContentUrl() {
|
||||
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
|
||||
|
||||
return `${this.routerBasePath}${this.contentUrl}?token=${this.userToken}`
|
||||
return `${this.routerBasePath}${this.sessionTrackUrl}`
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user