mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-12-31 11:38:47 -05:00
Compare commits
162 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdece944f4 | ||
|
|
d7952dab04 | ||
|
|
bec599f325 | ||
|
|
affcc03c61 | ||
|
|
db18c71857 | ||
|
|
dcc223949a | ||
|
|
6a6d384d88 | ||
|
|
cd57667444 | ||
|
|
3900db14d3 | ||
|
|
1fa94cbfad | ||
|
|
793233e782 | ||
|
|
94012e5dff | ||
|
|
d440a9fd6a | ||
|
|
928c6cf5b3 | ||
|
|
23a25d420c | ||
|
|
dc779a3fc5 | ||
|
|
876badbeea | ||
|
|
8563bdde74 | ||
|
|
803c9699ef | ||
|
|
c254dc5144 | ||
|
|
d22b475539 | ||
|
|
142205f060 | ||
|
|
02d997897c | ||
|
|
39979ff8a3 | ||
|
|
441b8c5bb7 | ||
|
|
d456ec2786 | ||
|
|
a729ce1512 | ||
|
|
3949896d88 | ||
|
|
14e5e11344 | ||
|
|
c23f31216a | ||
|
|
cd04533eea | ||
|
|
6701551289 | ||
|
|
1a4833f873 | ||
|
|
3a7639f690 | ||
|
|
63c55f08dc | ||
|
|
98e79f144c | ||
|
|
3b9236a7ce | ||
|
|
ac30a971c5 | ||
|
|
9ee6eaade9 | ||
|
|
8c32fed911 | ||
|
|
f36a5eae6d | ||
|
|
b7bdaac163 | ||
|
|
162a1b7971 | ||
|
|
97da73baf3 | ||
|
|
b6e3559aba | ||
|
|
39a13e3610 | ||
|
|
7aa89f16c9 | ||
|
|
88726bed86 | ||
|
|
a35b35c062 | ||
|
|
951afaa568 | ||
|
|
5e8979876f | ||
|
|
eb0ef8c696 | ||
|
|
066b6c13c6 | ||
|
|
014ad668a5 | ||
|
|
62c59c634c | ||
|
|
f3f2d614b1 | ||
|
|
7fd70c1c86 | ||
|
|
46a3974b79 | ||
|
|
f851cde1f4 | ||
|
|
0f772fd3cf | ||
|
|
dd0d2e9f55 | ||
|
|
022c506eda | ||
|
|
dd8577354b | ||
|
|
3e7a76574b | ||
|
|
0ef2a2e4b6 | ||
|
|
8e8046541e | ||
|
|
2d6f9bab8b | ||
|
|
11e3cf4f19 | ||
|
|
37a3fdb606 | ||
|
|
9983fe7d66 | ||
|
|
731cf8e4ed | ||
|
|
c3f2e606dd | ||
|
|
dbb62069ef | ||
|
|
b08ad8785e | ||
|
|
ff04eb8d5e | ||
|
|
9a7503cde2 | ||
|
|
7d4e7ce2c0 | ||
|
|
565bb4cd6b | ||
|
|
be592a04d0 | ||
|
|
ae4ac392c6 | ||
|
|
f6b6c0a41e | ||
|
|
83e4a8f4ed | ||
|
|
70ef09f451 | ||
|
|
b91b320006 | ||
|
|
d139fffa96 | ||
|
|
845fc0794e | ||
|
|
ac6c885878 | ||
|
|
b2b5111c50 | ||
|
|
e11629a161 | ||
|
|
ff2fb2b2ba | ||
|
|
b9a9c0e717 | ||
|
|
c16e6d19ae | ||
|
|
0e98620939 | ||
|
|
e32f51f58a | ||
|
|
1ec12a547e | ||
|
|
baedced83f | ||
|
|
174decf8da | ||
|
|
0700f12896 | ||
|
|
3dc848a106 | ||
|
|
c17612a233 | ||
|
|
7313d151f8 | ||
|
|
97dc9fbccf | ||
|
|
9a87e4af73 | ||
|
|
4ccb4243f7 | ||
|
|
eb25ca7af5 | ||
|
|
872d5178e6 | ||
|
|
d11501b2c6 | ||
|
|
7e05804bcf | ||
|
|
a73b72a07b | ||
|
|
8ec4bd4279 | ||
|
|
e362456895 | ||
|
|
8cd7de25ad | ||
|
|
99ea7866c5 | ||
|
|
3194b4cd87 | ||
|
|
149f52b33c | ||
|
|
575ec9d00b | ||
|
|
40e999fcae | ||
|
|
ac57b2b867 | ||
|
|
3cafa87eda | ||
|
|
dee4ca3559 | ||
|
|
772c7b3217 | ||
|
|
c0dd58a94e | ||
|
|
91e116969a | ||
|
|
1f37e32f91 | ||
|
|
221061ea30 | ||
|
|
1e8e45431d | ||
|
|
381a81e4bb | ||
|
|
be28b9899e | ||
|
|
37ca139195 | ||
|
|
6b02779e0f | ||
|
|
ff6d95dc4d | ||
|
|
e611d7a8fd | ||
|
|
67f6cd3c56 | ||
|
|
d0ab13865c | ||
|
|
33ae93e61e | ||
|
|
3b961c424f | ||
|
|
389b603d7d | ||
|
|
721de0a343 | ||
|
|
0aadf579f3 | ||
|
|
4ec217e5d0 | ||
|
|
0f01f21a0a | ||
|
|
46668854ad | ||
|
|
a690dfe671 | ||
|
|
7528e8df41 | ||
|
|
8224ca7650 | ||
|
|
a574d06e22 | ||
|
|
dd9a072231 | ||
|
|
2304f37cbe | ||
|
|
0c20988e18 | ||
|
|
9a57fcad40 | ||
|
|
01333b6401 | ||
|
|
8509ca3249 | ||
|
|
7a69afdcd9 | ||
|
|
2c0c53bbf1 | ||
|
|
9f200ece99 | ||
|
|
c5f91ec508 | ||
|
|
d06c61b329 | ||
|
|
be4f11a60e | ||
|
|
0c5db214d1 | ||
|
|
1ad9ea92b6 | ||
|
|
d15120eb5f | ||
|
|
b9deb32b20 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,7 +11,6 @@ test/
|
||||
/client/.nuxt/
|
||||
/client/dist/
|
||||
/dist/
|
||||
library/
|
||||
|
||||
sw.*
|
||||
.DS_STORE
|
||||
|
||||
20
.vscode/settings.json
vendored
Normal file
20
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"vetur.format.defaultFormatterOptions": {
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 400,
|
||||
"proseWrap": "never",
|
||||
"trailingComma": "none"
|
||||
},
|
||||
"prettyhtml": {
|
||||
"printWidth": 400,
|
||||
"singleQuote": false,
|
||||
"wrapAttributes": false,
|
||||
"sortAttributes": false
|
||||
}
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"editor.detectIndentation": true,
|
||||
"editor.tabSize": 2
|
||||
}
|
||||
@@ -6,7 +6,9 @@ RUN npm ci && npm cache clean --force
|
||||
RUN npm run generate
|
||||
|
||||
### STAGE 1: Build server ###
|
||||
FROM sandreas/tone:v0.1.1 AS tone
|
||||
FROM node:16-alpine
|
||||
|
||||
ENV NODE_ENV=production
|
||||
RUN apk update && \
|
||||
apk add --no-cache --update \
|
||||
@@ -14,6 +16,7 @@ RUN apk update && \
|
||||
tzdata \
|
||||
ffmpeg
|
||||
|
||||
COPY --from=tone /usr/local/bin/tone /usr/local/bin/
|
||||
COPY --from=build /client/dist /client/dist
|
||||
COPY index.js package* /
|
||||
COPY server server
|
||||
|
||||
@@ -5,11 +5,9 @@ set -o pipefail
|
||||
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg"
|
||||
DEFAULT_DATA_DIR="/usr/share/audiobookshelf"
|
||||
CONFIG_PATH="/etc/default/audiobookshelf"
|
||||
DEFAULT_PORT=7331
|
||||
DEFAULT_PORT=13378
|
||||
DEFAULT_HOST="0.0.0.0"
|
||||
|
||||
|
||||
|
||||
add_user() {
|
||||
: "${1:?'User was not defined'}"
|
||||
declare -r user="$1"
|
||||
@@ -52,6 +50,7 @@ install_ffmpeg() {
|
||||
echo "Starting FFMPEG Install"
|
||||
|
||||
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz"
|
||||
WGET_TONE="wget https://github.com/sandreas/tone/releases/download/v0.1.1/tone-0.1.1-linux-x64.tar.gz"
|
||||
|
||||
if ! cd "$FFMPEG_INSTALL_DIR"; then
|
||||
echo "Creating ffmpeg install dir at $FFMPEG_INSTALL_DIR"
|
||||
@@ -64,13 +63,26 @@ install_ffmpeg() {
|
||||
tar xvf ffmpeg-git-amd64-static.tar.xz --strip-components=1
|
||||
rm ffmpeg-git-amd64-static.tar.xz
|
||||
|
||||
echo "Good to go on Ffmpeg... hopefully"
|
||||
# Temp downloading tone library to the ffmpeg dir
|
||||
echo "Getting tone.."
|
||||
$WGET_TONE
|
||||
tar xvf tone-0.1.1-linux-x64.tar.gz --strip-components=1
|
||||
rm tone-0.1.1-linux-x64.tar.gz
|
||||
|
||||
echo "Good to go on Ffmpeg (& tone)... hopefully"
|
||||
}
|
||||
|
||||
setup_config() {
|
||||
if [ -f "$CONFIG_PATH" ]; then
|
||||
echo "Existing config found."
|
||||
cat $CONFIG_PATH
|
||||
|
||||
# TONE_PATH variable added in 2.1.6, if it doesnt exist then add it
|
||||
if ! grep -q "TONE_PATH" "$CONFIG_PATH"; then
|
||||
echo "Adding TONE_PATH to existing config"
|
||||
echo "TONE_PATH=$FFMPEG_INSTALL_DIR/tone" >> "$CONFIG_PATH"
|
||||
fi
|
||||
|
||||
else
|
||||
|
||||
if [ ! -d "$DEFAULT_DATA_DIR" ]; then
|
||||
@@ -83,11 +95,12 @@ setup_config() {
|
||||
echo "Creating default config."
|
||||
|
||||
config_text="METADATA_PATH=$DEFAULT_DATA_DIR/metadata
|
||||
CONFIG_PATH=$DEFAULT_DATA_DIR/config
|
||||
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
|
||||
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
|
||||
PORT=$DEFAULT_PORT
|
||||
HOST=$DEFAULT_HOST"
|
||||
CONFIG_PATH=$DEFAULT_DATA_DIR/config
|
||||
FFMPEG_PATH=$FFMPEG_INSTALL_DIR/ffmpeg
|
||||
FFPROBE_PATH=$FFMPEG_INSTALL_DIR/ffprobe
|
||||
TONE_PATH=$FFMPEG_INSTALL_DIR/tone
|
||||
PORT=$DEFAULT_PORT
|
||||
HOST=$DEFAULT_HOST"
|
||||
|
||||
echo "$config_text"
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ ExecStart=/usr/share/audiobookshelf/audiobookshelf
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
Restart=always
|
||||
User=audiobookshelf
|
||||
Group=audiobookshelf
|
||||
PermissionsStartOnly=true
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
#bookshelf {
|
||||
height: calc(100% - 40px);
|
||||
background-image: linear-gradient(to right bottom, #2e2e2e, #303030, #313131, #333333, #353535, #343434, #323232, #313131, #2c2c2c, #282828, #232323, #1f1f1f);
|
||||
|
||||
/* For Firefox */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #855620 rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.bookshelf-row {
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
font-family: 'Material Icons';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(/fonts/MaterialIcons.woff2) format('woff2');
|
||||
src: url(~static/fonts/MaterialIcons.woff2) format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Material Icons Outlined';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(/fonts/MaterialIconsOutlined.woff2) format('woff2');
|
||||
src: url(~static/fonts/MaterialIconsOutlined.woff2) format('woff2');
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
@@ -54,7 +54,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
|
||||
src: url(~static/fonts/GentiumBookBasic.woff2) format('woff2');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
|
||||
src: url(~static/fonts/GentiumBookBasic.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
@@ -224,7 +224,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
@@ -244,7 +244,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
@@ -254,7 +254,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@@ -274,7 +274,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@@ -284,7 +284,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
@@ -294,7 +294,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
@@ -304,7 +304,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
@@ -314,7 +314,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
@@ -324,7 +324,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@@ -334,6 +334,6 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@@ -3,19 +3,19 @@
|
||||
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-50">
|
||||
<div class="flex h-full items-center">
|
||||
<nuxt-link to="/">
|
||||
<img src="/icon.svg" class="w-10 min-w-10 h-10 mr-2 sm:w-12 sm:min-w-12 sm:h-12 sm:mr-4" />
|
||||
<img src="~static/icon.svg" class="w-8 min-w-8 h-8 mr-2 sm:w-12 sm:min-w-12 sm:h-12 sm:mr-4" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link to="/">
|
||||
<h1 class="text-2xl font-book mr-6 hidden lg:block hover:underline">audiobookshelf</h1>
|
||||
<h1 class="text-2xl font-book mr-6 hidden lg:block hover:underline">audiobookshelf <span v-if="showExperimentalFeatures" class="material-icons text-lg text-warning pr-1">logo_dev</span></h1>
|
||||
</nuxt-link>
|
||||
|
||||
<ui-libraries-dropdown class="mr-2" />
|
||||
|
||||
<controls-global-search v-if="currentLibrary" class="" />
|
||||
<controls-global-search v-if="currentLibrary" class="mr-1 sm:mr-0" />
|
||||
<div class="flex-grow" />
|
||||
|
||||
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
|
||||
<widgets-notification-widget class="hidden md:block" />
|
||||
|
||||
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
|
||||
<span class="material-icons-outlined text-warning text-opacity-50"> cast </span>
|
||||
@@ -49,6 +49,9 @@
|
||||
<div v-show="numLibraryItemsSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
|
||||
<h1 class="text-2xl px-4">{{ numLibraryItemsSelected }} Selected</h1>
|
||||
<div class="flex-grow" />
|
||||
<ui-tooltip v-if="userIsAdminOrUp && !isPodcastLibrary" text="Quick Match Selected" direction="bottom">
|
||||
<ui-icon-btn :disabled="processingBatch" icon="auto_awesome" @click="batchAutoMatchClick" class="mx-1.5" />
|
||||
</ui-tooltip>
|
||||
<ui-tooltip v-if="!isPodcastLibrary" :text="`Mark as ${selectedIsFinished ? 'Not Finished' : 'Finished'}`" direction="bottom">
|
||||
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsFinished" @click="toggleBatchRead" class="mx-1.5" />
|
||||
</ui-tooltip>
|
||||
@@ -158,7 +161,7 @@ export default {
|
||||
var newIsFinished = !this.selectedIsFinished
|
||||
var updateProgressPayloads = this.selectedLibraryItems.map((lid) => {
|
||||
return {
|
||||
id: lid,
|
||||
libraryItemId: lid,
|
||||
isFinished: newIsFinished
|
||||
}
|
||||
})
|
||||
@@ -210,6 +213,9 @@ export default {
|
||||
},
|
||||
setBookshelfTotalEntities(totalEntities) {
|
||||
this.totalEntities = totalEntities
|
||||
},
|
||||
batchAutoMatchClick() {
|
||||
this.$store.commit('globals/setShowBatchQuickMatchModal', true)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -16,10 +16,10 @@
|
||||
<!-- Alternate plain view -->
|
||||
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24">
|
||||
<template v-for="(shelf, index) in shelves">
|
||||
<widgets-item-slider v-if="shelf.type === 'book' || shelf.type === 'podcast'" :key="index + '.'" :items="shelf.entities" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||
<widgets-item-slider v-if="shelf.type === 'book' || shelf.type === 'podcast'" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ shelf.label }}</p>
|
||||
</widgets-item-slider>
|
||||
<widgets-episode-slider v-else-if="shelf.type === 'episode'" :key="index + '.'" :items="shelf.entities" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||
<widgets-episode-slider v-else-if="shelf.type === 'episode'" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ shelf.label }}</p>
|
||||
</widgets-episode-slider>
|
||||
<widgets-series-slider v-else-if="shelf.type === 'series'" :key="index + '.'" :items="shelf.entities" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||
@@ -33,7 +33,7 @@
|
||||
<!-- Regular bookshelf view -->
|
||||
<div v-else class="w-full">
|
||||
<template v-for="(shelf, index) in shelves">
|
||||
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" :continue-listening-shelf="shelf.id === 'continue-listening'" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,11 +70,8 @@ export default {
|
||||
libraryName() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||
},
|
||||
bookshelfView() {
|
||||
return this.$store.getters['getServerSetting']('bookshelfView')
|
||||
},
|
||||
isAlternativeBookshelfView() {
|
||||
return this.bookshelfView === this.$constants.BookshelfView.TITLES
|
||||
return this.$store.getters['getHomeBookshelfView'] === this.$constants.BookshelfView.TITLES
|
||||
},
|
||||
bookCoverWidth() {
|
||||
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
||||
@@ -82,13 +79,10 @@ export default {
|
||||
return coverSize
|
||||
},
|
||||
coverAspectRatio() {
|
||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
isCoverSquareAspectRatio() {
|
||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.isCoverSquareAspectRatio ? 1 : 1.6
|
||||
return this.coverAspectRatio == 1
|
||||
},
|
||||
sizeMultiplier() {
|
||||
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||
@@ -181,7 +175,6 @@ export default {
|
||||
}
|
||||
this.shelves = shelves
|
||||
},
|
||||
settingsUpdated(settings) {},
|
||||
scan() {
|
||||
this.$store
|
||||
.dispatch('libraries/requestLibraryScan', { libraryId: this.$store.state.libraries.currentLibraryId })
|
||||
@@ -193,6 +186,15 @@ export default {
|
||||
this.$toast.error('Failed to start scan')
|
||||
})
|
||||
},
|
||||
userUpdated(user) {
|
||||
if (user.seriesHideFromContinueListening && user.seriesHideFromContinueListening.length) {
|
||||
this.removeAllSeriesFromContinueSeries(user.seriesHideFromContinueListening)
|
||||
}
|
||||
if (user.mediaProgress.length) {
|
||||
const mediaProgressToHide = user.mediaProgress.filter((mp) => mp.hideFromContinueListening)
|
||||
this.removeItemsFromContinueListening(mediaProgressToHide)
|
||||
}
|
||||
},
|
||||
libraryItemAdded(libraryItem) {
|
||||
console.log('libraryItem added', libraryItem)
|
||||
// TODO: Check if libraryItem would be on this shelf
|
||||
@@ -250,6 +252,45 @@ export default {
|
||||
this.libraryItemUpdated(li)
|
||||
})
|
||||
},
|
||||
removeAllSeriesFromContinueSeries(seriesIds) {
|
||||
this.shelves.forEach((shelf) => {
|
||||
if (shelf.type == 'book' && shelf.id == 'continue-series') {
|
||||
// Filter out series books from continue series shelf
|
||||
shelf.entities = shelf.entities.filter((ent) => {
|
||||
if (ent.media.metadata.series && seriesIds.includes(ent.media.metadata.series.id)) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
removeItemsFromContinueListening(mediaProgressItems) {
|
||||
const continueListeningShelf = this.shelves.find((s) => s.id === 'continue-listening')
|
||||
if (continueListeningShelf) {
|
||||
if (continueListeningShelf.type === 'book') {
|
||||
continueListeningShelf.entities = continueListeningShelf.entities.filter((ent) => {
|
||||
if (mediaProgressItems.some((mp) => mp.libraryItemId === ent.id)) return false
|
||||
return true
|
||||
})
|
||||
} else if (continueListeningShelf.type === 'episode') {
|
||||
continueListeningShelf.entities = continueListeningShelf.entities.filter((ent) => {
|
||||
if (!ent.recentEpisode) return true // Should always have this here
|
||||
if (mediaProgressItems.some((mp) => mp.libraryItemId === ent.id && mp.episodeId === ent.recentEpisode.id)) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
// this.shelves.forEach((shelf) => {
|
||||
// if (shelf.id == 'continue-listening') {
|
||||
// if (shelf.type == 'book') {
|
||||
// // Filter out books from continue listening shelf
|
||||
// shelf.entities = shelf.entities.filter((ent) => {
|
||||
// if (mediaProgressItems.some(mp => mp.libraryItemId === ent.id)) return false
|
||||
// return true
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
},
|
||||
authorUpdated(author) {
|
||||
this.shelves.forEach((shelf) => {
|
||||
if (shelf.type == 'authors') {
|
||||
@@ -273,9 +314,8 @@ export default {
|
||||
})
|
||||
},
|
||||
initListeners() {
|
||||
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
|
||||
|
||||
if (this.$root.socket) {
|
||||
this.$root.socket.on('user_updated', this.userUpdated)
|
||||
this.$root.socket.on('author_updated', this.authorUpdated)
|
||||
this.$root.socket.on('author_removed', this.authorRemoved)
|
||||
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
||||
@@ -291,6 +331,7 @@ export default {
|
||||
this.$store.commit('user/removeSettingsListener', 'bookshelf')
|
||||
|
||||
if (this.$root.socket) {
|
||||
this.$root.socket.off('user_updated', this.userUpdated)
|
||||
this.$root.socket.off('author_updated', this.authorUpdated)
|
||||
this.$root.socket.off('author_removed', this.authorRemoved)
|
||||
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
||||
|
||||
@@ -4,12 +4,26 @@
|
||||
<div class="w-full h-full pt-6">
|
||||
<div v-if="shelf.type === 'book' || shelf.type === 'podcast'" class="flex items-center">
|
||||
<template v-for="(entity, index) in shelf.entities">
|
||||
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editItem" />
|
||||
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" :continue-listening-shelf="continueListeningShelf" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editItem" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'episode'" class="flex items-center">
|
||||
<template v-for="(entity, index) in shelf.entities">
|
||||
<cards-lazy-book-card :key="entity.recentEpisode.id" :ref="`shelf-episode-${entity.recentEpisode.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @editPodcast="editItem" @edit="editEpisode" />
|
||||
<cards-lazy-book-card
|
||||
:key="entity.recentEpisode.id"
|
||||
:ref="`shelf-episode-${entity.recentEpisode.id}`"
|
||||
:index="index"
|
||||
:width="bookCoverWidth"
|
||||
:height="bookCoverHeight"
|
||||
:book-cover-aspect-ratio="bookCoverAspectRatio"
|
||||
:book-mount="entity"
|
||||
:continue-listening-shelf="continueListeningShelf"
|
||||
class="relative mx-2"
|
||||
@hook:updated="updatedBookCard"
|
||||
@select="selectItem"
|
||||
@editPodcast="editItem"
|
||||
@edit="editEpisode"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'series'" class="flex items-center">
|
||||
@@ -17,6 +31,11 @@
|
||||
<cards-lazy-series-card :key="entity.name" :series-mount="entity" :height="bookCoverHeight" :width="bookCoverWidth * 2" :book-cover-aspect-ratio="bookCoverAspectRatio" class="relative mx-2" @hook:updated="updatedBookCard" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'tags'" class="flex items-center">
|
||||
<template v-for="entity in shelf.entities">
|
||||
<cards-group-card :key="entity.name" :group="entity" :height="bookCoverHeight" :width="bookCoverWidth * 2" :book-cover-aspect-ratio="bookCoverAspectRatio" class="relative mx-2" @hook:updated="updatedBookCard" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'authors'" class="flex items-center">
|
||||
<template v-for="entity in shelf.entities">
|
||||
<cards-author-card :key="entity.id" :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" />
|
||||
@@ -52,7 +71,8 @@ export default {
|
||||
},
|
||||
sizeMultiplier: Number,
|
||||
bookCoverWidth: Number,
|
||||
bookCoverAspectRatio: Number
|
||||
bookCoverAspectRatio: Number,
|
||||
continueListeningShelf: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -18,13 +18,10 @@
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-30 flex items-center justify-end md:justify-start px-2 md:px-8">
|
||||
<template v-if="page !== 'search' && page !== 'podcast-search' && !isHome">
|
||||
<template v-if="page !== 'search' && page !== 'podcast-search' && page !== 'recent-episodes' && !isHome">
|
||||
<p v-if="!selectedSeries" class="font-book hidden md:block">{{ numShowing }} {{ entityName }}</p>
|
||||
<div v-else class="items-center hidden md:flex w-full">
|
||||
<div @click="seriesBackArrow" class="rounded-full h-9 w-9 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
||||
<span class="material-icons text-2xl text-white">west</span>
|
||||
</div>
|
||||
<p class="pl-4 font-book text-lg">
|
||||
<p class="pl-2 font-book text-lg">
|
||||
{{ seriesName }}
|
||||
</p>
|
||||
<div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3">
|
||||
@@ -40,8 +37,8 @@
|
||||
<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>
|
||||
<span class="pl-2"> Mark Series {{ isSeriesFinished ? 'Not Finished' : 'Finished' }}</span></ui-btn
|
||||
>
|
||||
<span class="pl-2"> Mark Series {{ isSeriesFinished ? 'Not Finished' : 'Finished' }}</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
<div class="flex-grow hidden sm:inline-block" />
|
||||
|
||||
@@ -60,9 +57,6 @@
|
||||
<ui-btn v-if="isIssuesFilter && userCanDelete" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">Remove All {{ numShowing }} {{ entityName }}</ui-btn>
|
||||
</template>
|
||||
<template v-else-if="page === 'search'">
|
||||
<div @click="searchBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
||||
<span class="material-icons text-3xl text-white">west</span>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<p>Search results for "{{ searchQuery }}"</p>
|
||||
<div class="flex-grow" />
|
||||
@@ -215,6 +209,7 @@ export default {
|
||||
this.$toast.success('Removed library items with issues')
|
||||
this.$router.push(`/library/${this.currentLibraryId}/bookshelf`)
|
||||
this.processingIssues = false
|
||||
this.$store.dispatch('libraries/fetch', this.currentLibraryId)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove library items with issues', error)
|
||||
@@ -228,7 +223,7 @@ export default {
|
||||
this.processingSeries = true
|
||||
var updateProgressPayloads = this.seriesLibraryItemIds.map((lid) => {
|
||||
return {
|
||||
id: lid,
|
||||
libraryItemId: lid,
|
||||
isFinished: newIsFinished
|
||||
}
|
||||
})
|
||||
@@ -246,12 +241,6 @@ export default {
|
||||
this.processingSeries = false
|
||||
})
|
||||
},
|
||||
searchBackArrow() {
|
||||
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`)
|
||||
},
|
||||
seriesBackArrow() {
|
||||
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf/series`)
|
||||
},
|
||||
updateOrder() {
|
||||
this.saveSettings()
|
||||
},
|
||||
@@ -302,4 +291,4 @@ export default {
|
||||
#toolbar {
|
||||
box-shadow: 0px 8px 6px #111111aa;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -82,6 +82,11 @@ export default {
|
||||
id: 'config-log',
|
||||
title: 'Logs',
|
||||
path: '/config/log'
|
||||
},
|
||||
{
|
||||
id: 'config-notifications',
|
||||
title: 'Notifications',
|
||||
path: '/config/notifications'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -110,24 +110,20 @@ export default {
|
||||
return this.$store.getters['user/getUserSetting']('collapseSeries')
|
||||
},
|
||||
coverAspectRatio() {
|
||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||
},
|
||||
bookshelfView() {
|
||||
return this.$store.getters['getServerSetting']('bookshelfView')
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
sortingIgnorePrefix() {
|
||||
return this.$store.getters['getServerSetting']('sortingIgnorePrefix')
|
||||
},
|
||||
isCoverSquareAspectRatio() {
|
||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
||||
return this.coverAspectRatio == 1
|
||||
},
|
||||
bookshelfView() {
|
||||
return this.$store.getters['getBookshelfView']
|
||||
},
|
||||
isAlternativeBookshelfView() {
|
||||
// if (!this.isEntityBook) return false // Only used for bookshelf showing books
|
||||
return this.bookshelfView === this.$constants.BookshelfView.TITLES
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.isCoverSquareAspectRatio ? 1 : 1.6
|
||||
},
|
||||
hasFilter() {
|
||||
return this.filterBy && this.filterBy !== 'all'
|
||||
},
|
||||
|
||||
@@ -14,6 +14,14 @@
|
||||
<div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastLatestPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<span class="material-icons">format_list_bulleted</span>
|
||||
|
||||
<p class="font-book pt-1" style="font-size: 0.9rem">Latest</p>
|
||||
|
||||
<div v-show="isPodcastLatestPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</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 border-opacity-70 hover:bg-primary cursor-pointer relative" :class="showLibrary ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-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" />
|
||||
@@ -80,7 +88,7 @@
|
||||
<p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p>
|
||||
</div>
|
||||
|
||||
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version"/>
|
||||
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -123,6 +131,9 @@ export default {
|
||||
isPodcastSearchPage() {
|
||||
return this.$route.name === 'library-library-podcast-search'
|
||||
},
|
||||
isPodcastLatestPage() {
|
||||
return this.$route.name === 'library-library-podcast-latest'
|
||||
},
|
||||
homePage() {
|
||||
return this.$route.name === 'library-library'
|
||||
},
|
||||
@@ -165,9 +176,9 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickChangelog(){
|
||||
clickChangelog() {
|
||||
this.showChangelogModal = true
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-1 md:pb-4 pt-2">
|
||||
<div id="videoDock" />
|
||||
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-1 sm:left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
|
||||
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
|
||||
</nuxt-link>
|
||||
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : 'pl-20 sm:pl-24'">
|
||||
<div>
|
||||
@@ -44,11 +44,14 @@
|
||||
@close="closePlayer"
|
||||
@showBookmarks="showBookmarks"
|
||||
@showSleepTimer="showSleepTimerModal = true"
|
||||
@showPlayerQueueItems="showPlayerQueueItemsModal = true"
|
||||
/>
|
||||
|
||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
|
||||
|
||||
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-time="sleepTimerTime" :remaining="sleepTimerRemaining" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
||||
|
||||
<modals-player-queue-items-modal v-model="showPlayerQueueItemsModal" :library-item-id="libraryItemId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -66,6 +69,7 @@ export default {
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
showSleepTimerModal: false,
|
||||
showPlayerQueueItemsModal: false,
|
||||
sleepTimerSet: false,
|
||||
sleepTimerTime: 0,
|
||||
sleepTimerRemaining: 0,
|
||||
@@ -77,16 +81,13 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
coverAspectRatio() {
|
||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
bookCoverWidth() {
|
||||
return 88
|
||||
},
|
||||
bookCoverPosTop() {
|
||||
if (this.bookCoverAspectRatio === 1) return -10
|
||||
if (this.coverAspectRatio == 1) return -10
|
||||
return -64
|
||||
},
|
||||
cover() {
|
||||
@@ -141,9 +142,39 @@ export default {
|
||||
podcastAuthor() {
|
||||
if (!this.isPodcast) return null
|
||||
return this.mediaMetadata.author || 'Unknown'
|
||||
},
|
||||
playerQueueItems() {
|
||||
return this.$store.state.playerQueueItems || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
mediaFinished(libraryItemId, episodeId) {
|
||||
// Play next item in queue
|
||||
if (!this.playerQueueItems.length || !this.$store.state.playerQueueAutoPlay) {
|
||||
// TODO: Set media finished flag so play button will play next queue item
|
||||
return
|
||||
}
|
||||
var currentQueueIndex = this.playerQueueItems.findIndex((i) => {
|
||||
if (episodeId) return i.libraryItemId === libraryItemId && i.episodeId === episodeId
|
||||
return i.libraryItemId === libraryItemId
|
||||
})
|
||||
if (currentQueueIndex < 0) {
|
||||
console.error('Media finished not found in queue', this.playerQueueItems)
|
||||
return
|
||||
}
|
||||
if (currentQueueIndex === this.playerQueueItems.length - 1) {
|
||||
console.log('Finished last item in queue')
|
||||
return
|
||||
}
|
||||
const nextItemInQueue = this.playerQueueItems[currentQueueIndex + 1]
|
||||
if (nextItemInQueue) {
|
||||
this.playLibraryItem({
|
||||
libraryItemId: nextItemInQueue.libraryItemId,
|
||||
episodeId: nextItemInQueue.episodeId || null,
|
||||
queueItems: this.playerQueueItems
|
||||
})
|
||||
}
|
||||
},
|
||||
setPlaying(isPlaying) {
|
||||
this.isPlaying = isPlaying
|
||||
this.$store.commit('setIsPlaying', isPlaying)
|
||||
@@ -316,6 +347,7 @@ export default {
|
||||
}
|
||||
},
|
||||
sessionOpen(session) {
|
||||
// For opening session on init (temporarily unused)
|
||||
this.$store.commit('setMediaPlaying', {
|
||||
libraryItem: session.libraryItem,
|
||||
episodeId: session.episodeId
|
||||
@@ -367,7 +399,7 @@ export default {
|
||||
if (payload.startTime !== null && !isNaN(payload.startTime)) {
|
||||
this.seek(payload.startTime)
|
||||
} else {
|
||||
this.playerHandler.play()
|
||||
this.playerHandler.play()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -379,7 +411,8 @@ export default {
|
||||
if (!libraryItem) return
|
||||
this.$store.commit('setMediaPlaying', {
|
||||
libraryItem,
|
||||
episodeId
|
||||
episodeId,
|
||||
queueItems: payload.queueItems || []
|
||||
})
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
|
||||
|
||||
@@ -93,12 +93,12 @@ export default {
|
||||
return null
|
||||
})
|
||||
if (!response) {
|
||||
this.$toast.error('Author not found')
|
||||
this.$toast.error(`Author ${this.name} not found`)
|
||||
} else if (response.updated) {
|
||||
if (response.author.imagePath) this.$toast.success('Author was updated')
|
||||
else this.$toast.success('Author was updated (no image found)')
|
||||
if (response.author.imagePath) this.$toast.success(`Author ${response.author.name} was updated`)
|
||||
else this.$toast.success(`Author ${response.author.name} was updated (no image found)`)
|
||||
} else {
|
||||
this.$toast.info('No updates were made for Author')
|
||||
this.$toast.info(`No updates were made for Author ${response.author.name}`)
|
||||
}
|
||||
this.searching = false
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<div class="min-w-12 max-w-12 md:min-w-20 md:max-w-20">
|
||||
<div class="w-full bg-primary">
|
||||
<img v-if="selectedCover" :src="selectedCover" class="h-full w-full object-contain" />
|
||||
<div v-else class="w-12 h-12 md:w-20 md:h-20 bg-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isPodcast" class="px-2 md:px-4 flex-grow">
|
||||
@@ -12,13 +13,13 @@
|
||||
<div class="flex-grow" />
|
||||
<p class="text-sm md:text-base">{{ book.publishedYear }}</p>
|
||||
</div>
|
||||
<p class="text-gray-300 text-xs md:text-sm">by {{ book.author }}</p>
|
||||
<p v-if="book.author" class="text-gray-300 text-xs md:text-sm">by {{ book.author }}</p>
|
||||
<p v-if="book.narrator" class="text-gray-400 text-xs">Narrated by {{ book.narrator }}</p>
|
||||
<p v-if="book.duration" class="text-gray-400 text-xs">Runtime: {{ $elapsedPrettyExtended(book.duration) }}</p>
|
||||
<p v-if="book.duration" class="text-gray-400 text-xs">Runtime: {{ $elapsedPrettyExtended(book.duration * 60) }}</p>
|
||||
<div v-if="book.series && book.series.length" class="flex py-1 -mx-1">
|
||||
<div v-for="(series, index) in book.series" :key="index" class="bg-white bg-opacity-10 rounded-full px-1 py-0.5 mx-1">
|
||||
<p class="leading-3 text-xs text-gray-400">
|
||||
{{ series.series }}<span v-if="series.volumeNumber"> #{{ series.volumeNumber }}</span>
|
||||
{{ series.series }}<span v-if="series.sequence"> #{{ series.sequence }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div class="rounded-sm h-full relative" :style="{ padding: `0px ${paddingX}px` }" @mouseover="mouseoverCard" @mouseleave="mouseleaveCard" @click="clickCard">
|
||||
<div class="rounded-sm h-full relative" :style="{ width: width + 'px', height: height + 'px' }" @mouseover="mouseoverCard" @mouseleave="mouseleaveCard" @click="clickCard">
|
||||
<nuxt-link :to="groupTo" class="cursor-pointer">
|
||||
<div class="w-full h-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'" :style="{ height: coverHeight + 'px', width: coverWidth + 'px' }">
|
||||
<covers-group-cover ref="groupcover" :id="seriesId" :name="groupName" :group-to="groupTo" :type="groupType" :book-items="bookItems" :width="coverWidth" :height="coverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div class="w-full h-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'">
|
||||
<covers-group-cover ref="groupcover" :id="groupEncode" :name="groupName" :type="groupType" :book-items="bookItems" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
|
||||
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity z-30" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
|
||||
<p class="font-book" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ groupName }}</p>
|
||||
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ groupName }}</p>
|
||||
</div>
|
||||
|
||||
<div class="absolute top-2 right-2 w-7 h-7 rounded-lg bg-black bg-opacity-90 text-gray-300 box-shadow-book flex items-center justify-center border border-white border-opacity-25 pointer-events-none z-40">
|
||||
<p class="font-book text-xl">{{ bookItems.length }}</p>
|
||||
</div>
|
||||
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ bookItems.length }}</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
|
||||
<div v-if="!isCategorized" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto bottom-0 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, coverWidth) + 'px' }">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${1 * sizeMultiplier}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ groupName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -31,11 +23,8 @@ export default {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 120
|
||||
},
|
||||
isCategorized: Boolean,
|
||||
width: Number,
|
||||
height: Number,
|
||||
bookCoverAspectRatio: Number
|
||||
},
|
||||
data() {
|
||||
@@ -43,23 +32,7 @@ export default {
|
||||
isHovering: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
width(newVal) {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.groupcover) {
|
||||
this.$refs.groupcover.init()
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
seriesId() {
|
||||
return this.groupEncode
|
||||
},
|
||||
labelFontSize() {
|
||||
if (this.coverWidth < 160) return 0.75
|
||||
return 0.875
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
@@ -70,29 +43,11 @@ export default {
|
||||
return this._group.type
|
||||
},
|
||||
groupTo() {
|
||||
if (this.groupType === 'series') {
|
||||
return `/library/${this.currentLibraryId}/series/${this._group.id}`
|
||||
} else if (this.groupType === 'collection') {
|
||||
return `/collection/${this._group.id}`
|
||||
} else {
|
||||
return `/library/${this.currentLibraryId}/bookshelf?filter=tags.${this.groupEncode}`
|
||||
}
|
||||
},
|
||||
squareAspectRatio() {
|
||||
return this.bookCoverAspectRatio === 1
|
||||
},
|
||||
coverWidth() {
|
||||
return this.width * 2
|
||||
},
|
||||
coverHeight() {
|
||||
return this.width * this.bookCoverAspectRatio
|
||||
return `/library/${this.currentLibraryId}/bookshelf?filter=${this.filter}`
|
||||
},
|
||||
sizeMultiplier() {
|
||||
var baseSize = this.squareAspectRatio ? 192 : 120
|
||||
return this.width / baseSize
|
||||
},
|
||||
paddingX() {
|
||||
return 16 * this.sizeMultiplier
|
||||
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
||||
return this.width / 240
|
||||
},
|
||||
bookItems() {
|
||||
return this._group.books || []
|
||||
|
||||
@@ -31,7 +31,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
coverWidth() {
|
||||
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<div v-if="!booksInSeries && (!isPodcast || episodeProgress)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
|
||||
<!-- Overlay is not shown if collapsing series in library -->
|
||||
<div v-show="!booksInSeries && libraryItem && (isHovering || isSelectionMode || isMoreMenuOpen)" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded hidden md:block" :class="overlayWrapperClasslist">
|
||||
<div v-show="!booksInSeries && libraryItem && (isHovering || isSelectionMode || isMoreMenuOpen) && !processing" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded hidden md:block" :class="overlayWrapperClasslist">
|
||||
<div v-show="showPlayButton" class="h-full flex items-center justify-center pointer-events-none">
|
||||
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="play">
|
||||
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
|
||||
@@ -66,6 +66,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processing/loading spinner overlay -->
|
||||
<div v-if="processing" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 rounded flex items-center justify-center">
|
||||
<widgets-loading-spinner size="la-lg" />
|
||||
</div>
|
||||
|
||||
<!-- Series name overlay -->
|
||||
<div v-if="booksInSeries && libraryItem && isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-60 rounded flex items-center justify-center" :style="{ padding: sizeMultiplier + 'rem' }">
|
||||
<p class="text-gray-200 text-center" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">{{ series }}</p>
|
||||
@@ -88,7 +93,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Podcast Episode # -->
|
||||
<div v-if="recentEpisodeNumber && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<div v-if="recentEpisodeNumber && !isHovering && !isSelectionMode && !processing" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">Episode #{{ recentEpisodeNumber }}</p>
|
||||
</div>
|
||||
|
||||
@@ -123,16 +128,16 @@ export default {
|
||||
},
|
||||
orderBy: String,
|
||||
filterBy: String,
|
||||
sortingIgnorePrefix: Boolean
|
||||
sortingIgnorePrefix: Boolean,
|
||||
continueListeningShelf: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isHovering: false,
|
||||
isMoreMenuOpen: false,
|
||||
isProcessingReadUpdate: false,
|
||||
processing: false,
|
||||
libraryItem: null,
|
||||
imageReady: false,
|
||||
rescanning: false,
|
||||
selected: false,
|
||||
isSelectionMode: false,
|
||||
showCoverBg: false
|
||||
@@ -177,7 +182,8 @@ export default {
|
||||
return this.mediaType === 'podcast'
|
||||
},
|
||||
placeholderUrl() {
|
||||
return '/book_placeholder.jpg'
|
||||
const config = this.$config || this.$nuxt.$config
|
||||
return `${config.routerBasePath}/book_placeholder.jpg`
|
||||
},
|
||||
bookCoverSrc() {
|
||||
return this.store.getters['globals/getLibraryItemCoverSrc'](this._libraryItem, this.placeholderUrl)
|
||||
@@ -364,7 +370,7 @@ export default {
|
||||
},
|
||||
moreMenuItems() {
|
||||
if (this.recentEpisode) {
|
||||
return [
|
||||
const items = [
|
||||
{
|
||||
func: 'editPodcast',
|
||||
text: 'Edit Podcast'
|
||||
@@ -374,6 +380,13 @@ export default {
|
||||
text: `Mark as ${this.itemIsFinished ? 'Not Finished' : 'Finished'}`
|
||||
}
|
||||
]
|
||||
if (this.continueListeningShelf) {
|
||||
items.push({
|
||||
func: 'removeFromContinueListening',
|
||||
text: 'Remove from Continue Listening'
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
var items = []
|
||||
@@ -382,12 +395,14 @@ export default {
|
||||
{
|
||||
func: 'toggleFinished',
|
||||
text: `Mark as ${this.itemIsFinished ? 'Not Finished' : 'Finished'}`
|
||||
},
|
||||
{
|
||||
func: 'openCollections',
|
||||
text: 'Add to Collection'
|
||||
}
|
||||
]
|
||||
if (this.userCanUpdate) {
|
||||
items.push({
|
||||
func: 'openCollections',
|
||||
text: 'Add to Collection'
|
||||
})
|
||||
}
|
||||
}
|
||||
if (this.userCanUpdate) {
|
||||
items.push({
|
||||
@@ -405,6 +420,18 @@ export default {
|
||||
text: 'Re-Scan'
|
||||
})
|
||||
}
|
||||
if (this.series && this.bookMount) {
|
||||
items.push({
|
||||
func: 'removeSeriesFromContinueListening',
|
||||
text: 'Remove Series from Continue Series'
|
||||
})
|
||||
}
|
||||
if (this.continueListeningShelf) {
|
||||
items.push({
|
||||
func: 'removeFromContinueListening',
|
||||
text: 'Remove from Continue Listening'
|
||||
})
|
||||
}
|
||||
return items
|
||||
},
|
||||
_socket() {
|
||||
@@ -490,6 +517,7 @@ export default {
|
||||
this.libraryItem = libraryItem
|
||||
},
|
||||
clickCard(e) {
|
||||
if (this.processing) return
|
||||
if (this.isSelectionMode) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
@@ -526,7 +554,7 @@ export default {
|
||||
var updatePayload = {
|
||||
isFinished: !this.itemIsFinished
|
||||
}
|
||||
this.isProcessingReadUpdate = true
|
||||
this.processing = true
|
||||
|
||||
var apiEndpoint = `/api/me/progress/${this.libraryItemId}`
|
||||
if (this.recentEpisode) apiEndpoint += `/${this.recentEpisode.id}`
|
||||
@@ -536,12 +564,12 @@ export default {
|
||||
axios
|
||||
.$patch(apiEndpoint, updatePayload)
|
||||
.then(() => {
|
||||
this.isProcessingReadUpdate = false
|
||||
this.processing = false
|
||||
toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.isProcessingReadUpdate = false
|
||||
this.processing = false
|
||||
toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
},
|
||||
@@ -549,11 +577,12 @@ export default {
|
||||
this.$emit('editPodcast', this.libraryItem)
|
||||
},
|
||||
rescan() {
|
||||
this.rescanning = true
|
||||
this.$axios
|
||||
if (this.processing) return
|
||||
const axios = this.$axios || this.$nuxt.$axios
|
||||
this.processing = true
|
||||
axios
|
||||
.$get(`/api/items/${this.libraryItemId}/scan`)
|
||||
.then((data) => {
|
||||
this.rescanning = false
|
||||
var result = data.result
|
||||
if (!result) {
|
||||
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
|
||||
@@ -568,7 +597,9 @@ export default {
|
||||
.catch((error) => {
|
||||
console.error('Failed to scan library item', error)
|
||||
this.$toast.error('Failed to scan library item')
|
||||
this.rescanning = false
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
showEditModalFiles() {
|
||||
@@ -579,6 +610,40 @@ export default {
|
||||
// More menu func
|
||||
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' })
|
||||
},
|
||||
removeSeriesFromContinueListening() {
|
||||
const axios = this.$axios || this.$nuxt.$axios
|
||||
this.processing = true
|
||||
axios
|
||||
.$get(`/api/me/series/${this.series.id}/remove-from-continue-listening`)
|
||||
.then((data) => {
|
||||
console.log('User updated', data)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove series from home', error)
|
||||
this.$toast.error('Failed to update user')
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
removeFromContinueListening() {
|
||||
if (!this.userProgress) return
|
||||
|
||||
const axios = this.$axios || this.$nuxt.$axios
|
||||
this.processing = true
|
||||
axios
|
||||
.$get(`/api/me/progress/${this.userProgress.id}/remove-from-continue-listening`)
|
||||
.then((data) => {
|
||||
console.log('User updated', data)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to hide item from home', error)
|
||||
this.$toast.error('Failed to update user')
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
openCollections() {
|
||||
this.store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||
this.store.commit('globals/setShowUserCollectionsModal', true)
|
||||
@@ -647,11 +712,50 @@ export default {
|
||||
this.selected = !this.selected
|
||||
this.$emit('select', this.libraryItem)
|
||||
},
|
||||
play() {
|
||||
async play() {
|
||||
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||
|
||||
const queueItems = []
|
||||
// Podcast episode load queue items
|
||||
if (this.recentEpisode) {
|
||||
const axios = this.$axios || this.$nuxt.$axios
|
||||
this.processing = true
|
||||
const fullLibraryItem = await axios.$get(`/api/items/${this.libraryItemId}`).catch((err) => {
|
||||
console.error('Failed to fetch library item', err)
|
||||
return null
|
||||
})
|
||||
this.processing = false
|
||||
|
||||
if (fullLibraryItem && fullLibraryItem.media.episodes) {
|
||||
const episodes = fullLibraryItem.media.episodes || []
|
||||
// Sort from least recent to most recent
|
||||
episodes.sort((a, b) => String(a.publishedAt).localeCompare(String(b.publishedAt), undefined, { numeric: true, sensitivity: 'base' }))
|
||||
|
||||
const episodeIndex = episodes.findIndex((ep) => ep.id === this.recentEpisode.id)
|
||||
if (episodeIndex >= 0) {
|
||||
for (let i = episodeIndex; i < episodes.length; i++) {
|
||||
const episode = episodes[i]
|
||||
const podcastProgress = this.store.getters['user/getUserMediaProgress'](this.libraryItemId, episode.id)
|
||||
if (!podcastProgress || !podcastProgress.isFinished) {
|
||||
queueItems.push({
|
||||
libraryItemId: this.libraryItemId,
|
||||
episodeId: episode.id,
|
||||
title: episode.title,
|
||||
subtitle: this.mediaMetadata.title,
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
|
||||
duration: episode.audioFile.duration || null,
|
||||
coverPath: this.media.coverPath || null
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eventBus.$emit('play-item', {
|
||||
libraryItemId: this.libraryItemId,
|
||||
episodeId: this.recentEpisode ? this.recentEpisode.id : null
|
||||
episodeId: this.recentEpisode ? this.recentEpisode.id : null,
|
||||
queueItems
|
||||
})
|
||||
},
|
||||
mouseover() {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||
<covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
<div v-show="isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
|
||||
<div v-show="isHovering && userCanUpdate" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
|
||||
<div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickEdit">
|
||||
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
|
||||
</div>
|
||||
@@ -30,7 +30,12 @@ export default {
|
||||
bookshelfView: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
collectionMount: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
isTag: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -64,6 +69,9 @@ export default {
|
||||
isAlternativeBookshelfView() {
|
||||
const constants = this.$constants || this.$nuxt.$constants
|
||||
return this.bookshelfView == constants.BookshelfView.TITLES
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.store.getters['user/getUserCanUpdate']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -99,6 +107,10 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {
|
||||
if (this.collectionMount) {
|
||||
this.setEntity(this.collectionMount)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -2,7 +2,7 @@
|
||||
<div ref="card" :id="`series-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
|
||||
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="displayTitle" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" :group-to="seriesBooksRoute" />
|
||||
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="displayTitle" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
|
||||
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ books.length }}</div>
|
||||
|
||||
174
client/components/cards/NotificationCard.vue
Normal file
174
client/components/cards/NotificationCard.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div class="w-full border border-white border-opacity-10 rounded-xl p-4 my-2" :class="notification.enabled ? 'bg-primary bg-opacity-25' : 'bg-error bg-opacity-5'">
|
||||
<div class="flex flex-wrap items-center">
|
||||
<p class="text-base md:text-lg font-semibold pr-4">{{ eventName }}</p>
|
||||
<div class="flex-grow" />
|
||||
|
||||
<ui-btn v-if="eventName === 'onTest' && notification.enabled" :loading="testing" small class="mr-2" @click.stop="fireTestEventAndSucceed">Fire onTest Event</ui-btn>
|
||||
<ui-btn v-if="eventName === 'onTest' && notification.enabled" :loading="testing" small class="mr-2" color="red-600" @click.stop="fireTestEventAndFail">Fire & Fail</ui-btn>
|
||||
<!-- <ui-btn v-if="eventName === 'onTest' && notification.enabled" :loading="testing" small class="mr-2" @click.stop="rapidFireTestEvents">Rapid Fire</ui-btn> -->
|
||||
<ui-btn v-else-if="notification.enabled" :loading="sendingTest" small class="mr-2" @click.stop="sendTestClick">Test</ui-btn>
|
||||
<ui-btn v-else :loading="enabling" small color="success" class="mr-2" @click="enableNotification">Enable</ui-btn>
|
||||
|
||||
<ui-icon-btn :size="7" icon-font-size="1.1rem" icon="edit" class="mr-2" @click="editNotification" />
|
||||
<ui-icon-btn bg-color="error" :size="7" icon-font-size="1.2rem" icon="delete" @click="deleteNotificationClick" />
|
||||
</div>
|
||||
<div class="pt-4">
|
||||
<p class="text-gray-300 text-xs md:text-sm mb-2">{{ notification.urls.join(', ') }}</p>
|
||||
|
||||
<p v-if="lastFiredAt && lastAttemptFailed" class="text-red-300 text-xs">Last attempt failed {{ $dateDistanceFromNow(lastFiredAt) }} ({{ numConsecutiveFailedAttempts }} attempt{{ numConsecutiveFailedAttempts === 1 ? '' : 's' }})</p>
|
||||
<p v-else-if="lastFiredAt" class="text-gray-400 text-xs">Last fired {{ $dateDistanceFromNow(lastFiredAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
notification: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
sendingTest: false,
|
||||
enabling: false,
|
||||
deleting: false,
|
||||
testing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
eventName() {
|
||||
return this.notification ? this.notification.eventName : null
|
||||
},
|
||||
lastFiredAt() {
|
||||
return this.notification ? this.notification.lastFiredAt : null
|
||||
},
|
||||
lastAttemptFailed() {
|
||||
return this.notification ? this.notification.lastAttemptFailed : null
|
||||
},
|
||||
numConsecutiveFailedAttempts() {
|
||||
return this.notification ? this.notification.numConsecutiveFailedAttempts : null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// For testing using the onTest event
|
||||
fireTestEventAndFail() {
|
||||
this.fireTestEvent(true)
|
||||
},
|
||||
fireTestEventAndSucceed() {
|
||||
this.fireTestEvent(false)
|
||||
},
|
||||
fireTestEvent(intentionallyFail = false) {
|
||||
this.testing = true
|
||||
this.$axios
|
||||
.$get(`/api/notifications/test?fail=${intentionallyFail ? 1 : 0}`)
|
||||
.then(() => {
|
||||
this.$toast.success('Triggered onTest Event')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
const errorMsg = error.response ? error.response.data : null
|
||||
this.$toast.error(`Failed: ${errorMsg}` || 'Failed to trigger onTest event')
|
||||
})
|
||||
.finally(() => {
|
||||
this.testing = false
|
||||
})
|
||||
},
|
||||
rapidFireTestEvents() {
|
||||
this.testing = true
|
||||
var numFired = 0
|
||||
var interval = setInterval(() => {
|
||||
this.fireTestEvent()
|
||||
numFired++
|
||||
if (numFired > 25) {
|
||||
this.testing = false
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, 100)
|
||||
},
|
||||
// End testing functions
|
||||
sendTestClick() {
|
||||
const payload = {
|
||||
message: `Trigger this notification with test data?`,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.sendTest()
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
sendTest() {
|
||||
this.sendingTest = true
|
||||
this.$axios
|
||||
.$get(`/api/notifications/${this.notification.id}/test`)
|
||||
.then(() => {
|
||||
this.$toast.success('Triggered test notification')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
const errorMsg = error.response ? error.response.data : null
|
||||
this.$toast.error(`Failed: ${errorMsg}` || 'Failed to trigger test notification')
|
||||
})
|
||||
.finally(() => {
|
||||
this.sendingTest = false
|
||||
})
|
||||
},
|
||||
enableNotification() {
|
||||
this.enabling = true
|
||||
const payload = {
|
||||
id: this.notification.id,
|
||||
enabled: true
|
||||
}
|
||||
this.$axios
|
||||
.$patch(`/api/notifications/${this.notification.id}`, payload)
|
||||
.then((updatedSettings) => {
|
||||
this.$emit('update', updatedSettings)
|
||||
this.$toast.success('Notification enabled')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update notification', error)
|
||||
this.$toast.error('Failed to update notification')
|
||||
})
|
||||
.finally(() => {
|
||||
this.enabling = false
|
||||
})
|
||||
},
|
||||
deleteNotificationClick() {
|
||||
const payload = {
|
||||
message: `Are you sure you want to delete this notification?`,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.deleteNotification()
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
deleteNotification() {
|
||||
this.deleting = true
|
||||
this.$axios
|
||||
.$delete(`/api/notifications/${this.notification.id}`)
|
||||
.then((updatedSettings) => {
|
||||
this.$emit('update', updatedSettings)
|
||||
this.$toast.success('Deleted notification')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Failed to delete notification')
|
||||
})
|
||||
.finally(() => {
|
||||
this.deleting = false
|
||||
})
|
||||
},
|
||||
editNotification() {
|
||||
this.$emit('edit', this.notification)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -24,7 +24,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
name() {
|
||||
return this.series.name
|
||||
|
||||
@@ -44,6 +44,10 @@ export default {
|
||||
text: 'Author (Last, First)',
|
||||
value: 'media.metadata.authorNameLF'
|
||||
},
|
||||
{
|
||||
text: 'Published Year',
|
||||
value: 'media.metadata.publishedYear'
|
||||
},
|
||||
{
|
||||
text: 'Added At',
|
||||
value: 'addedAt'
|
||||
|
||||
@@ -37,6 +37,11 @@ export default {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
try {
|
||||
localStorage.setItem("volume", val);
|
||||
} catch(error) {
|
||||
console.error('Failed to store volume', err)
|
||||
}
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
@@ -141,6 +146,10 @@ export default {
|
||||
if (this.value === 0) {
|
||||
this.isMute = true
|
||||
}
|
||||
const storageVolume = localStorage.getItem("volume")
|
||||
if (storageVolume) {
|
||||
this.volume = parseFloat(storageVolume)
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('mousewheel', this.scroll)
|
||||
|
||||
@@ -58,7 +58,7 @@ export default {
|
||||
if (!this.imagePath) return null
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
// Testing
|
||||
return `http://localhost:3333/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
|
||||
return `http://localhost:3333${this.$config.routerBasePath}/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
|
||||
}
|
||||
return `/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ export default {
|
||||
return this.author
|
||||
},
|
||||
placeholderUrl() {
|
||||
return '/book_placeholder.jpg'
|
||||
return `${this.$config.routerBasePath}/book_placeholder.jpg`
|
||||
},
|
||||
fullCoverUrl() {
|
||||
if (!this.libraryItem) return null
|
||||
|
||||
@@ -17,7 +17,6 @@ export default {
|
||||
},
|
||||
width: Number,
|
||||
height: Number,
|
||||
groupTo: String,
|
||||
bookCoverAspectRatio: Number
|
||||
},
|
||||
data() {
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
<template>
|
||||
<div ref="container" @mouseover="mouseover" @mouseleave="mouseleave" class="relative">
|
||||
<covers-book-cover :width="24" :audiobook="audiobook" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isHovering: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
placeholderUrl() {
|
||||
return '/book_placeholder.jpg'
|
||||
},
|
||||
fullCoverUrl() {
|
||||
return this.$store.getters['globals/getLibraryItemCoverSrc'](this.audiobook, this.placeholderUrl)
|
||||
},
|
||||
hasCover() {
|
||||
return !!this.audiobook.book.cover
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
mouseover() {
|
||||
this.isHovering = true
|
||||
},
|
||||
mouseleave() {
|
||||
this.isHovering = false
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -18,7 +18,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!imageFailed" class="absolute -bottom-5 left-0 right-0 mx-auto text-xs text-gray-300 text-center">{{ resolution }}</p>
|
||||
<p v-if="!imageFailed && showResolution" class="absolute -bottom-5 left-0 right-0 mx-auto text-xs text-gray-300 text-center">{{ resolution }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -31,7 +31,11 @@ export default {
|
||||
default: 120
|
||||
},
|
||||
showOpenNewTab: Boolean,
|
||||
bookCoverAspectRatio: Number
|
||||
bookCoverAspectRatio: Number,
|
||||
showResolution: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
91
client/components/modals/BackupScheduleModal.vue
Normal file
91
client/components/modals/BackupScheduleModal.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="backup-scheduler" :width="700" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">Set Backup Schedule</p>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="show && newCronExpression" class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||
<widgets-cron-expression-builder ref="expressionBuilder" v-model="newCronExpression" @input="expressionUpdated" />
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<ui-btn :disabled="!isUpdated" @click="submit">{{ isUpdated ? 'Save Backup Schedule' : 'No update necessary' }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
cronExpression: {
|
||||
type: String,
|
||||
default: '* * * * *'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
newCronExpression: null,
|
||||
isUpdated: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
expressionUpdated() {
|
||||
this.isUpdated = this.newCronExpression !== this.cronExpression
|
||||
},
|
||||
init() {
|
||||
this.newCronExpression = this.cronExpression
|
||||
this.isUpdated = false
|
||||
},
|
||||
submit() {
|
||||
// If custom expression input is focused then unfocus it instead of submitting
|
||||
if (this.$refs.expressionBuilder && this.$refs.expressionBuilder.checkBlurExpressionInput) {
|
||||
if (this.$refs.expressionBuilder.checkBlurExpressionInput()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
|
||||
var updatePayload = {
|
||||
backupSchedule: this.newCronExpression
|
||||
}
|
||||
this.$store
|
||||
.dispatch('updateServerSettings', updatePayload)
|
||||
.then((success) => {
|
||||
console.log('Updated Server Settings', success)
|
||||
this.processing = false
|
||||
this.show = false
|
||||
this.$emit('update:cronExpression', this.newCronExpression)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
141
client/components/modals/BatchQuickMatchModel.vue
Normal file
141
client/components/modals/BatchQuickMatchModel.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="batchQuickMatch" :processing="processing" :width="500" :height="'unset'">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<div v-if="show" class="w-full h-full">
|
||||
<div class="py-4 px-4">
|
||||
<h1 class="text-2xl">Quick Match {{ selectedBookIds.length }} Books</h1>
|
||||
</div>
|
||||
|
||||
<div class="w-full overflow-y-auto overflow-x-hidden max-h-96">
|
||||
<div class="flex px-8 items-center py-2">
|
||||
<p class="pr-4">Provider</p>
|
||||
<ui-dropdown v-model="options.provider" :items="providers" small />
|
||||
</div>
|
||||
<p class="text-base px-8 py-2">Quick Match will attempt to add missing covers and metadata for the selected books. Enable the options below to allow Quick Match to overwrite existing covers and/or metadata.</p>
|
||||
<div class="flex px-8 items-end py-2">
|
||||
<ui-toggle-switch v-model="options.overrideCover"/>
|
||||
<ui-tooltip :text="tooltips.updateCovers">
|
||||
<p class="pl-4">
|
||||
Update Covers
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div class="flex px-8 items-end py-2">
|
||||
<ui-toggle-switch v-model="options.overrideDetails"/>
|
||||
<ui-tooltip :text="tooltips.updateDetails">
|
||||
<p class="pl-4">
|
||||
Update Details
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div class="mt-4 py-4 border-b border-white border-opacity-10 text-white text-opacity-80 border-t border-white border-opacity-5">
|
||||
<div class="flex items-center px-4">
|
||||
<ui-btn type="button" @click="show = false">Cancel</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" @click="doBatchQuickMatch">Continue</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
lastUsedLibrary: undefined,
|
||||
options: {
|
||||
provider: undefined,
|
||||
overrideDetails: true,
|
||||
overrideCover: true,
|
||||
overrideDefaults: true
|
||||
},
|
||||
tooltips: {
|
||||
updateCovers: 'Allow overwriting of existing covers for the selected books when a match is located.',
|
||||
updateDetails: 'Allow overwriting of existing details for the selected books when a match is located.'
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.$store.state.globals.showBatchQuickMatchModal
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('globals/setShowBatchQuickMatchModal', val)
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return `${this.selectedBookIds.length} Items Selected`
|
||||
},
|
||||
showBatchQuickMatchModal() {
|
||||
return this.$store.state.globals.showBatchQuickMatchModal
|
||||
},
|
||||
selectedBookIds() {
|
||||
return this.$store.state.selectedLibraryItems || []
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
providers() {
|
||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||
return this.$store.state.scanners.providers
|
||||
},
|
||||
libraryProvider() {
|
||||
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
// 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.options.lastUsedLibrary != this.currentLibraryId)) {
|
||||
this.options.lastUsedLibrary = this.currentLibraryId
|
||||
this.options.provider = this.libraryProvider
|
||||
}
|
||||
},
|
||||
doBatchQuickMatch() {
|
||||
if (!this.selectedBookIds.length) return
|
||||
if (this.processing) return
|
||||
|
||||
this.processing = true
|
||||
this.$store.commit('setProcessingBatch', true)
|
||||
this.$axios
|
||||
.$post(`/api/items/batch/quickmatch`, {
|
||||
options: this.options,
|
||||
libraryItemIds: this.selectedBookIds
|
||||
})
|
||||
.then(() => {
|
||||
this.$toast.info('Batch quick match of ' + this.selectedBookIds.length + ' books started!')
|
||||
}).catch((error) => {
|
||||
this.$toast.error('Batch quick match failed')
|
||||
console.error('Failed to batch quick match', error)
|
||||
}).finally(() => {
|
||||
this.processing = false
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
this.show = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 right-0 w-full py-2 px-4 flex">
|
||||
<ui-btn small color="error" type="button" @click.stop="removeClick">Remove</ui-btn>
|
||||
<ui-btn v-if="userCanDelete" small color="error" type="button" @click.stop="removeClick">Remove</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" type="submit">Save</ui-btn>
|
||||
</div>
|
||||
@@ -75,7 +75,7 @@ export default {
|
||||
}
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
collection() {
|
||||
return this.$store.state.globals.selectedCollection || {}
|
||||
@@ -85,6 +85,9 @@ export default {
|
||||
},
|
||||
books() {
|
||||
return this.collection.books || []
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="listening-session-modal" :width="700" :height="'unset'">
|
||||
<modals-modal v-model="show" name="listening-session-modal" :processing="processing" :width="700" :height="'unset'">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">Session {{ _session.id }}</p>
|
||||
@@ -96,6 +96,10 @@
|
||||
<p v-if="deviceInfo.deviceType" class="mb-1">Type: {{ deviceInfo.deviceType }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<ui-btn small color="error" @click.stop="deleteSessionClick">Delete</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
@@ -110,7 +114,9 @@ export default {
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
@@ -147,7 +153,37 @@ export default {
|
||||
return 'Unknown'
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
methods: {
|
||||
deleteSessionClick() {
|
||||
const payload = {
|
||||
message: `Are you sure you want to delete this session?`,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.deleteSession()
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
deleteSession() {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/sessions/${this._session.id}`)
|
||||
.then(() => {
|
||||
this.processing = false
|
||||
this.$toast.success('Session deleted successfully')
|
||||
this.$emit('removedSession')
|
||||
this.show = false
|
||||
})
|
||||
.catch((error) => {
|
||||
this.processing = false
|
||||
console.error('Failed to delete session', error)
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
this.$toast.error(errMsg || 'Failed to delete session')
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -70,7 +70,7 @@ export default {
|
||||
return this.selectedLibraryItem ? this.selectedLibraryItem.media.metadata.title : ''
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
selectedLibraryItem() {
|
||||
return this.$store.state.selectedLibraryItem
|
||||
|
||||
@@ -46,12 +46,14 @@ export default {
|
||||
{
|
||||
id: 'chapters',
|
||||
title: 'Chapters',
|
||||
component: 'modals-item-tabs-chapters'
|
||||
component: 'modals-item-tabs-chapters',
|
||||
mediaType: 'book'
|
||||
},
|
||||
{
|
||||
id: 'episodes',
|
||||
title: 'Episodes',
|
||||
component: 'modals-item-tabs-episodes'
|
||||
component: 'modals-item-tabs-episodes',
|
||||
mediaType: 'podcast'
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
@@ -64,9 +66,18 @@ export default {
|
||||
component: 'modals-item-tabs-match'
|
||||
},
|
||||
{
|
||||
id: 'manage',
|
||||
title: 'Manage',
|
||||
component: 'modals-item-tabs-manage'
|
||||
id: 'tools',
|
||||
title: 'Tools',
|
||||
component: 'modals-item-tabs-tools',
|
||||
mediaType: 'book',
|
||||
admin: true
|
||||
},
|
||||
{
|
||||
id: 'schedule',
|
||||
title: 'Schedule',
|
||||
component: 'modals-item-tabs-schedule',
|
||||
mediaType: 'podcast',
|
||||
admin: true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -120,16 +131,20 @@ export default {
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
availableTabs() {
|
||||
if (!this.userCanUpdate && !this.userCanDownload) return []
|
||||
return this.tabs.filter((tab) => {
|
||||
if (tab.experimental && !this.showExperimentalFeatures) return false
|
||||
if (tab.id === 'manage' && (this.isMissing || this.mediaType !== 'book')) return false
|
||||
if (this.mediaType == 'podcast' && tab.id == 'chapters') return false
|
||||
if (this.mediaType == 'book' && tab.id == 'episodes') return false
|
||||
if (tab.mediaType && this.mediaType !== tab.mediaType) return false
|
||||
if (tab.admin && !this.userIsAdminOrUp) return false
|
||||
|
||||
if ((tab.id === 'manage' || tab.id === 'files') && this.userCanDownload) return true
|
||||
if (tab.id !== 'manage' && tab.id !== 'files' && this.userCanUpdate) return true
|
||||
if (tab.id === 'tools' && this.isMissing) return false
|
||||
|
||||
if ((tab.id === 'tools' || tab.id === 'files') && this.userCanDownload) return true
|
||||
if (tab.id !== 'tools' && tab.id !== 'files' && this.userCanUpdate) return true
|
||||
if (tab.id === 'match' && this.userCanUpdate) return true
|
||||
return false
|
||||
})
|
||||
@@ -216,8 +231,10 @@ export default {
|
||||
}
|
||||
},
|
||||
selectTab(tab) {
|
||||
if (this.selectedTab === tab) return
|
||||
if (this.availableTabs.find((t) => t.id === tab)) {
|
||||
this.selectedTab = tab
|
||||
this.processing = false
|
||||
}
|
||||
},
|
||||
libraryItemUpdated(expandedLibraryItem) {
|
||||
|
||||
@@ -125,15 +125,12 @@ export default {
|
||||
return this.$store.state.scanners.providers
|
||||
},
|
||||
searchTitleLabel() {
|
||||
if (this.provider == 'audible') return 'Search Title or ASIN'
|
||||
if (this.provider.startsWith('audible')) return 'Search Title or ASIN'
|
||||
else if (this.provider == 'itunes') return 'Search Term'
|
||||
return 'Search Title'
|
||||
},
|
||||
coverAspectRatio() {
|
||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem ? this.libraryItem.id : null
|
||||
@@ -167,7 +164,7 @@ export default {
|
||||
.filter((f) => f.fileType === 'image')
|
||||
.map((file) => {
|
||||
var _file = { ...file }
|
||||
_file.localPath = `/s/item/${this.libraryItemId}/${this.$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}`
|
||||
_file.localPath = `${process.env.serverUrl}/s/item/${this.libraryItemId}/${this.$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}`
|
||||
return _file
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<ui-tooltip v-if="mediaType == 'book'" :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-2 md:mr-4">
|
||||
<ui-tooltip :disabled="!!quickMatching" :text="`Populate empty ${mediaType} details & cover with first ${mediaType} result from '${libraryProvider}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.`" direction="bottom" class="mr-2 md:mr-4">
|
||||
<ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-2 md:mr-4">
|
||||
<ui-tooltip :disabled="!!libraryScan" text="Rescan library item including metadata" direction="bottom" class="mr-2 md:mr-4">
|
||||
<ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
|
||||
@@ -2,8 +2,15 @@
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<div class="w-full mb-4">
|
||||
<div v-if="userIsAdminOrUp" class="flex items-end justify-end mb-4">
|
||||
<!-- <p v-if="autoDownloadEpisodes">Last new episode check {{ $formatDate(lastEpisodeCheck) }}</p> -->
|
||||
<ui-text-input-with-label ref="lastCheckInput" v-model="lastEpisodeCheckInput" :disabled="checkingNewEpisodes" type="datetime-local" label="Look for new episodes after this date" class="max-w-xs mr-2" />
|
||||
<ui-text-input-with-label ref="maxEpisodesInput" v-model="maxEpisodesToDownload" :disabled="checkingNewEpisodes" type="number" label="Max episodes" class="w-16 mr-2" input-class="h-10">
|
||||
<div class="flex -mb-0.5">
|
||||
<p class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': checkingNewEpisodes }">Limit</p>
|
||||
<ui-tooltip direction="top" text="Max # of episodes to download. Use 0 for unlimited.">
|
||||
<span class="material-icons text-base">info_outlined</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</ui-text-input-with-label>
|
||||
<ui-btn :loading="checkingNewEpisodes" @click="checkForNewEpisodes">Check & Download New Episodes</ui-btn>
|
||||
</div>
|
||||
|
||||
@@ -52,7 +59,8 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
checkingNewEpisodes: false,
|
||||
lastEpisodeCheckInput: null
|
||||
lastEpisodeCheckInput: null,
|
||||
maxEpisodesToDownload: 3
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -89,6 +97,16 @@ export default {
|
||||
if (this.$refs.lastCheckInput) {
|
||||
this.$refs.lastCheckInput.blur()
|
||||
}
|
||||
if (this.$refs.maxEpisodesInput) {
|
||||
this.$refs.maxEpisodesInput.blur()
|
||||
}
|
||||
|
||||
if (this.maxEpisodesToDownload < 0) {
|
||||
this.maxEpisodesToDownload = 3
|
||||
this.$toast.error('Invalid max episodes to download')
|
||||
return
|
||||
}
|
||||
|
||||
this.checkingNewEpisodes = true
|
||||
const lastEpisodeCheck = new Date(this.lastEpisodeCheckInput).valueOf()
|
||||
|
||||
@@ -102,7 +120,7 @@ export default {
|
||||
}
|
||||
|
||||
this.$axios
|
||||
.$get(`/api/podcasts/${this.libraryItemId}/checknew`)
|
||||
.$get(`/api/podcasts/${this.libraryItemId}/checknew?limit=${this.maxEpisodesToDownload}`)
|
||||
.then((response) => {
|
||||
if (response.episodes && response.episodes.length) {
|
||||
console.log('New episodes', response.episodes.length)
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
||||
<!-- Merge to m4b -->
|
||||
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8">
|
||||
<div class="flex flex-wrap items-center">
|
||||
<div>
|
||||
<p class="text-lg">Make M4B Audiobook File <span class="text-error">*</span></p>
|
||||
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded metadata, cover image, and chapters. <br /><span class="text-warning">*</span> Does not delete existing audio files.</p>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div class="mt-2 md:mt-0">
|
||||
<p v-if="abmergeStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
|
||||
<p v-if="abmergeStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
|
||||
<p v-if="abmergeStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
||||
|
||||
<ui-btn v-if="abmergeStatus !== $constants.DownloadStatus.READY" :loading="abmergeStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startAudiobookMerge">Start Merge</ui-btn>
|
||||
<div v-else>
|
||||
<div class="flex">
|
||||
<ui-btn @click="downloadWithProgress(abmergeDownload)">Download</ui-btn>
|
||||
<ui-icon-btn small icon="delete" bg-color="error" class="ml-2" @click="removeDownload" />
|
||||
</div>
|
||||
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(abmergeDownload.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Split to mp3 -->
|
||||
<div v-if="showMp3Split && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<p class="text-lg">Split M4B to MP3's</p>
|
||||
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate multiple MP3's split by chapters with embedded metadata, cover image, and chapters. <br /><span class="text-warning">*</span> Does not delete existing audio files.</p>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div>
|
||||
<p v-if="abmergeStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
|
||||
<p v-if="abmergeStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
|
||||
<p v-if="abmergeStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
||||
|
||||
<ui-btn v-if="abmergeStatus !== $constants.DownloadStatus.READY" :loading="abmergeStatus === $constants.DownloadStatus.PENDING" :disabled="true" @click="startAudiobookMerge">Not yet implemented</ui-btn>
|
||||
<div v-else>
|
||||
<div class="flex">
|
||||
<ui-btn @click="downloadWithProgress(abmergeDownload)">Download</ui-btn>
|
||||
<ui-icon-btn small icon="delete" bg-color="error" class="ml-2" @click="removeDownload" />
|
||||
</div>
|
||||
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(abmergeDownload.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Embed Metadata -->
|
||||
<div v-if="mediaTracks.length && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<p class="text-lg">Embed Metadata</p>
|
||||
<p class="max-w-sm text-sm pt-2 text-gray-300">Embed metadata into audio files including cover image and chapters. <br /><span class="text-warning">*</span> Modifies audio files.</p>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div>
|
||||
<ui-btn :to="`/item/${libraryItemId}/manage`" class="flex items-center"
|
||||
>Open Manager
|
||||
<span class="material-icons text-lg ml-2">launch</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="showM4bDownload" class="text-left text-base mb-4 py-4">
|
||||
<span class="text-error">* <strong>Experimental</strong></span
|
||||
> - M4b merge can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted. Download will timeout after 30 minutes.
|
||||
</p>
|
||||
|
||||
<!-- <p v-if="isSingleM4b" class="text-lg text-center my-8">Audiobook is already a single m4b!</p> -->
|
||||
<p v-if="!mediaTracks.length" class="text-lg text-center my-8">No audio tracks to merge</p>
|
||||
|
||||
<div v-if="isDownloading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||
<div class="w-80 border border-black-400 bg-bg rounded-xl h-20">
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<p class="text-lg">Download.... {{ downloadPercent }}%</p>
|
||||
<p class="w-24 font-mono pl-8 text-right">
|
||||
{{ downloadAmount }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tempDisable: false,
|
||||
isDownloading: false,
|
||||
downloadPercent: '0',
|
||||
downloadAmount: '0 KB'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
abmergeStatus(newVal) {
|
||||
if (newVal) {
|
||||
this.tempDisable = false
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem ? this.libraryItem.id : null
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
downloads() {
|
||||
return this.$store.getters['downloads/getDownloads'](this.libraryItemId)
|
||||
},
|
||||
abmergeDownload() {
|
||||
return this.downloads.find((d) => d.type === 'abmerge')
|
||||
},
|
||||
abmergeStatus() {
|
||||
return this.abmergeDownload ? this.abmergeDownload.status : false
|
||||
},
|
||||
libraryFiles() {
|
||||
return this.libraryItem.libraryFiles
|
||||
},
|
||||
totalFiles() {
|
||||
return this.libraryFiles.length
|
||||
},
|
||||
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
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
removeDownload() {
|
||||
if (!this.abmergeDownload) return
|
||||
if (!confirm(`Are you sure you want to remove this merge download?`)) return
|
||||
|
||||
var downloadId = this.abmergeDownload.id
|
||||
|
||||
this.tempDisable = true
|
||||
this.$axios
|
||||
.$delete(`/api/download/${downloadId}`)
|
||||
.then(() => {
|
||||
this.tempDisable = false
|
||||
this.$toast.success('Merge download deleted')
|
||||
this.$store.commit('downloads/removeDownload', { id: downloadId })
|
||||
})
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||
this.$toast.error(errorMsg)
|
||||
this.tempDisable = false
|
||||
})
|
||||
},
|
||||
startAudiobookMerge() {
|
||||
this.tempDisable = true
|
||||
|
||||
this.$axios
|
||||
.$get(`/api/audiobook-merge/${this.libraryItemId}`)
|
||||
.then(() => {
|
||||
this.tempDisable = false
|
||||
})
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||
this.$toast.error(errorMsg)
|
||||
this.tempDisable = false
|
||||
})
|
||||
},
|
||||
downloadWithProgress(download) {
|
||||
var downloadId = download.id
|
||||
var downloadUrl = `${process.env.serverUrl}/api/download/${downloadId}`
|
||||
var filename = download.filename
|
||||
|
||||
this.isDownloading = true
|
||||
|
||||
var request = new XMLHttpRequest()
|
||||
request.responseType = 'blob'
|
||||
request.open('get', downloadUrl, true)
|
||||
request.setRequestHeader('Authorization', `Bearer ${this.$store.getters['user/getToken']}`)
|
||||
request.send()
|
||||
|
||||
request.onreadystatechange = () => {
|
||||
if (request.readyState === 4) {
|
||||
this.isDownloading = false
|
||||
}
|
||||
if (request.readyState == 4 && request.status == 200) {
|
||||
const url = window.URL.createObjectURL(request.response)
|
||||
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
setTimeout(() => {
|
||||
if (anchor) anchor.remove()
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
request.onerror = (err) => {
|
||||
console.error('Download error', err)
|
||||
this.isDownloading = false
|
||||
}
|
||||
|
||||
request.onprogress = (e) => {
|
||||
const percent_complete = Math.floor((e.loaded / e.total) * 100)
|
||||
this.downloadAmount = this.$bytesPretty(e.loaded)
|
||||
this.downloadPercent = percent_complete
|
||||
|
||||
// const duration = (new Date().getTime() - startTime) / 1000
|
||||
// const bps = e.loaded / duration
|
||||
// const kbps = Math.floor(bps / 1024)
|
||||
// const time = (e.total - e.loaded) / bps
|
||||
// const seconds = Math.floor(time % 60)
|
||||
// const minutes = Math.floor(time / 60)
|
||||
// console.log(`${percent_complete}% - ${kbps} Kbps - ${minutes} min ${seconds} sec remaining`)
|
||||
}
|
||||
},
|
||||
loadDownloads() {
|
||||
this.$axios
|
||||
.$get(`/api/downloads`)
|
||||
.then((data) => {
|
||||
var pendingDownloads = data.pendingDownloads.map((pd) => {
|
||||
pd.download.status = this.$constants.DownloadStatus.PENDING
|
||||
return pd.download
|
||||
})
|
||||
var downloads = data.downloads.map((d) => {
|
||||
d.status = this.$constants.DownloadStatus.READY
|
||||
return d
|
||||
})
|
||||
var allDownloads = downloads.concat(pendingDownloads)
|
||||
this.$store.commit('downloads/setDownloads', allDownloads)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load downloads', error)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadDownloads()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -25,135 +25,140 @@
|
||||
<cards-book-match-card :key="index" :book="res" :is-podcast="isPodcast" :book-cover-aspect-ratio="bookCoverAspectRatio" @select="selectMatch" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="selectedMatch" class="absolute top-0 left-0 w-full bg-bg h-full p-8 max-h-full overflow-y-auto overflow-x-hidden">
|
||||
<div class="flex mb-2">
|
||||
<div class="w-8 h-8 rounded-full hover:bg-white hover:bg-opacity-10 flex items-center justify-center cursor-pointer" @click="selectedMatch = null">
|
||||
<div v-if="selectedMatchOrig" class="absolute top-0 left-0 w-full bg-bg h-full px-2 py-6 md:p-8 max-h-full overflow-y-auto overflow-x-hidden">
|
||||
<div class="flex mb-4">
|
||||
<div class="w-8 h-8 rounded-full hover:bg-white hover:bg-opacity-10 flex items-center justify-center cursor-pointer" @click="clearSelectedMatch">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
</div>
|
||||
<p class="text-xl pl-3">Update Book Details</p>
|
||||
</div>
|
||||
<ui-checkbox v-model="selectAll" checkbox-bg="bg" @input="selectAllToggled" />
|
||||
<form @submit.prevent="submitMatchUpdate">
|
||||
<div v-if="selectedMatch.cover" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.cover" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" label="Cover" class="flex-grow ml-4" />
|
||||
<div v-if="selectedMatchOrig.cover" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.cover" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" readonly label="Cover" class="flex-grow mx-4" />
|
||||
<div class="min-w-12 max-w-12 md:min-w-16 md:max-w-16">
|
||||
<a :href="selectedMatch.cover" target="_blank" class="w-full bg-primary">
|
||||
<img :src="selectedMatch.cover" class="h-full w-full object-contain" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.title" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.title" />
|
||||
<div v-if="selectedMatchOrig.title" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.title" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.title" :disabled="!selectedMatchUsage.title" label="Title" />
|
||||
<p v-if="mediaMetadata.title" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.title || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.subtitle" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.subtitle" />
|
||||
<div v-if="selectedMatchOrig.subtitle" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.subtitle" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.subtitle" :disabled="!selectedMatchUsage.subtitle" label="Subtitle" />
|
||||
<p v-if="mediaMetadata.subtitle" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.subtitle || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.author" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.author" />
|
||||
<div v-if="selectedMatchOrig.author" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.author" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.author" :disabled="!selectedMatchUsage.author" label="Author" />
|
||||
<p v-if="mediaMetadata.authorName" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.authorName || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.narrator" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.narrator" />
|
||||
<div v-if="selectedMatchOrig.narrator" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.narrator" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.narrator" :disabled="!selectedMatchUsage.narrator" label="Narrator" />
|
||||
<p v-if="mediaMetadata.narratorName" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.narratorName || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.description" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.description" />
|
||||
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" label="Description" class="flex-grow ml-4" />
|
||||
<div v-if="selectedMatchOrig.description" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.description" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" label="Description" />
|
||||
<p v-if="mediaMetadata.description" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.description.substr(0, 100) + (mediaMetadata.description.length > 100 ? '...' : '') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.publisher" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.publisher" />
|
||||
<div v-if="selectedMatchOrig.publisher" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.publisher" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.publisher" :disabled="!selectedMatchUsage.publisher" label="Publisher" />
|
||||
<p v-if="mediaMetadata.publisher" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.publisher || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.publishedYear" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.publishedYear" />
|
||||
<div v-if="selectedMatchOrig.publishedYear" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.publishedYear" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.publishedYear" :disabled="!selectedMatchUsage.publishedYear" label="Published Year" />
|
||||
<p v-if="mediaMetadata.publishedYear" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.publishedYear || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedMatch.series" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.series" />
|
||||
<div v-if="selectedMatchOrig.series" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.series" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<widgets-series-input-widget v-model="selectedMatch.series" />
|
||||
<widgets-series-input-widget v-model="selectedMatch.series" :disabled="!selectedMatchUsage.series" />
|
||||
<p v-if="mediaMetadata.seriesName" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.seriesName || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.volumeNumber" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.volumeNumber" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.volumeNumber" :disabled="!selectedMatchUsage.volumeNumber" label="Volume Number" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.genres" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.genres" />
|
||||
<div v-if="selectedMatchOrig.genres && selectedMatchOrig.genres.length" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.genres" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.genres" :disabled="!selectedMatchUsage.genres" label="Genres" />
|
||||
<p v-if="mediaMetadata.genresList" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.genresList || '' }}</p>
|
||||
<ui-multi-select v-model="selectedMatch.genres" :items="selectedMatch.genres" :disabled="!selectedMatchUsage.genres" label="Genres" />
|
||||
<p v-if="mediaMetadata.genres" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.genres.join(', ') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.tags" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.tags" />
|
||||
<div v-if="selectedMatchOrig.tags" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.tags" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.tags" :disabled="!selectedMatchUsage.tags" label="Tags" />
|
||||
<p v-if="mediaMetadata.tagsList" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.tagsList || '' }}</p>
|
||||
<p v-if="media.tags" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ media.tags.join(', ') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.language" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.language" />
|
||||
<div v-if="selectedMatchOrig.language" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.language" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.language" :disabled="!selectedMatchUsage.language" label="Language" />
|
||||
<p v-if="mediaMetadata.language" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.language || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.isbn" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.isbn" />
|
||||
<div v-if="selectedMatchOrig.isbn" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.isbn" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.isbn" :disabled="!selectedMatchUsage.isbn" label="ISBN" />
|
||||
<p v-if="mediaMetadata.isbn" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.isbn || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.asin" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.asin" />
|
||||
<div v-if="selectedMatchOrig.asin" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.asin" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.asin" :disabled="!selectedMatchUsage.asin" label="ASIN" />
|
||||
<p v-if="mediaMetadata.asin" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.asin || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedMatch.itunesId" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.itunesId" />
|
||||
<div v-if="selectedMatchOrig.itunesId" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.itunesId" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.itunesId" type="number" :disabled="!selectedMatchUsage.itunesId" label="iTunes ID" />
|
||||
<p v-if="mediaMetadata.itunesId" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.itunesId || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.feedUrl" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.feedUrl" />
|
||||
<div v-if="selectedMatchOrig.feedUrl" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.feedUrl" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.feedUrl" :disabled="!selectedMatchUsage.feedUrl" label="RSS Feed URL" />
|
||||
<p v-if="mediaMetadata.feedUrl" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.feedUrl || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.itunesPageUrl" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.itunesPageUrl" />
|
||||
<div v-if="selectedMatchOrig.itunesPageUrl" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.itunesPageUrl" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.itunesPageUrl" :disabled="!selectedMatchUsage.itunesPageUrl" label="iTunes Page URL" />
|
||||
<p v-if="mediaMetadata.itunesPageUrl" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.itunesPageUrl || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.releaseDate" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.releaseDate" />
|
||||
<div v-if="selectedMatchOrig.releaseDate" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.releaseDate" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.releaseDate" :disabled="!selectedMatchUsage.releaseDate" label="Release Date" />
|
||||
<p v-if="mediaMetadata.releaseDate" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.releaseDate || '' }}</p>
|
||||
@@ -187,6 +192,7 @@ export default {
|
||||
searchResults: [],
|
||||
hasSearched: false,
|
||||
selectedMatch: null,
|
||||
selectedMatchOrig: null,
|
||||
selectedMatchUsage: {
|
||||
title: true,
|
||||
subtitle: true,
|
||||
@@ -197,7 +203,6 @@ export default {
|
||||
publisher: true,
|
||||
publishedYear: true,
|
||||
series: true,
|
||||
volumeNumber: true,
|
||||
genres: true,
|
||||
tags: true,
|
||||
language: true,
|
||||
@@ -209,7 +214,8 @@ export default {
|
||||
itunesId: true,
|
||||
feedUrl: true,
|
||||
releaseDate: true
|
||||
}
|
||||
},
|
||||
selectAll: true
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -234,26 +240,25 @@ export default {
|
||||
return this.selectedMatch.series.map((se) => {
|
||||
return {
|
||||
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||
displayName: se.volumeNumber ? `${se.series} #${se.volumeNumber}` : se.series,
|
||||
displayName: se.sequence ? `${se.series} #${se.sequence}` : se.series,
|
||||
name: se.series,
|
||||
sequence: se.volumeNumber || ''
|
||||
sequence: se.sequence || ''
|
||||
}
|
||||
})
|
||||
},
|
||||
set(val) {
|
||||
console.log('set series items', val)
|
||||
this.selectedMatch.series = val
|
||||
}
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
providers() {
|
||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||
return this.$store.state.scanners.providers
|
||||
},
|
||||
searchTitleLabel() {
|
||||
if (this.provider == 'audible') return 'Search Title or ASIN'
|
||||
if (this.provider.startsWith('audible')) return 'Search Title or ASIN'
|
||||
else if (this.provider == 'itunes') return 'Search Term'
|
||||
return 'Search Title'
|
||||
},
|
||||
@@ -271,6 +276,14 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
selectAllToggled(val) {
|
||||
for (const key in this.selectedMatchUsage) {
|
||||
this.selectedMatchUsage[key] = val
|
||||
}
|
||||
},
|
||||
checkboxToggled() {
|
||||
this.selectAll = Object.values(this.selectedMatchUsage).findIndex((v) => v == false) < 0
|
||||
},
|
||||
persistProvider() {
|
||||
try {
|
||||
localStorage.setItem('book-provider', this.provider)
|
||||
@@ -299,7 +312,7 @@ export default {
|
||||
this.isProcessing = true
|
||||
this.lastSearch = searchQuery
|
||||
var searchEntity = this.isPodcast ? 'podcast' : 'books'
|
||||
var results = await this.$axios.$get(`/api/search/${searchEntity}?${searchQuery}`, { timeout: 10000 }).catch((error) => {
|
||||
var results = await this.$axios.$get(`/api/search/${searchEntity}?${searchQuery}`, { timeout: 20000 }).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return []
|
||||
})
|
||||
@@ -323,7 +336,7 @@ export default {
|
||||
this.hasSearched = true
|
||||
},
|
||||
init() {
|
||||
this.selectedMatch = null
|
||||
this.clearSelectedMatch()
|
||||
this.selectedMatchUsage = {
|
||||
title: true,
|
||||
subtitle: true,
|
||||
@@ -334,7 +347,6 @@ export default {
|
||||
publisher: true,
|
||||
publishedYear: true,
|
||||
series: true,
|
||||
volumeNumber: true,
|
||||
genres: true,
|
||||
tags: true,
|
||||
language: true,
|
||||
@@ -377,37 +389,34 @@ export default {
|
||||
match.series = match.series.map((se) => {
|
||||
return {
|
||||
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||
displayName: se.volumeNumber ? `${se.series} #${se.volumeNumber}` : se.series,
|
||||
displayName: se.sequence ? `${se.series} #${se.sequence}` : se.series,
|
||||
name: se.series,
|
||||
sequence: se.volumeNumber || ''
|
||||
sequence: se.sequence || ''
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
if (match.genres && Array.isArray(match.genres)) {
|
||||
match.genres = match.genres.join(',')
|
||||
if (match.genres && !Array.isArray(match.genres)) {
|
||||
// match.genres = match.genres.join(',')
|
||||
match.genres = match.genres.split(',').map((g) => g.trim())
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Select Match', match)
|
||||
this.selectedMatch = match
|
||||
this.selectedMatchOrig = JSON.parse(JSON.stringify(match))
|
||||
},
|
||||
buildMatchUpdatePayload() {
|
||||
var updatePayload = {}
|
||||
updatePayload.metadata = {}
|
||||
|
||||
var volumeNumber = this.selectedMatchUsage.volumeNumber ? this.selectedMatch.volumeNumber || null : null
|
||||
for (const key in this.selectedMatchUsage) {
|
||||
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
|
||||
if (key === 'series') {
|
||||
var seriesPayload = []
|
||||
if (!Array.isArray(this.selectedMatch[key])) {
|
||||
seriesPayload.push({
|
||||
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||
name: this.selectedMatch[key],
|
||||
sequence: volumeNumber
|
||||
})
|
||||
console.error('Invalid series in selectedMatch', this.selectedMatch[key])
|
||||
} else {
|
||||
var seriesPayload = []
|
||||
this.selectedMatch[key].forEach((seriesItem) =>
|
||||
seriesPayload.push({
|
||||
id: seriesItem.id,
|
||||
@@ -415,9 +424,8 @@ export default {
|
||||
sequence: seriesItem.sequence
|
||||
})
|
||||
)
|
||||
updatePayload.metadata.series = seriesPayload
|
||||
}
|
||||
|
||||
updatePayload.metadata.series = seriesPayload
|
||||
} else if (key === 'author' && !this.isPodcast) {
|
||||
var authors = this.selectedMatch[key]
|
||||
if (!Array.isArray(authors)) {
|
||||
@@ -434,7 +442,8 @@ export default {
|
||||
} else if (key === 'narrator') {
|
||||
updatePayload.metadata.narrators = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||
} else if (key === 'genres') {
|
||||
updatePayload.metadata.genres = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||
// updatePayload.metadata.genres = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||
updatePayload.metadata.genres = [...this.selectedMatch[key]]
|
||||
} else if (key === 'tags') {
|
||||
updatePayload.tags = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||
} else if (key === 'itunesId') {
|
||||
@@ -485,15 +494,19 @@ export default {
|
||||
} else {
|
||||
this.$toast.info('No detail updates were necessary')
|
||||
}
|
||||
this.selectedMatch = null
|
||||
this.clearSelectedMatch()
|
||||
this.$emit('selectTab', 'details')
|
||||
} else {
|
||||
this.$toast.error('Item Details Failed to Update')
|
||||
}
|
||||
} else {
|
||||
this.selectedMatch = null
|
||||
this.clearSelectedMatch()
|
||||
}
|
||||
this.isProcessing = false
|
||||
},
|
||||
clearSelectedMatch() {
|
||||
this.selectedMatch = null
|
||||
this.selectedMatchOrig = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
155
client/components/modals/item/tabs/Schedule.vue
Normal file
155
client/components/modals/item/tabs/Schedule.vue
Normal file
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<div class="w-full h-full relative">
|
||||
<div id="scheduleWrapper" class="w-full overflow-y-auto px-2 py-4 md:px-6 md:py-6">
|
||||
<template v-if="!feedUrl">
|
||||
<widgets-alert type="warning" class="text-base mb-4">No RSS feed URL is set for this podcast</widgets-alert>
|
||||
</template>
|
||||
<template v-if="feedUrl || autoDownloadEpisodes">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<p class="text-base md:text-xl font-semibold">Schedule Automatic Episode Downloads</p>
|
||||
<ui-checkbox v-model="enableAutoDownloadEpisodes" label="Enable" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" />
|
||||
</div>
|
||||
|
||||
<div v-if="enableAutoDownloadEpisodes" class="flex items-center py-2">
|
||||
<ui-text-input ref="maxEpisodesInput" type="number" v-model="newMaxEpisodesToKeep" no-spinner :padding-x="1" text-center class="w-10 text-base" @change="updatedMaxEpisodesToKeep" />
|
||||
<ui-tooltip text="Value of 0 sets no max limit. After a new episode is auto-downloaded this will delete the oldest episode if you have more than X episodes. <br>This will only delete 1 episode per new download.">
|
||||
<p class="pl-4 text-base">
|
||||
Max episodes to keep
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<widgets-cron-expression-builder ref="cronExpressionBuilder" v-if="enableAutoDownloadEpisodes" v-model="cronExpression" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="feedUrl || autoDownloadEpisodes" class="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg border-t border-white border-opacity-5">
|
||||
<div class="flex items-center px-2 md:px-4">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn @click="save" :disabled="!isUpdated" :color="isUpdated ? 'success' : 'primary'" class="mx-2">{{ isUpdated ? 'Save' : 'No update necessary' }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
enableAutoDownloadEpisodes: false,
|
||||
cronExpression: null,
|
||||
newMaxEpisodesToKeep: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isProcessing: {
|
||||
get() {
|
||||
return this.processing
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:processing', val)
|
||||
}
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem ? this.libraryItem.id : null
|
||||
},
|
||||
feedUrl() {
|
||||
return this.mediaMetadata.feedUrl
|
||||
},
|
||||
autoDownloadEpisodes() {
|
||||
return !!this.media.autoDownloadEpisodes
|
||||
},
|
||||
autoDownloadSchedule() {
|
||||
return this.media.autoDownloadSchedule
|
||||
},
|
||||
maxEpisodesToKeep() {
|
||||
return this.media.maxEpisodesToKeep
|
||||
},
|
||||
isUpdated() {
|
||||
return this.autoDownloadSchedule !== this.cronExpression || this.autoDownloadEpisodes !== this.enableAutoDownloadEpisodes || this.maxEpisodesToKeep !== Number(this.newMaxEpisodesToKeep)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updatedMaxEpisodesToKeep() {
|
||||
if (isNaN(this.newMaxEpisodesToKeep) || this.newMaxEpisodesToKeep < 0) {
|
||||
this.newMaxEpisodesToKeep = 0
|
||||
} else {
|
||||
this.newMaxEpisodesToKeep = Number(this.newMaxEpisodesToKeep)
|
||||
}
|
||||
},
|
||||
save() {
|
||||
// If custom expression input is focused then unfocus it instead of submitting
|
||||
if (this.$refs.cronExpressionBuilder && this.$refs.cronExpressionBuilder.checkBlurExpressionInput) {
|
||||
if (this.$refs.cronExpressionBuilder.checkBlurExpressionInput()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (this.$refs.maxEpisodesInput && this.$refs.maxEpisodesInput.isFocused) {
|
||||
this.$refs.maxEpisodesInput.blur()
|
||||
return
|
||||
}
|
||||
|
||||
const updatePayload = {
|
||||
autoDownloadEpisodes: this.enableAutoDownloadEpisodes
|
||||
}
|
||||
if (this.enableAutoDownloadEpisodes) {
|
||||
updatePayload.autoDownloadSchedule = this.cronExpression
|
||||
}
|
||||
if (this.newMaxEpisodesToKeep !== this.maxEpisodesToKeep) {
|
||||
updatePayload.maxEpisodesToKeep = this.newMaxEpisodesToKeep
|
||||
}
|
||||
|
||||
this.updateDetails(updatePayload)
|
||||
},
|
||||
async updateDetails(updatePayload) {
|
||||
this.isProcessing = true
|
||||
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
this.isProcessing = false
|
||||
if (updateResult) {
|
||||
if (updateResult.updated) {
|
||||
this.$toast.success('Item details updated')
|
||||
return true
|
||||
} else {
|
||||
this.$toast.info('No updates were necessary')
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
init() {
|
||||
this.enableAutoDownloadEpisodes = this.autoDownloadEpisodes
|
||||
this.cronExpression = this.autoDownloadSchedule
|
||||
this.newMaxEpisodesToKeep = this.maxEpisodesToKeep
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#scheduleWrapper {
|
||||
height: calc(100% - 80px);
|
||||
max-height: calc(100% - 80px);
|
||||
}
|
||||
</style>
|
||||
100
client/components/modals/item/tabs/Tools.vue
Normal file
100
client/components/modals/item/tabs/Tools.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
||||
<p class="text-xl font-semibold mb-2">Audiobook File Management Tools</p>
|
||||
|
||||
<!-- Merge to m4b -->
|
||||
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8">
|
||||
<div class="flex flex-wrap items-center">
|
||||
<div>
|
||||
<p class="text-lg">Make M4B Audiobook File</p>
|
||||
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded metadata, cover image, and chapters.</p>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div>
|
||||
<ui-btn :to="`/audiobook/${libraryItemId}/manage?tool=m4b`" class="flex items-center"
|
||||
>Open Manager
|
||||
<span class="material-icons text-lg ml-2">launch</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Split to mp3 -->
|
||||
<div v-if="showMp3Split && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<p class="text-lg">Split M4B to MP3's</p>
|
||||
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate multiple MP3's split by chapters with embedded metadata, cover image, and chapters.</p>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div>
|
||||
<ui-btn :disabled="true">Not yet implemented</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Embed Metadata -->
|
||||
<div v-if="mediaTracks.length" class="w-full border border-black-200 p-4 my-8">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<p class="text-lg">Embed Metadata</p>
|
||||
<p class="max-w-sm text-sm pt-2 text-gray-300">Embed metadata into audio files including cover image and chapters.</p>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div>
|
||||
<ui-btn :to="`/audiobook/${libraryItemId}/manage?tool=embed`" class="flex items-center"
|
||||
>Open Manager
|
||||
<span class="material-icons text-lg ml-2">launch</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!mediaTracks.length" class="text-lg text-center my-8">No audio tracks</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem ? this.libraryItem.id : null
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
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
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="w-full h-full px-4 py-2 mb-4">
|
||||
<div class="w-full h-full px-1 md:px-4 py-2 mb-4">
|
||||
<div v-if="!showDirectoryPicker" class="w-full h-full py-4">
|
||||
<div class="flex flex-wrap md:flex-nowrap -mx-1">
|
||||
<div class="w-2/5 md:w-72 px-1 py-1 md:py-0">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<modals-modal v-model="show" name="edit-library" :width="700" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
<p class="font-book text-xl md:text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="absolute -top-10 left-0 z-10 w-full flex">
|
||||
@@ -11,10 +11,10 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="px-4 w-full text-sm pt-6 pb-20 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||
<component v-if="libraryCopy && show" :is="tabName" :is-new="!library" :library="libraryCopy" :processing.sync="processing" @update="updateLibrary" @close="show = false" />
|
||||
<div class="px-2 md:px-4 w-full text-sm pt-6 pb-20 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||
<component v-if="libraryCopy && show" ref="tabComponent" :is="tabName" :is-new="!library" :library="libraryCopy" :processing.sync="processing" @update="updateLibrary" @close="show = false" />
|
||||
|
||||
<div class="absolute bottom-0 left-0 w-full px-4 py-4 border-t border-opacity-10">
|
||||
<div class="absolute bottom-0 left-0 w-full px-4 py-4 border-t border-white border-opacity-10">
|
||||
<div class="flex justify-end">
|
||||
<ui-btn @click="submit">{{ buttonText }}</ui-btn>
|
||||
</div>
|
||||
@@ -46,6 +46,11 @@ export default {
|
||||
id: 'settings',
|
||||
title: 'Settings',
|
||||
component: 'modals-libraries-library-settings'
|
||||
},
|
||||
{
|
||||
id: 'schedule',
|
||||
title: 'Schedule',
|
||||
component: 'modals-libraries-schedule-scan'
|
||||
}
|
||||
],
|
||||
libraryCopy: null
|
||||
@@ -84,6 +89,7 @@ export default {
|
||||
},
|
||||
updateLibrary(library) {
|
||||
this.mapLibraryToCopy(library)
|
||||
console.log('Updated library', this.libraryCopy)
|
||||
},
|
||||
getNewLibraryData() {
|
||||
return {
|
||||
@@ -93,9 +99,11 @@ export default {
|
||||
icon: 'database',
|
||||
mediaType: 'book',
|
||||
settings: {
|
||||
coverAspectRatio: this.$constants.BookCoverAspectRatio.SQUARE,
|
||||
disableWatcher: false,
|
||||
skipMatchingMediaWithAsin: false,
|
||||
skipMatchingMediaWithIsbn: false
|
||||
skipMatchingMediaWithIsbn: false,
|
||||
autoScanCronExpression: null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -112,7 +120,9 @@ export default {
|
||||
if (key === 'folders') {
|
||||
this.libraryCopy.folders = library.folders.map((f) => ({ ...f }))
|
||||
} else if (key === 'settings') {
|
||||
this.libraryCopy.settings = { ...library.settings }
|
||||
for (const settingKey in library.settings) {
|
||||
this.libraryCopy.settings[settingKey] = library.settings[settingKey]
|
||||
}
|
||||
} else {
|
||||
this.libraryCopy[key] = library[key]
|
||||
}
|
||||
@@ -134,6 +144,13 @@ export default {
|
||||
submit() {
|
||||
if (!this.validate()) return
|
||||
|
||||
// If custom expression input is focused then unfocus it instead of submitting
|
||||
if (this.$refs.tabComponent && this.$refs.tabComponent.checkBlurExpressionInput) {
|
||||
if (this.$refs.tabComponent.checkBlurExpressionInput()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (this.library) {
|
||||
this.submitUpdateLibrary()
|
||||
} else {
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
<template>
|
||||
<div class="w-full h-full px-4 py-1 mb-4">
|
||||
<div class="w-full h-full px-1 md:px-4 py-1 mb-4">
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="useSquareBookCovers" @input="formUpdated" />
|
||||
<ui-tooltip :text="tooltips.coverAspectRatio">
|
||||
<p class="pl-4 text-base">
|
||||
Use square book covers
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div class="py-3">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="disableWatcher" @input="formUpdated" />
|
||||
<ui-toggle-switch v-else disabled :value="false" />
|
||||
<p class="pl-4 text-lg">Disable folder watcher for library</p>
|
||||
<p class="pl-4 text-base">Disable folder watcher for library</p>
|
||||
</div>
|
||||
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*Watcher is disabled globally in server settings</p>
|
||||
</div>
|
||||
<div v-if="mediaType == 'book'" class="py-3">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="skipMatchingMediaWithAsin" @input="formUpdated" />
|
||||
<p class="pl-4 text-lg">Skip matching books that already have an ASIN</p>
|
||||
<p class="pl-4 text-base">Skip matching books that already have an ASIN</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="mediaType == 'book'" class="py-3">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="skipMatchingMediaWithIsbn" @input="formUpdated" />
|
||||
<p class="pl-4 text-lg">Skip matching books that already have an ISBN</p>
|
||||
<p class="pl-4 text-base">Skip matching books that already have an ISBN</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -35,9 +44,13 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
provider: null,
|
||||
useSquareBookCovers: false,
|
||||
disableWatcher: false,
|
||||
skipMatchingMediaWithAsin: false,
|
||||
skipMatchingMediaWithIsbn: false
|
||||
skipMatchingMediaWithIsbn: false,
|
||||
tooltips: {
|
||||
coverAspectRatio: 'Prefer to use square covers over standard 1.6:1 book covers'
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -59,6 +72,7 @@ export default {
|
||||
getLibraryData() {
|
||||
return {
|
||||
settings: {
|
||||
coverAspectRatio: this.useSquareBookCovers ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD,
|
||||
disableWatcher: !!this.disableWatcher,
|
||||
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
|
||||
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn
|
||||
@@ -69,6 +83,7 @@ export default {
|
||||
this.$emit('update', this.getLibraryData())
|
||||
},
|
||||
init() {
|
||||
this.useSquareBookCovers = this.librarySettings.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
||||
this.disableWatcher = !!this.librarySettings.disableWatcher
|
||||
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
|
||||
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
|
||||
|
||||
56
client/components/modals/libraries/ScheduleScan.vue
Normal file
56
client/components/modals/libraries/ScheduleScan.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="w-full h-full px-1 md:px-4 py-1 mb-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<p class="text-base md:text-xl font-semibold">Schedule Automatic Library Scans</p>
|
||||
<ui-checkbox v-model="enableAutoScan" @input="toggleEnableAutoScan" label="Enable" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" />
|
||||
</div>
|
||||
<widgets-cron-expression-builder ref="cronExpressionBuilder" v-if="enableAutoScan" v-model="cronExpression" @input="updatedCron" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
library: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
processing: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
cronExpression: null,
|
||||
enableAutoScan: false
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
checkBlurExpressionInput() {
|
||||
// returns true if advanced cron input is focused
|
||||
if (!this.$refs.cronExpressionBuilder) return false
|
||||
return this.$refs.cronExpressionBuilder.checkBlurExpressionInput()
|
||||
},
|
||||
toggleEnableAutoScan(v) {
|
||||
if (!v) this.updatedCron(null)
|
||||
else if (!this.cronExpression) {
|
||||
this.cronExpression = '0 0 * * 1'
|
||||
this.updatedCron(this.cronExpression)
|
||||
}
|
||||
},
|
||||
updatedCron(expression) {
|
||||
this.$emit('update', {
|
||||
settings: {
|
||||
autoScanCronExpression: expression
|
||||
}
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.cronExpression = this.library.settings.autoScanCronExpression
|
||||
this.enableAutoScan = !!this.cronExpression
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
188
client/components/modals/notification/NotificationEditModal.vue
Normal file
188
client/components/modals/notification/NotificationEditModal.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<modals-modal ref="modal" v-model="show" name="notification-edit" :width="800" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300">
|
||||
<div class="w-full px-3 py-5 md:p-12">
|
||||
<ui-dropdown v-model="newNotification.eventName" label="Notification Event" :items="eventOptions" class="mb-4" @input="eventOptionUpdated" />
|
||||
|
||||
<ui-multi-select v-model="newNotification.urls" label="Apprise URL(s)" class="mb-2" />
|
||||
|
||||
<ui-text-input-with-label v-model="newNotification.titleTemplate" label="Title Template" class="mb-2" />
|
||||
|
||||
<ui-textarea-with-label v-model="newNotification.bodyTemplate" label="Body Template" :rows="4" class="mb-2" />
|
||||
|
||||
<p v-if="availableVariables" class="text-sm text-gray-300"><strong>Available variables:</strong> {{ availableVariables.join(', ') }}</p>
|
||||
|
||||
<div class="flex items-center pt-4">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="newNotification.enabled" />
|
||||
<p class="text-lg pl-2">Enabled</p>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" type="submit">Submit</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
notification: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
notificationData: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
newNotification: {},
|
||||
isNew: true
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
notificationEvents() {
|
||||
if (!this.notificationData) return []
|
||||
return this.notificationData.events || []
|
||||
},
|
||||
eventOptions() {
|
||||
return this.notificationEvents.map((e) => ({ value: e.name, text: e.name, subtext: e.description }))
|
||||
},
|
||||
selectedEventData() {
|
||||
return this.notificationEvents.find((e) => e.name === this.newNotification.eventName)
|
||||
},
|
||||
showLibrarySelectInput() {
|
||||
return this.selectedEventData && this.selectedEventData.requiresLibrary
|
||||
},
|
||||
title() {
|
||||
return this.isNew ? 'Create Notification' : 'Update Notification'
|
||||
},
|
||||
availableVariables() {
|
||||
return this.selectedEventData ? this.selectedEventData.variables || null : null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
eventOptionUpdated() {
|
||||
if (!this.selectedEventData) return
|
||||
this.newNotification.titleTemplate = this.selectedEventData.defaults.title || ''
|
||||
this.newNotification.bodyTemplate = this.selectedEventData.defaults.body || ''
|
||||
},
|
||||
close() {
|
||||
// Force close when navigating - used in UsersTable
|
||||
if (this.$refs.modal) this.$refs.modal.setHide()
|
||||
},
|
||||
submitForm() {
|
||||
if (!this.newNotification.urls.length) {
|
||||
this.$toast.error('Must enter an Apprise URL')
|
||||
return
|
||||
}
|
||||
|
||||
if (this.isNew) {
|
||||
this.submitCreate()
|
||||
} else {
|
||||
this.submitUpdate()
|
||||
}
|
||||
},
|
||||
submitUpdate() {
|
||||
this.processing = true
|
||||
|
||||
const payload = {
|
||||
...this.newNotification
|
||||
}
|
||||
console.log('Sending update notification', payload)
|
||||
this.$axios
|
||||
.$patch(`/api/notifications/${payload.id}`, payload)
|
||||
.then((updatedSettings) => {
|
||||
this.$emit('update', updatedSettings)
|
||||
this.$toast.success('Notification updated')
|
||||
this.show = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update notification', error)
|
||||
this.$toast.error('Failed to update notification')
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
submitCreate() {
|
||||
this.processing = true
|
||||
|
||||
const payload = {
|
||||
...this.newNotification
|
||||
}
|
||||
console.log('Sending create notification', payload)
|
||||
this.$axios
|
||||
.$post('/api/notifications', payload)
|
||||
.then((updatedSettings) => {
|
||||
this.$emit('update', updatedSettings)
|
||||
this.$toast.success('Notification created')
|
||||
this.show = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to create notification', error)
|
||||
this.$toast.error('Failed to create notification')
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.isNew = !this.notification
|
||||
if (this.notification) {
|
||||
this.newNotification = {
|
||||
id: this.notification.id,
|
||||
libraryId: this.notification.libraryId,
|
||||
eventName: this.notification.eventName,
|
||||
urls: [...this.notification.urls],
|
||||
titleTemplate: this.notification.titleTemplate,
|
||||
bodyTemplate: this.notification.bodyTemplate,
|
||||
enabled: this.notification.enabled,
|
||||
type: this.notification.type
|
||||
}
|
||||
} else {
|
||||
this.newNotification = {
|
||||
libraryId: null,
|
||||
eventName: 'onTest',
|
||||
urls: [],
|
||||
titleTemplate: '',
|
||||
bodyTemplate: '',
|
||||
enabled: true,
|
||||
type: null
|
||||
}
|
||||
this.eventOptionUpdated()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
102
client/components/modals/player/QueueItemRow.vue
Normal file
102
client/components/modals/player/QueueItemRow.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div v-if="item" class="w-full flex items-center px-4 py-2" :class="wrapperClass" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<covers-preview-cover :src="coverUrl" :width="48" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" />
|
||||
<div class="flex-grow px-2 py-1 queue-item-row-content truncate">
|
||||
<p class="text-gray-200 text-sm truncate">{{ title }}</p>
|
||||
<p class="text-gray-300 text-sm">{{ subtitle }}</p>
|
||||
<p v-if="caption" class="text-gray-400 text-xs">{{ caption }}</p>
|
||||
</div>
|
||||
<div class="w-28">
|
||||
<p v-if="isOpenInPlayer" class="text-sm text-right text-gray-400">Streaming</p>
|
||||
<div v-else-if="isHovering" class="flex items-center justify-end -mx-1">
|
||||
<button class="outline-none mx-1 flex items-center" @click.stop="playClick">
|
||||
<span class="material-icons text-success">play_arrow</span>
|
||||
</button>
|
||||
<button class="outline-none mx-1 flex items-center" @click.stop="removeClick">
|
||||
<span class="material-icons text-error">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="text-gray-400 text-sm text-right">{{ durationPretty }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
item: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
index: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isHovering: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
title() {
|
||||
return this.item.title || ''
|
||||
},
|
||||
subtitle() {
|
||||
return this.item.subtitle || ''
|
||||
},
|
||||
caption() {
|
||||
return this.item.caption
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.item.libraryItemId
|
||||
},
|
||||
episodeId() {
|
||||
return this.item.episodeId
|
||||
},
|
||||
coverPath() {
|
||||
return this.item.coverPath
|
||||
},
|
||||
coverUrl() {
|
||||
if (!this.coverPath) return `${this.$config.routerBasePath}/book_placeholder.jpg`
|
||||
return this.$store.getters['globals/getLibraryItemCoverSrcById'](this.libraryItemId)
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
duration() {
|
||||
return this.item.duration
|
||||
},
|
||||
durationPretty() {
|
||||
if (!this.duration) return 'N/A'
|
||||
return this.$elapsedPretty(this.duration)
|
||||
},
|
||||
isOpenInPlayer() {
|
||||
return this.$store.getters['getIsMediaStreaming'](this.libraryItemId, this.episodeId)
|
||||
},
|
||||
wrapperClass() {
|
||||
if (this.isOpenInPlayer) return 'bg-yellow-400 bg-opacity-10'
|
||||
if (this.index % 2 === 0) return 'bg-gray-300 bg-opacity-5 hover:bg-opacity-10'
|
||||
return 'bg-bg hover:bg-gray-300 hover:bg-opacity-10'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
mouseover() {
|
||||
this.isHovering = true
|
||||
},
|
||||
mouseleave() {
|
||||
this.isHovering = false
|
||||
},
|
||||
playClick() {
|
||||
this.$emit('play', this.item)
|
||||
},
|
||||
removeClick() {
|
||||
this.$emit('remove', this.item)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.queue-item-row-content {
|
||||
max-width: calc(100% - 48px - 128px);
|
||||
}
|
||||
</style>
|
||||
70
client/components/modals/player/QueueItemsModal.vue
Normal file
70
client/components/modals/player/QueueItemsModal.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="queue-items" :width="800" :height="'unset'">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">Player Queue</p>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden py-4" style="max-height: 80vh">
|
||||
<div v-if="show" class="w-full h-full">
|
||||
<div class="pb-4 px-4 flex items-center">
|
||||
<p class="text-base text-gray-200">Player Queue</p>
|
||||
<p class="text-base text-gray-400 px-4">{{ playerQueueItems.length }} Items</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-checkbox v-model="playerQueueAutoPlay" label="Auto Play" medium checkbox-bg="primary" border-color="gray-600" label-class="pl-2 mb-px" />
|
||||
</div>
|
||||
<modals-player-queue-item-row v-for="(item, index) in playerQueueItems" :key="index" :item="item" :index="index" @play="playItem" @remove="removeItem" />
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
libraryItemId: String
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
playerQueueAutoPlay: {
|
||||
get() {
|
||||
return this.$store.state.playerQueueAutoPlay
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('setPlayerQueueAutoPlay', val)
|
||||
}
|
||||
},
|
||||
playerQueueItems() {
|
||||
return this.$store.state.playerQueueItems || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
playItem(item) {
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: item.libraryItemId,
|
||||
episodeId: item.episodeId || null,
|
||||
queueItems: this.playerQueueItems
|
||||
})
|
||||
this.show = false
|
||||
},
|
||||
removeItem(item) {
|
||||
const updatedQueue = this.playerQueueItems.filter((i) => {
|
||||
if (!i.episodeId) return i.libraryItemId !== item.libraryItemId
|
||||
return i.libraryItemId !== item.libraryItemId || i.episodeId !== item.episodeId
|
||||
})
|
||||
this.$store.commit('setPlayerQueueItems', updatedQueue)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -11,11 +11,11 @@
|
||||
v-for="(episode, index) in episodes"
|
||||
:key="index"
|
||||
class="relative"
|
||||
:class="episode.enclosure && itemEpisodeMap[episode.enclosure.url] ? 'bg-primary bg-opacity-40' : selectedEpisodes[String(index)] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
|
||||
@click="toggleSelectEpisode(index)"
|
||||
:class="itemEpisodeMap[episode.enclosure.url] ? 'bg-primary bg-opacity-40' : selectedEpisodes[String(index)] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
|
||||
@click="toggleSelectEpisode(index, episode)"
|
||||
>
|
||||
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
||||
<span v-if="episode.enclosure && itemEpisodeMap[episode.enclosure.url]" class="material-icons text-success text-xl">download_done</span>
|
||||
<span v-if="itemEpisodeMap[episode.enclosure.url]" class="material-icons text-success text-xl">download_done</span>
|
||||
<ui-checkbox v-else v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" />
|
||||
</div>
|
||||
<div class="px-8 py-2">
|
||||
@@ -23,20 +23,13 @@
|
||||
<p class="break-words mb-1">{{ episode.title }}</p>
|
||||
<p v-if="episode.subtitle" class="break-words mb-1 text-sm text-gray-300 episode-subtitle">{{ episode.subtitle }}</p>
|
||||
<p class="text-xs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
||||
<!-- <span class="material-icons cursor-pointer text-lg hover:text-success" @click="saveEpisode(episode)">save</span> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end pt-4">
|
||||
<div class="relative">
|
||||
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
||||
<ui-checkbox v-model="selectAll" small checkbox-bg="primary" border-color="gray-600" :disabled="allDownloaded" />
|
||||
</div>
|
||||
<div class="px-8 py-2">
|
||||
<p :class="!allDownloaded ? 'font-semibold text-gray-200' : 'text-gray-400'">Select all episodes</p>
|
||||
</div>
|
||||
</div>
|
||||
<ui-btn :disabled="!episodesSelected.length" @click="submit">{{ buttonText }}</ui-btn>
|
||||
<ui-checkbox v-if="!allDownloaded" v-model="selectAll" @input="toggleSelectAll" label="Select all episodes" small checkbox-bg="primary" border-color="gray-600" class="mx-8" />
|
||||
<ui-btn v-if="!allDownloaded" :disabled="!episodesSelected.length" @click="submit">{{ buttonText }}</ui-btn>
|
||||
<p v-else class="text-success text-base px-2 py-4">All episodes are downloaded</p>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
@@ -58,7 +51,8 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
selectedEpisodes: {}
|
||||
selectedEpisodes: {},
|
||||
selectAll: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -78,22 +72,12 @@ export default {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
selectAll: {
|
||||
get() {
|
||||
return this.episodesSelected.length == this.episodes.filter((_, index) => !(this.episodes[index].enclosure && this.itemEpisodeMap[this.episodes[index].enclosure.url])).length
|
||||
},
|
||||
set(val) {
|
||||
for (const key in this.selectedEpisodes) {
|
||||
this.selectedEpisodes[key] = val
|
||||
}
|
||||
}
|
||||
},
|
||||
title() {
|
||||
if (!this.libraryItem) return ''
|
||||
return this.libraryItem.media.metadata.title || 'Unknown'
|
||||
},
|
||||
allDownloaded() {
|
||||
return Object.values(this.episodes).filter((episode) => !(episode.enclosure && this.itemEpisodeMap[episode.enclosure.url])).length === 0
|
||||
return !this.episodes.some((episode) => !this.itemEpisodeMap[episode.enclosure.url])
|
||||
},
|
||||
episodesSelected() {
|
||||
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
|
||||
@@ -115,8 +99,27 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleSelectEpisode(index) {
|
||||
toggleSelectAll(val) {
|
||||
for (let i = 0; i < this.episodes.length; i++) {
|
||||
const episode = this.episodes[i]
|
||||
if (this.itemEpisodeMap[episode.enclosure.url]) this.selectedEpisodes[String(i)] = false
|
||||
else this.$set(this.selectedEpisodes, String(i), val)
|
||||
}
|
||||
},
|
||||
checkSetIsSelectedAll() {
|
||||
for (let i = 0; i < this.episodes.length; i++) {
|
||||
const episode = this.episodes[i]
|
||||
if (!this.itemEpisodeMap[episode.enclosure.url] && !this.selectedEpisodes[String(i)]) {
|
||||
this.selectAll = false
|
||||
return
|
||||
}
|
||||
}
|
||||
this.selectAll = true
|
||||
},
|
||||
toggleSelectEpisode(index, episode) {
|
||||
if (this.itemEpisodeMap[episode.enclosure.url]) return
|
||||
this.$set(this.selectedEpisodes, String(index), !this.selectedEpisodes[String(index)])
|
||||
this.checkSetIsSelectedAll()
|
||||
},
|
||||
submit() {
|
||||
var episodesToDownload = []
|
||||
@@ -145,17 +148,15 @@ export default {
|
||||
console.error('Failed to download episodes', error)
|
||||
this.processing = false
|
||||
this.$toast.error(errorMsg)
|
||||
|
||||
this.selectedEpisodes = {}
|
||||
this.selectAll = false
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.episodes.sort((a, b) => (a.publishedAt < b.publishedAt) ? 1 : -1)
|
||||
for (let i = 0; i < this.episodes.length; i++) {
|
||||
var episode = this.episodes[i]
|
||||
if (episode.enclosure && !this.itemEpisodeMap[episode.enclosure.url]) {
|
||||
// Do not include episodes already downloaded
|
||||
this.$set(this.selectedEpisodes, String(i), false)
|
||||
}
|
||||
}
|
||||
this.episodes.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
|
||||
this.selectAll = false
|
||||
this.selectedEpisodes = {}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
@@ -170,4 +171,4 @@ export default {
|
||||
#episodes-scroll {
|
||||
max-height: calc(80vh - 200px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="new-podcast-modal" :width="1000" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
<div class="absolute top-0 left-0 p-5 w-3/4 overflow-hidden">
|
||||
<p class="font-book text-xl md:text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="wrapper" id="podcast-wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||
<div class="w-full p-4">
|
||||
<p class="text-lg font-semibold mb-2">Details</p>
|
||||
<div ref="wrapper" id="podcast-wrapper" class="p-2 md:p-8 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-x-hidden overflow-y-auto" style="max-height: 80vh">
|
||||
<div class="w-full">
|
||||
<p class="text-lg font-semibold mb-2 px-2">Details</p>
|
||||
|
||||
<div v-if="podcast.imageUrl" class="p-1 w-full">
|
||||
<div v-if="podcast.imageUrl" class="p-2 w-full">
|
||||
<img :src="podcast.imageUrl" class="h-16 w-16 object-contain" />
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div class="flex flex-wrap">
|
||||
<div class="w-full md:w-1/2 p-2">
|
||||
<ui-text-input-with-label v-model="podcast.title" label="Title" @input="titleUpdated" />
|
||||
</div>
|
||||
@@ -20,7 +20,7 @@
|
||||
<ui-text-input-with-label v-model="podcast.author" label="Author" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div class="flex flex-wrap">
|
||||
<div class="w-full md:w-1/2 p-2">
|
||||
<ui-text-input-with-label v-model="podcast.feedUrl" label="Feed URL" readonly />
|
||||
</div>
|
||||
@@ -31,19 +31,19 @@
|
||||
<div class="p-2 w-full">
|
||||
<ui-textarea-with-label v-model="podcast.description" label="Description" :rows="3" />
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div class="flex flex-wrap">
|
||||
<div class="w-full md:w-1/2 p-2">
|
||||
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="processing" label="Folder" @input="folderUpdated" />
|
||||
</div>
|
||||
<div class="w-full md:w-1/2 p-2">
|
||||
<ui-text-input-with-label v-model="fullPath" label="Podcast Path" readonly />
|
||||
<ui-text-input-with-label v-model="fullPath" label="Podcast Path" input-class="h-10" readonly />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center py-4">
|
||||
<div class="flex items-center py-4 px-2">
|
||||
<div class="flex-grow" />
|
||||
<div class="px-4">
|
||||
<ui-checkbox v-model="podcast.autoDownloadEpisodes" label="Auto Download Episodes" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
<ui-checkbox v-model="podcast.autoDownloadEpisodes" label="Auto Download Episodes" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-sm md:text-base font-semibold" />
|
||||
</div>
|
||||
<ui-btn color="success" @click="submit">Add Podcast</ui-btn>
|
||||
</div>
|
||||
|
||||
@@ -7,16 +7,17 @@
|
||||
</template>
|
||||
<div ref="wrapper" class="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||
<div class="mb-4">
|
||||
<p class="text-lg text-gray-200 mb-4">
|
||||
<p v-if="episode" class="text-lg text-gray-200 mb-4">
|
||||
Are you sure you want to remove episode<br /><span class="text-base">{{ episodeTitle }}</span
|
||||
>?
|
||||
</p>
|
||||
<p v-else class="text-lg text-gray-200 mb-4">Are you sure you want to remove {{ episodes.length }} episodes?</p>
|
||||
<p class="text-xs font-semibold text-warning text-opacity-90">Note: This does not delete the audio file unless toggling "Hard delete file"</p>
|
||||
</div>
|
||||
<div class="flex justify-between items-center pt-4">
|
||||
<ui-checkbox v-model="hardDeleteFile" label="Hard delete file" check-color="error" checkbox-bg="bg" small label-class="text-base text-gray-200 pl-3" />
|
||||
|
||||
<ui-btn @click="submit">{{ hardDeleteFile ? 'Delete episode' : 'Remove episode' }}</ui-btn>
|
||||
<ui-btn @click="submit">{{ btnText }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
@@ -30,9 +31,9 @@ export default {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
episode: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
episodes: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -55,34 +56,49 @@ export default {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
episode() {
|
||||
if (this.episodes.length === 1) return this.episodes[0]
|
||||
return null
|
||||
},
|
||||
title() {
|
||||
if (this.episodes.length > 1) return `Remove ${this.episodes.length} episodes`
|
||||
return 'Remove Episode'
|
||||
},
|
||||
episodeId() {
|
||||
return this.episode ? this.episode.id : null
|
||||
btnText() {
|
||||
if (this.episodes.length > 1) return this.hardDeleteFile ? `Delete ${this.episodes.length} episodes` : `Remove ${this.episodes.length} episodes`
|
||||
return this.hardDeleteFile ? 'Delete episode' : 'Remove episode'
|
||||
},
|
||||
episodeTitle() {
|
||||
return this.episode ? this.episode.title : null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submit() {
|
||||
async submit() {
|
||||
this.processing = true
|
||||
|
||||
var queryString = this.hardDeleteFile ? '?hard=1' : ''
|
||||
this.$axios
|
||||
.$delete(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}${queryString}`)
|
||||
.then(() => {
|
||||
for (const episode of this.episodes) {
|
||||
const success = await this.$axios
|
||||
.$delete(`/api/podcasts/${this.libraryItem.id}/episode/${episode.id}${queryString}`)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to remove episode'
|
||||
console.error('Failed to remove episode', error)
|
||||
this.$toast.error(errorMsg)
|
||||
return false
|
||||
})
|
||||
if (!success) {
|
||||
this.processing = false
|
||||
this.$toast.success('Podcast episode removed')
|
||||
this.show = false
|
||||
})
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed remove episode'
|
||||
console.error('Failed update episode', error)
|
||||
this.processing = false
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
this.$emit('clearSelected')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.processing = false
|
||||
this.$toast.success(`${this.episodes.length} episode${this.episodes.length > 1 ? 's' : ''} removed`)
|
||||
this.show = false
|
||||
this.$emit('clearSelected')
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
||||
@@ -66,7 +66,7 @@ export default {
|
||||
return this.mediaMetadata.author
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
|
||||
@@ -23,9 +23,16 @@
|
||||
<ui-rich-text-editor label="Description" v-model="newEpisode.description" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end pt-4">
|
||||
<div class="flex items-center justify-end pt-4">
|
||||
<ui-btn @click="submit">Submit</ui-btn>
|
||||
</div>
|
||||
<div v-if="enclosureUrl" class="py-4">
|
||||
<p class="text-xs text-gray-300 font-semibold">Episode URL from RSS feed</p>
|
||||
<a :href="enclosureUrl" target="_blank" class="text-xs text-blue-400 hover:text-blue-500 hover:underline">{{ enclosureUrl }}</a>
|
||||
</div>
|
||||
<div v-else class="py-4">
|
||||
<p class="text-xs text-gray-300 font-semibold">Episode not linked to RSS feed episode</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -76,6 +83,12 @@ export default {
|
||||
},
|
||||
episodeId() {
|
||||
return this.episode ? this.episode.id : null
|
||||
},
|
||||
enclosure() {
|
||||
return this.episode ? this.episode.enclosure || {} : {}
|
||||
},
|
||||
enclosureUrl() {
|
||||
return this.enclosure.url
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -117,7 +117,7 @@ export default {
|
||||
serverAddress: window.origin,
|
||||
slug: this.newFeedSlug
|
||||
}
|
||||
if (this.$isDev) payload.serverAddress = 'http://localhost:3333'
|
||||
if (this.$isDev) payload.serverAddress = `http://localhost:3333${this.$config.routerBasePath}`
|
||||
|
||||
console.log('Payload', payload)
|
||||
this.$axios
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
<span class="material-icons text-2xl sm:text-3xl transform transition-transform" :class="useChapterTrack ? 'rotate-180' : ''">timelapse</span>
|
||||
</div>
|
||||
</ui-tooltip>
|
||||
|
||||
<button v-if="playerQueueItems.length" class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showPlayerQueueItems')">
|
||||
<span class="material-icons text-2xl sm:text-3xl">playlist_play</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate.sync="playbackRate" :paused="paused" :has-next-chapter="hasNextChapter" @prevChapter="prevChapter" @nextChapter="nextChapter" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
|
||||
@@ -138,6 +142,9 @@ export default {
|
||||
hasNextChapter() {
|
||||
if (!this.chapters.length) return false
|
||||
return this.currentChapterIndex < this.chapters.length - 1
|
||||
},
|
||||
playerQueueItems() {
|
||||
return this.$store.state.playerQueueItems || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
||||
<div ref="content" class="relative text-white" :style="{ height: modalHeight, width: modalWidth }" v-click-outside="clickedOutside">
|
||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
||||
<p class="text-base mb-8 mt-2 px-1">{{ message }}</p>
|
||||
<p class="text-lg mb-8 mt-2 px-1" v-html="message" />
|
||||
<div class="flex px-1 items-center">
|
||||
<ui-btn v-if="isYesNo" color="primary" @click="nevermind">Cancel</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
|
||||
@@ -96,7 +96,9 @@ export default {
|
||||
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
|
||||
var relPath = this.ebookFile.metadata.relPath
|
||||
if (relPath.startsWith('/')) relPath = relPath.slice(1)
|
||||
return `/ebook/${this.libraryId}/${this.folderId}/${itemRelPath}/${relPath}`
|
||||
|
||||
const relRelPath = this.$encodeUriPath(`${itemRelPath}/${relPath}`)
|
||||
return `/ebook/${this.libraryId}/${this.folderId}/${relRelPath}`
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<td class="hidden sm:table-cell font-mono md:text-sm text-xs">{{ $bytesPretty(backup.fileSize) }}</td>
|
||||
<td>
|
||||
<div class="w-full flex flex-row items-center justify-center">
|
||||
<ui-btn v-if="backup.serverVersion" small color="primary" @click="applyBackup(backup)">Apply</ui-btn>
|
||||
<ui-btn v-if="backup.serverVersion" small color="primary" @click="applyBackup(backup)">Restore</ui-btn>
|
||||
|
||||
<a v-if="backup.serverVersion" :href="`/metadata/${$encodeUriPath(backup.path)}?token=${userToken}`" class="mx-1 pt-1 hover:text-opacity-100 text-opacity-70 text-white" download><span class="material-icons text-xl">download</span></a>
|
||||
<ui-tooltip v-else text="This backup was created with an old version of audiobookshelf no longer supported" direction="bottom" class="mx-2 flex items-center">
|
||||
@@ -45,7 +45,7 @@
|
||||
<div v-if="selectedBackup" class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
||||
<p class="text-error text-lg font-semibold">Important Notice!</p>
|
||||
<p class="text-base py-1">Applying a backup will overwrite users, user progress, book details, settings, and covers stored in metadata with the backed up data.</p>
|
||||
<p class="text-base py-1">Backups <strong>do not</strong> modify any files in your library folders, only data in the audiobookshelf created <span class="font-mono">/config</span> and <span class="font-mono">/metadata</span> directories. If you have enabled server settings to store cover art and metadata in your library folders then those are not backup up or overwritten.</p>
|
||||
<p class="text-base py-1">Backups <strong>do not</strong> modify any files in your library folders, only data in the audiobookshelf created <span class="font-mono">/config</span> and <span class="font-mono">/metadata</span> directories. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.</p>
|
||||
<p class="text-base py-1">All clients using your server will be automatically refreshed.</p>
|
||||
|
||||
<p class="text-lg text-center my-8">Are you sure you want to apply the backup created on {{ selectedBackup.datePretty }}?</p>
|
||||
|
||||
@@ -52,11 +52,8 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
coverAspectRatio() {
|
||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
totalDuration() {
|
||||
var _total = 0
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
</div>
|
||||
</td>
|
||||
<td v-if="userCanDownload && !isMissing" class="text-center">
|
||||
<a :href="`/s/item/${libraryItemId}/${$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
||||
<a :href="`${$config.routerBasePath}/s/item/${libraryItemId}/${$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
@@ -24,6 +24,14 @@
|
||||
<th class="text-left w-20">Size</th>
|
||||
<th class="text-left w-20">Duration</th>
|
||||
<th v-if="userCanDownload" class="text-center w-20">Download</th>
|
||||
<th v-if="showExperimentalFeatures" class="text-center w-20">
|
||||
<div class="flex items-center">
|
||||
<p>Tone</p>
|
||||
<ui-tooltip text="Experimental feature for testing Tone library metadata scan results. Results logged in browser console." class="ml-2 w-2" direction="left">
|
||||
<span class="material-icons-outlined text-sm">information</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
<template v-for="track in tracks">
|
||||
<tr :key="track.index">
|
||||
@@ -38,7 +46,10 @@
|
||||
{{ $secondsToTimestamp(track.duration) }}
|
||||
</td>
|
||||
<td v-if="userCanDownload" class="text-center">
|
||||
<a :href="`/s/item/${libraryItemId}/${$encodeUriPath(track.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
||||
<a :href="`${$config.routerBasePath}/s/item/${libraryItemId}/${$encodeUriPath(track.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text pt-1">download</span></a>
|
||||
</td>
|
||||
<td v-if="showExperimentalFeatures" class="text-center">
|
||||
<ui-icon-btn borderless :loading="toneProbing" icon="search" @click="toneProbe(track.index)" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
@@ -65,7 +76,8 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
showTracks: false,
|
||||
showFullPath: false
|
||||
showFullPath: false,
|
||||
toneProbing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -77,11 +89,35 @@ export default {
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickBar() {
|
||||
this.showTracks = !this.showTracks
|
||||
},
|
||||
toneProbe(index) {
|
||||
this.toneProbing = true
|
||||
|
||||
this.$axios
|
||||
.$post(`/api/items/${this.libraryItemId}/tone-scan/${index}`)
|
||||
.then((data) => {
|
||||
console.log('Tone probe data', data)
|
||||
if (data.error) {
|
||||
this.$toast.error('Tone probe error: ' + data.error)
|
||||
} else {
|
||||
this.$toast.success('Tone probe successful! Check browser console')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to tone probe', error)
|
||||
this.$toast.error('Tone probe failed')
|
||||
})
|
||||
.finally(() => {
|
||||
this.toneProbing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
||||
@@ -164,8 +164,8 @@ export default {
|
||||
}
|
||||
|
||||
if (this.$root.socket) {
|
||||
this.$root.socket.off('user_added', this.newUserAdded)
|
||||
this.$root.socket.off('user_updated', this.userUpdated)
|
||||
this.$root.socket.off('user_added', this.addUpdateUser)
|
||||
this.$root.socket.off('user_updated', this.addUpdateUser)
|
||||
this.$root.socket.off('user_removed', this.userRemoved)
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,6 @@ export default {
|
||||
font-weight: 600;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
background-color: #272727
|
||||
background-color: #272727;
|
||||
}
|
||||
</style>
|
||||
@@ -27,15 +27,15 @@
|
||||
<span class="material-icons text-lg text-white text-opacity-70 hover:text-opacity-100 cursor-pointer">radio_button_unchecked</span>
|
||||
</div> -->
|
||||
</div>
|
||||
<div class="w-40 absolute top-0 -right-24 h-full transform transition-transform" :class="!isHovering ? 'translate-x-0' : '-translate-x-24'">
|
||||
<div class="w-40 absolute top-0 -right-24 h-full transform transition-transform" :class="!isHovering ? 'translate-x-0' : translateDistance">
|
||||
<div class="flex h-full items-center">
|
||||
<ui-tooltip :text="userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished'" direction="top">
|
||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
||||
</ui-tooltip>
|
||||
<div class="mx-1" :class="isHovering ? '' : 'ml-6'">
|
||||
<div v-if="userCanUpdate" class="mx-1" :class="isHovering ? '' : 'ml-6'">
|
||||
<ui-icon-btn icon="edit" borderless @click="clickEdit" />
|
||||
</div>
|
||||
<div class="mx-1">
|
||||
<div v-if="userCanDelete" class="mx-1">
|
||||
<ui-icon-btn icon="close" borderless @click="removeClick" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,6 +71,11 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
translateDistance() {
|
||||
if (!this.userCanUpdate && !this.userCanDelete) return 'translate-x-0'
|
||||
else if (!this.userCanUpdate || !this.userCanDelete) return '-translate-x-12'
|
||||
return '-translate-x-24'
|
||||
},
|
||||
media() {
|
||||
return this.book.media || {}
|
||||
},
|
||||
@@ -113,6 +118,12 @@ export default {
|
||||
coverWidth() {
|
||||
if (this.bookCoverAspectRatio === 1) return this.coverSize * 1.6
|
||||
return this.coverSize
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div v-if="episode" class="flex items-center h-24 cursor-pointer" @click="$emit('view', episode)">
|
||||
<div v-if="episode" class="flex items-center cursor-pointer" :class="{ 'opacity-70': isSelected || selectionMode }" @click="clickedEpisode">
|
||||
<div class="flex-grow px-2">
|
||||
<p class="text-sm font-semibold">
|
||||
{{ title }}
|
||||
@@ -8,6 +8,12 @@
|
||||
|
||||
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">{{ subtitle }}</p>
|
||||
|
||||
<div class="flex justify-between pt-2 max-w-xl">
|
||||
<p v-if="episode.season" class="text-sm text-gray-300">Season #{{ episode.season }}</p>
|
||||
<p v-if="episode.episode" class="text-sm text-gray-300">Episode #{{ episode.episode }}</p>
|
||||
<p v-if="publishedAt" class="text-sm text-gray-300">Published {{ $formatDate(publishedAt, 'MMM do, yyyy') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center pt-2">
|
||||
<button class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer focus:outline-none" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick">
|
||||
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||
@@ -17,20 +23,18 @@
|
||||
<ui-tooltip :text="userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished'" direction="top">
|
||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
||||
</ui-tooltip>
|
||||
<p v-if="episode.season" class="px-4 text-sm text-gray-300">Season #{{ episode.season }}</p>
|
||||
<p v-if="episode.episode" class="px-4 text-sm text-gray-300">Episode #{{ episode.episode }}</p>
|
||||
<p v-if="publishedAt" class="px-4 text-sm text-gray-300">Published {{ $formatDate(publishedAt, 'MMM do, yyyy') }}</p>
|
||||
|
||||
<ui-icon-btn v-if="userCanUpdate" icon="edit" borderless @click="clickEdit" />
|
||||
<ui-icon-btn v-if="userCanDelete" icon="close" borderless @click="removeClick" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-24 min-w-24" />
|
||||
<div v-if="isHovering || isSelected || selectionMode" class="hidden md:block w-12 min-w-12" />
|
||||
</div>
|
||||
<div class="w-24 min-w-24 -right-0 absolute top-0 h-full transform transition-transform" :class="!isHovering ? 'translate-x-32' : 'translate-x-0'">
|
||||
<div v-if="isSelected || selectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-10 z-10 cursor-pointer" @click.stop="clickedSelectionBg" />
|
||||
<div class="hidden md:block md:w-12 md:min-w-12 md:-right-0 md:absolute md:top-0 h-full transform transition-transform z-20" :class="!isHovering && !isSelected && !selectionMode ? 'translate-x-24' : 'translate-x-0'">
|
||||
<div class="flex h-full items-center">
|
||||
<div class="mx-1">
|
||||
<ui-icon-btn v-if="userCanUpdate" icon="edit" borderless @click="clickEdit" />
|
||||
</div>
|
||||
<div class="mx-1">
|
||||
<ui-icon-btn v-if="userCanDelete" icon="close" borderless @click="removeClick" />
|
||||
<ui-checkbox v-model="isSelected" @input="selectedUpdated" checkbox-bg="bg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,13 +50,15 @@ export default {
|
||||
episode: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
selectionMode: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isProcessingReadUpdate: false,
|
||||
processingRemove: false,
|
||||
isHovering: false
|
||||
isHovering: false,
|
||||
isSelected: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -104,8 +110,17 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickedEpisode() {
|
||||
this.$emit('view', this.episode)
|
||||
},
|
||||
clickedSelectionBg() {
|
||||
this.isSelected = !this.isSelected
|
||||
this.selectedUpdated(this.isSelected)
|
||||
},
|
||||
selectedUpdated(value) {
|
||||
this.$emit('selected', { isSelected: value, episode: this.episode })
|
||||
},
|
||||
mouseover() {
|
||||
// if (this.isDragging) return
|
||||
this.isHovering = true
|
||||
},
|
||||
mouseleave() {
|
||||
@@ -118,10 +133,7 @@ export default {
|
||||
if (this.streamIsPlaying) {
|
||||
this.$eventBus.$emit('pause-item')
|
||||
} else {
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: this.libraryItemId,
|
||||
episodeId: this.episode.id
|
||||
})
|
||||
this.$emit('play', this.episode)
|
||||
}
|
||||
},
|
||||
toggleFinished(confirmed = false) {
|
||||
|
||||
@@ -3,14 +3,21 @@
|
||||
<div class="flex items-center mb-4">
|
||||
<p class="text-lg mb-0 font-semibold">Episodes</p>
|
||||
<div class="flex-grow" />
|
||||
<controls-episode-sort-select v-model="sortKey" :descending.sync="sortDesc" class="w-36 sm:w-44 md:w-48 h-9 ml-1 sm:ml-4" />
|
||||
<template v-if="isSelectionMode">
|
||||
<ui-tooltip :text="`Mark as ${selectedIsFinished ? 'Not Finished' : 'Finished'}`" direction="bottom">
|
||||
<ui-read-icon-btn :disabled="processing" :is-read="selectedIsFinished" @click="toggleBatchFinished" class="mx-1.5" />
|
||||
</ui-tooltip>
|
||||
<ui-btn color="error" :disabled="processing" small class="h-9" @click="removeSelectedEpisodes">Remove {{ selectedEpisodes.length }} episode{{ selectedEpisodes.length > 1 ? 's' : '' }}</ui-btn>
|
||||
<ui-btn :disabled="processing" small class="ml-2 h-9" @click="clearSelected">Cancel</ui-btn>
|
||||
</template>
|
||||
<controls-episode-sort-select v-else v-model="sortKey" :descending.sync="sortDesc" class="w-36 sm:w-44 md:w-48 h-9 ml-1 sm:ml-4" />
|
||||
</div>
|
||||
<p v-if="!episodes.length" class="py-4 text-center text-lg">No Episodes</p>
|
||||
<template v-for="episode in episodesSorted">
|
||||
<tables-podcast-episode-table-row :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" class="item" @remove="removeEpisode" @edit="editEpisode" @view="viewEpisode" />
|
||||
<tables-podcast-episode-table-row ref="episodeRow" :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" :selection-mode="isSelectionMode" class="item" @play="playEpisode" @remove="removeEpisode" @edit="editEpisode" @view="viewEpisode" @selected="episodeSelected" />
|
||||
</template>
|
||||
|
||||
<modals-podcast-remove-episode v-model="showPodcastRemoveModal" :library-item="libraryItem" :episode="selectedEpisode" />
|
||||
<modals-podcast-remove-episode v-model="showPodcastRemoveModal" @input="removeEpisodeModalToggled" :library-item="libraryItem" :episodes="episodesToRemove" @clearSelected="clearSelected" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -28,7 +35,10 @@ export default {
|
||||
sortKey: 'publishedAt',
|
||||
sortDesc: true,
|
||||
selectedEpisode: null,
|
||||
showPodcastRemoveModal: false
|
||||
showPodcastRemoveModal: false,
|
||||
selectedEpisodes: [],
|
||||
episodesToRemove: [],
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -37,6 +47,9 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isSelectionMode() {
|
||||
return this.selectedEpisodes.length > 0
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
@@ -56,11 +69,92 @@ export default {
|
||||
}
|
||||
return String(a[this.sortKey]).localeCompare(String(b[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
|
||||
})
|
||||
},
|
||||
selectedIsFinished() {
|
||||
// Find an item that is not finished, if none then all items finished
|
||||
return !this.selectedEpisodes.find((episode) => {
|
||||
var itemProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id)
|
||||
return !itemProgress || !itemProgress.isFinished
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleBatchFinished() {
|
||||
this.processing = true
|
||||
var newIsFinished = !this.selectedIsFinished
|
||||
var updateProgressPayloads = this.selectedEpisodes.map((episode) => {
|
||||
return {
|
||||
libraryItemId: this.libraryItem.id,
|
||||
episodeId: episode.id,
|
||||
isFinished: newIsFinished
|
||||
}
|
||||
})
|
||||
|
||||
this.$axios
|
||||
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
|
||||
.then(() => {
|
||||
this.$toast.success('Batch update success!')
|
||||
this.processing = false
|
||||
this.clearSelected()
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error('Batch update failed')
|
||||
console.error('Failed to batch update read/not read', error)
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
removeEpisodeModalToggled(val) {
|
||||
if (!val) this.episodesToRemove = []
|
||||
},
|
||||
clearSelected() {
|
||||
const episodeRows = this.$refs.episodeRow
|
||||
if (episodeRows && episodeRows.length) {
|
||||
for (const epRow of episodeRows) {
|
||||
if (epRow) epRow.isSelected = false
|
||||
}
|
||||
}
|
||||
this.selectedEpisodes = []
|
||||
},
|
||||
removeSelectedEpisodes() {
|
||||
this.episodesToRemove = this.selectedEpisodes
|
||||
this.showPodcastRemoveModal = true
|
||||
},
|
||||
episodeSelected({ isSelected, episode }) {
|
||||
if (isSelected) {
|
||||
this.selectedEpisodes.push(episode)
|
||||
} else {
|
||||
this.selectedEpisodes = this.selectedEpisodes.filter((ep) => ep.id !== episode.id)
|
||||
}
|
||||
},
|
||||
playEpisode(episode) {
|
||||
const queueItems = []
|
||||
|
||||
const episodesInListeningOrder = this.episodesCopy.map((ep) => ({ ...ep })).sort((a, b) => String(a.publishedAt).localeCompare(String(b.publishedAt), undefined, { numeric: true, sensitivity: 'base' }))
|
||||
const episodeIndex = episodesInListeningOrder.findIndex((e) => e.id === episode.id)
|
||||
for (let i = episodeIndex; i < episodesInListeningOrder.length; i++) {
|
||||
const episode = episodesInListeningOrder[i]
|
||||
const podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id)
|
||||
if (!podcastProgress || !podcastProgress.isFinished) {
|
||||
queueItems.push({
|
||||
libraryItemId: this.libraryItem.id,
|
||||
episodeId: episode.id,
|
||||
title: episode.title,
|
||||
subtitle: this.mediaMetadata.title,
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
|
||||
duration: episode.audioFile.duration || null,
|
||||
coverPath: this.media.coverPath || null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: this.libraryItem.id,
|
||||
episodeId: episode.id,
|
||||
queueItems
|
||||
})
|
||||
},
|
||||
removeEpisode(episode) {
|
||||
this.selectedEpisode = episode
|
||||
this.episodesToRemove = [episode]
|
||||
this.showPodcastRemoveModal = true
|
||||
},
|
||||
editEpisode(episode) {
|
||||
|
||||
@@ -14,6 +14,7 @@ export default {
|
||||
value: Boolean,
|
||||
label: String,
|
||||
small: Boolean,
|
||||
medium: Boolean,
|
||||
checkboxBg: {
|
||||
type: String,
|
||||
default: 'white'
|
||||
@@ -47,19 +48,23 @@ export default {
|
||||
wrapperClass() {
|
||||
var classes = [`bg-${this.checkboxBg} border-${this.borderColor}`]
|
||||
if (this.small) classes.push('w-4 h-4')
|
||||
else if (this.medium) classes.push('w-5 h-5')
|
||||
else classes.push('w-6 h-6')
|
||||
|
||||
return classes.join(' ')
|
||||
},
|
||||
labelClassname() {
|
||||
if (this.labelClass) return this.labelClass
|
||||
var classes = ['pl-1']
|
||||
if (this.small) classes.push('text-xs md:text-sm')
|
||||
var classes = []
|
||||
if (this.small) classes.push('text-xs md:text-sm pl-1')
|
||||
else if (this.medium) classes.push('text-base md:text-lg pl-2')
|
||||
else classes.push('pl-2')
|
||||
return classes.join(' ')
|
||||
},
|
||||
svgClass() {
|
||||
var classes = [`text-${this.checkColor}`]
|
||||
if (this.small) classes.push('w-3 h-3')
|
||||
else if (this.medium) classes.push('w-3.5 h-3.5')
|
||||
else classes.push('w-4 h-4')
|
||||
|
||||
return classes.join(' ')
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
<p class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||
<button type="button" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||
<span class="flex items-center">
|
||||
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText }}</span>
|
||||
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</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>
|
||||
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<span class="material-icons">expand_more</span>
|
||||
@@ -15,7 +17,9 @@
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate font-sans text-sm">{{ item.text }}</span>
|
||||
<span class="ml-3 block truncate font-sans text-sm" :class="{ 'font-semibold': item.subtext }">{{ item.text }}</span>
|
||||
<span v-if="item.subtext">: </span>
|
||||
<span v-if="item.subtext" class="font-normal block truncate font-sans text-sm text-gray-400">{{ item.subtext }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
@@ -64,6 +68,9 @@ export default {
|
||||
selectedText() {
|
||||
return this.selectedItem ? this.selectedItem.text : ''
|
||||
},
|
||||
selectedSubtext() {
|
||||
return this.selectedItem ? this.selectedItem.subtext : ''
|
||||
},
|
||||
buttonClass() {
|
||||
var classes = []
|
||||
if (this.small) classes.push('h-9')
|
||||
|
||||
@@ -54,7 +54,7 @@ export default {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
this.$emit('click')
|
||||
this.$emit('click', e)
|
||||
e.stopPropagation()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div v-if="currentLibrary" class="relative h-8 max-w-52 min-w-32" v-click-outside="clickOutsideObj">
|
||||
<div v-if="currentLibrary" class="relative h-8 max-w-52 md:min-w-32" v-click-outside="clickOutsideObj">
|
||||
<button type="button" :disabled="disabled" class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<div class="flex items-center justify-center sm:justify-start">
|
||||
<widgets-library-icon :icon="currentLibraryIcon" class="sm:mr-1.5" />
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<form @submit.prevent="submitForm">
|
||||
<div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div v-for="item in selected" :key="item" 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 class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
|
||||
<div v-if="!disabled" class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 px-1 bg-bg bg-opacity-75 flex items-center justify-end">
|
||||
<span v-if="showEdit" class="material-icons text-white hover:text-warning" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</span>
|
||||
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
|
||||
</div>
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
<form @submit.prevent="submitForm">
|
||||
<div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-0.5" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div v-for="item in selected" :key="item.id" class="rounded-full px-2 py-0.5 m-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center justify-center relative min-w-12">
|
||||
<div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
|
||||
<div v-if="!disabled" class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
|
||||
<span v-if="showEdit" class="material-icons text-white hover:text-warning mr-1" style="font-size: 1rem" @click.stop="editItem(item)">edit</span>
|
||||
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item.id)">close</span>
|
||||
</div>
|
||||
{{ item[textKey] }}
|
||||
</div>
|
||||
<div v-if="showEdit" class="rounded-full cursor-pointer w-6 h-6 mx-0.5 bg-bg flex items-center justify-center">
|
||||
<div v-if="showEdit && !disabled" class="rounded-full cursor-pointer w-6 h-6 mx-0.5 bg-bg flex items-center justify-center">
|
||||
<span class="material-icons text-white hover:text-success pt-px pr-px" style="font-size: 1.1rem" @click.stop="addItem">add</span>
|
||||
</div>
|
||||
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
|
||||
|
||||
@@ -36,7 +36,8 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
showPassword: false,
|
||||
isHovering: false
|
||||
isHovering: false,
|
||||
isFocused: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -66,9 +67,11 @@ export default {
|
||||
this.inputValue = ''
|
||||
},
|
||||
focused() {
|
||||
this.isFocused = true
|
||||
this.$emit('focus')
|
||||
},
|
||||
blurred() {
|
||||
this.isFocused = false
|
||||
this.$emit('blur')
|
||||
},
|
||||
change(e) {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }">
|
||||
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
||||
</p>
|
||||
<ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" @blur="inputBlurred" />
|
||||
<slot>
|
||||
<p class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }">
|
||||
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
||||
</p>
|
||||
</slot>
|
||||
<ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" :class="inputClass" @blur="inputBlurred" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -18,7 +20,8 @@ export default {
|
||||
default: 'text'
|
||||
},
|
||||
readonly: Boolean,
|
||||
disabled: Boolean
|
||||
disabled: Boolean,
|
||||
inputClass: String
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
|
||||
@@ -41,13 +41,6 @@ export default {
|
||||
this.setTooltipPosition(this.tooltip)
|
||||
}
|
||||
},
|
||||
getTextWidth() {
|
||||
var styles = {
|
||||
'font-size': '0.75rem'
|
||||
}
|
||||
var size = this.$calculateTextSize(this.text, styles)
|
||||
return size.width
|
||||
},
|
||||
createTooltip() {
|
||||
if (!this.$refs.box) return
|
||||
var tooltip = document.createElement('div')
|
||||
|
||||
@@ -46,7 +46,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
cardScaleMulitiplier() {
|
||||
return this.height / 192
|
||||
|
||||
312
client/components/widgets/CronExpressionBuilder.vue
Normal file
312
client/components/widgets/CronExpressionBuilder.vue
Normal file
@@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<div class="w-full py-2">
|
||||
<div class="flex -mb-px">
|
||||
<div class="w-1/2 h-8 rounded-tl-md relative border border-black-200 flex items-center justify-center cursor-pointer" :class="!showAdvancedView ? 'text-white bg-bg hover:bg-opacity-60 border-b-bg' : 'text-gray-400 hover:text-gray-300 bg-primary bg-opacity-70 hover:bg-opacity-60'" @click="showAdvancedView = false">
|
||||
<p class="text-sm">Scheduler</p>
|
||||
</div>
|
||||
<div class="w-1/2 h-8 rounded-tr-md relative border border-black-200 flex items-center justify-center -ml-px cursor-pointer" :class="showAdvancedView ? 'text-white bg-bg hover:bg-opacity-60 border-b-bg' : 'text-gray-400 hover:text-gray-300 bg-primary bg-opacity-70 hover:bg-opacity-60'" @click="showAdvancedView = true">
|
||||
<p class="text-sm">Advanced</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2 py-4 md:p-4 border border-black-200 rounded-b-md mr-px" style="min-height: 280px">
|
||||
<template v-if="!showAdvancedView">
|
||||
<ui-dropdown v-model="selectedInterval" @input="updateCron" label="Interval" :items="intervalOptions" class="mb-2" />
|
||||
|
||||
<ui-multi-select-dropdown v-if="selectedInterval === 'custom'" v-model="selectedWeekdays" @input="updateCron" label="Weekdays to run" :items="weekdays" />
|
||||
|
||||
<div v-if="(selectedWeekdays.length && selectedInterval === 'custom') || selectedInterval === 'daily'" class="flex items-center py-2">
|
||||
<ui-text-input-with-label v-model="selectedHour" @input="updateCron" @blur="hourBlur" type="number" label="Hour" class="max-w-20" />
|
||||
<p class="text-xl px-2 mt-4">:</p>
|
||||
<ui-text-input-with-label v-model="selectedMinute" @input="updateCron" @blur="minuteBlur" type="number" label="Minute" class="max-w-20" />
|
||||
</div>
|
||||
|
||||
<div v-if="description" class="w-full bg-primary bg-opacity-75 rounded-xl p-2 md:p-4 text-center mt-2">
|
||||
<p class="text-base md:text-lg text-gray-200" v-html="description" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="px-1 text-sm font-semibold">Cron Expression</p>
|
||||
<ui-text-input ref="customExpressionInput" v-model="customCronExpression" @blur="cronExpressionBlur" label="Cron Expression" :padding-y="2" text-center class="w-full text-2xl md:text-4xl -tracking-widest mb-4 font-mono" />
|
||||
|
||||
<div class="flex items-center justify-center">
|
||||
<widgets-loading-spinner v-if="isValidating" class="mr-2" />
|
||||
<span v-else class="material-icons-outlined mr-2 text-xl" :class="isValid ? 'text-success' : 'text-error'">{{ isValid ? 'check_circle_outline' : 'error_outline' }}</span>
|
||||
<p v-if="isValidating" class="text-gray-300 text-base md:text-lg text-center">Checking cron...</p>
|
||||
<p v-else-if="customCronError" class="text-error text-base md:text-lg text-center">{{ customCronError }}</p>
|
||||
<p v-else class="text-success text-base md:text-lg text-center">Valid cron expression</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedInterval: 'custom',
|
||||
showAdvancedView: false,
|
||||
selectedHour: 0,
|
||||
selectedMinute: 0,
|
||||
selectedWeekdays: [],
|
||||
cronExpression: '0 0 * * *',
|
||||
customCronExpression: '0 0 * * *',
|
||||
customCronError: '',
|
||||
isValidating: false,
|
||||
validatedCron: null,
|
||||
isValid: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
minuteIsValid() {
|
||||
return !(isNaN(this.selectedMinute) || this.selectedMinute === '' || this.selectedMinute < 0 || this.selectedMinute > 59)
|
||||
},
|
||||
hourIsValid() {
|
||||
return !(isNaN(this.selectedHour) || this.selectedHour === '' || this.selectedHour < 0 || this.selectedHour > 23)
|
||||
},
|
||||
description() {
|
||||
if ((this.selectedInterval !== 'custom' || !this.selectedWeekdays.length) && this.selectedInterval !== 'daily') return ''
|
||||
|
||||
if (!this.hourIsValid) {
|
||||
return `<span class="text-error">Invalid hour must be 0-23 | ${this.selectedHour < 0 || this.selectedHour > 23}</span>`
|
||||
}
|
||||
if (!this.minuteIsValid) {
|
||||
return `<span class="text-error">Invalid minute must be 0-59</span>`
|
||||
}
|
||||
|
||||
var description = 'Run every '
|
||||
var weekdayTexts = ''
|
||||
if (this.selectedWeekdays.length === 7 || this.selectedInterval === 'daily') {
|
||||
weekdayTexts = 'day'
|
||||
} else {
|
||||
weekdayTexts = this.selectedWeekdays
|
||||
.map((weekday) => {
|
||||
return this.weekdays.find((w) => w.value === weekday).text
|
||||
})
|
||||
.join(', ')
|
||||
}
|
||||
description += `<span class="font-bold text-white">${weekdayTexts}</span>`
|
||||
|
||||
const hourString = this.selectedHour.toString()
|
||||
const minuteString = this.selectedMinute.toString().padStart(2, '0')
|
||||
description += ` at <span class="font-bold text-white">${hourString}:${minuteString}</span>`
|
||||
return description
|
||||
},
|
||||
intervalOptions() {
|
||||
return [
|
||||
{
|
||||
text: 'Custom daily/weekly',
|
||||
value: 'custom'
|
||||
},
|
||||
{
|
||||
text: 'Every day',
|
||||
value: 'daily'
|
||||
},
|
||||
{
|
||||
text: 'Every 12 hours',
|
||||
value: '0 */12 * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every 6 hours',
|
||||
value: '0 */6 * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every 2 hours',
|
||||
value: '0 */2 * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every hour',
|
||||
value: '0 * * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every 30 minutes',
|
||||
value: '*/30 * * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every 15 minutes',
|
||||
value: '*/15 * * * *'
|
||||
}
|
||||
]
|
||||
},
|
||||
weekdays() {
|
||||
return [
|
||||
{
|
||||
text: 'Sunday',
|
||||
value: 0
|
||||
},
|
||||
{
|
||||
text: 'Monday',
|
||||
value: 1
|
||||
},
|
||||
{
|
||||
text: 'Tuesday',
|
||||
value: 2
|
||||
},
|
||||
{
|
||||
text: 'Wednesday',
|
||||
value: 3
|
||||
},
|
||||
{
|
||||
text: 'Thursday',
|
||||
value: 4
|
||||
},
|
||||
{
|
||||
text: 'Friday',
|
||||
value: 5
|
||||
},
|
||||
{
|
||||
text: 'Saturday',
|
||||
value: 6
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
checkBlurExpressionInput() {
|
||||
if (!this.showAdvancedView || !this.$refs.customExpressionInput) return false
|
||||
if (this.$refs.customExpressionInput.isFocused) {
|
||||
this.$refs.customExpressionInput.blur()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
updateCron() {
|
||||
if (this.selectedInterval === 'custom') {
|
||||
if (!this.minuteIsValid || !this.hourIsValid || !this.selectedWeekdays.length) {
|
||||
this.cronExpression = null
|
||||
return
|
||||
}
|
||||
this.selectedWeekdays.sort()
|
||||
|
||||
const daysOfWeekPiece = this.selectedWeekdays.length === 7 ? '*' : this.selectedWeekdays.join(',')
|
||||
this.cronExpression = `${this.selectedMinute} ${this.selectedHour} * * ${daysOfWeekPiece}`
|
||||
} else if (this.selectedInterval === 'daily') {
|
||||
if (!this.minuteIsValid || !this.hourIsValid) {
|
||||
this.cronExpression = null
|
||||
return
|
||||
}
|
||||
this.cronExpression = `${this.selectedMinute} ${this.selectedHour} * * *`
|
||||
} else {
|
||||
this.cronExpression = this.selectedInterval
|
||||
}
|
||||
|
||||
this.customCronExpression = this.cronExpression
|
||||
this.validatedCron = this.cronExpression
|
||||
this.isValid = true
|
||||
this.customCronError = ''
|
||||
this.$emit('input', this.cronExpression)
|
||||
},
|
||||
minuteBlur() {
|
||||
const v = this.selectedMinute
|
||||
if (v === '' || v === null || isNaN(v) || v < 0) {
|
||||
this.selectedMinute = 0
|
||||
} else if (v > 59) {
|
||||
this.selectedMinute = 59
|
||||
} else {
|
||||
this.selectedMinute = Number(v)
|
||||
}
|
||||
this.updateCron()
|
||||
},
|
||||
hourBlur() {
|
||||
const v = this.selectedHour
|
||||
if (v === '' || v === null || isNaN(v) || v < 0) {
|
||||
this.selectedHour = 0
|
||||
} else if (v > 23) {
|
||||
this.selectedHour = 23
|
||||
} else {
|
||||
this.selectedHour = Number(v)
|
||||
}
|
||||
this.updateCron()
|
||||
},
|
||||
async cronExpressionBlur() {
|
||||
this.customCronError = ''
|
||||
if (!this.customCronExpression || this.customCronExpression.split(' ').length !== 5) {
|
||||
this.customCronError = 'Invalid cron expression'
|
||||
this.isValid = false
|
||||
return
|
||||
}
|
||||
|
||||
if (this.customCronExpression !== this.cronExpression) {
|
||||
this.selectedWeekdays = []
|
||||
this.selectedHour = 0
|
||||
this.selectedMinute = 0
|
||||
this.cronExpression = this.customCronExpression
|
||||
}
|
||||
|
||||
if (!this.validatedCron || this.validatedCron !== this.cronExpression) {
|
||||
const validationPayload = await this.validateCron()
|
||||
this.isValid = validationPayload.isValid
|
||||
this.validatedCron = this.cronExpression
|
||||
this.customCronError = validationPayload.error || ''
|
||||
}
|
||||
|
||||
if (this.isValid) {
|
||||
this.$emit('input', this.cronExpression)
|
||||
}
|
||||
},
|
||||
validateCron() {
|
||||
this.isValidating = true
|
||||
return this.$axios
|
||||
.$post('/api/validate-cron', { expression: this.customCronExpression })
|
||||
.then(() => {
|
||||
this.isValidating = false
|
||||
return {
|
||||
isValid: true
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Invalid cron', error)
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
this.isValidating = false
|
||||
return {
|
||||
isValid: false,
|
||||
error: errMsg || 'Invalid cron expression'
|
||||
}
|
||||
})
|
||||
},
|
||||
init() {
|
||||
if (!this.value) return
|
||||
const pieces = this.value.split(' ')
|
||||
if (pieces.length !== 5) {
|
||||
console.error('Invalid cron expression input', this.value)
|
||||
return
|
||||
}
|
||||
|
||||
const intervalMatch = this.intervalOptions.find((opt) => opt.value === this.value)
|
||||
if (intervalMatch) {
|
||||
this.selectedInterval = this.value
|
||||
} else {
|
||||
var isCustomCron = false
|
||||
if (isNaN(pieces[0]) || isNaN(pieces[1])) {
|
||||
isCustomCron = true
|
||||
} else if (pieces[2] !== '*' || pieces[3] !== '*') {
|
||||
isCustomCron = true
|
||||
} else if (pieces[4] !== '*' && pieces[4].split(',').some((num) => isNaN(num))) {
|
||||
isCustomCron = true
|
||||
}
|
||||
|
||||
if (isCustomCron) {
|
||||
this.showAdvancedView = true
|
||||
} else {
|
||||
if (pieces[4] === '*') this.selectedInterval = 'daily'
|
||||
|
||||
this.selectedWeekdays = pieces[4] === '*' ? [0, 1, 2, 3, 4, 5, 6] : pieces[4].split(',').map((num) => Number(num))
|
||||
this.selectedHour = pieces[1]
|
||||
this.selectedMinute = pieces[0]
|
||||
}
|
||||
}
|
||||
this.cronExpression = this.value
|
||||
this.customCronExpression = this.value
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -13,7 +13,22 @@
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex" :style="{ height: height + 'px' }">
|
||||
<template v-for="(item, index) in items">
|
||||
<cards-lazy-book-card :key="item.recentEpisode.id" :ref="`slider-episode-${item.recentEpisode.id}`" :index="index" :book-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="bookshelfView" class="relative mx-2" @edit="editEpisode" @editPodcast="editPodcast" @select="selectItem" @hook:updated="setScrollVars" />
|
||||
<cards-lazy-book-card
|
||||
:key="item.recentEpisode.id"
|
||||
:ref="`slider-episode-${item.recentEpisode.id}`"
|
||||
:index="index"
|
||||
:book-mount="item"
|
||||
:height="cardHeight"
|
||||
:width="cardWidth"
|
||||
:book-cover-aspect-ratio="bookCoverAspectRatio"
|
||||
:bookshelf-view="bookshelfView"
|
||||
:continue-listening-shelf="continueListeningShelf"
|
||||
class="relative mx-2"
|
||||
@edit="editEpisode"
|
||||
@editPodcast="editPodcast"
|
||||
@select="selectItem"
|
||||
@hook:updated="setScrollVars"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,7 +49,8 @@ export default {
|
||||
bookshelfView: {
|
||||
type: Number,
|
||||
default: 1
|
||||
}
|
||||
},
|
||||
continueListeningShelf: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -46,7 +62,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
cardScaleMulitiplier() {
|
||||
return this.height / 192
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex" :style="{ height: height + 'px' }">
|
||||
<template v-for="(item, index) in items">
|
||||
<cards-lazy-book-card :key="item.id" :ref="`slider-item-${item.id}`" :index="index" :book-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="bookshelfView" class="relative mx-2" @edit="editItem" @select="selectItem" @hook:updated="setScrollVars" />
|
||||
<cards-lazy-book-card :key="item.id" :ref="`slider-item-${item.id}`" :index="index" :book-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="bookshelfView" :continue-listening-shelf="continueListeningShelf" class="relative mx-2" @edit="editItem" @select="selectItem" @hook:updated="setScrollVars" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,7 +34,8 @@ export default {
|
||||
bookshelfView: {
|
||||
type: Number,
|
||||
default: 1
|
||||
}
|
||||
},
|
||||
continueListeningShelf: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -46,7 +47,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
cardScaleMulitiplier() {
|
||||
return this.height / 192
|
||||
|
||||
@@ -147,6 +147,16 @@ export default {
|
||||
margin-top: -2px;
|
||||
margin-left: -2px;
|
||||
}
|
||||
.la-ball-spin-clockwise.la-lg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
.la-ball-spin-clockwise.la-lg > div {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: -4px;
|
||||
margin-left: -4px;
|
||||
}
|
||||
.la-ball-spin-clockwise.la-2x {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="absolute w-36 bg-bg rounded-md border border-black-200 shadow-lg z-50" v-click-outside="clickOutsideObj" style="top: 0; left: 0">
|
||||
<template v-for="(item, index) in items">
|
||||
<div :key="index" class="flex h-7 items-center px-2 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.func)">
|
||||
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.func)">
|
||||
<p>{{ item.text }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
25
client/components/widgets/NotificationWidget.vue
Normal file
25
client/components/widgets/NotificationWidget.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div v-if="tasksRunning" class="w-4 h-4 mx-3 relative">
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<widgets-loading-spinner />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
tasks() {
|
||||
return this.$store.state.tasks.tasks
|
||||
},
|
||||
tasksRunning() {
|
||||
return this.tasks.some((t) => !t.isFinished)
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -39,10 +39,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow px-1 pt-6">
|
||||
<ui-checkbox v-model="autoDownloadEpisodes" label="Auto Download New Episodes" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -71,7 +67,6 @@ export default {
|
||||
explicit: false,
|
||||
language: null
|
||||
},
|
||||
autoDownloadEpisodes: false,
|
||||
newTags: []
|
||||
}
|
||||
},
|
||||
@@ -196,10 +191,6 @@ export default {
|
||||
updatePayload.tags = [...this.newTags]
|
||||
}
|
||||
|
||||
if (this.media.autoDownloadEpisodes !== this.autoDownloadEpisodes) {
|
||||
updatePayload.autoDownloadEpisodes = !!this.autoDownloadEpisodes
|
||||
}
|
||||
|
||||
return {
|
||||
updatePayload,
|
||||
hasChanges: !!Object.keys(updatePayload).length
|
||||
@@ -219,7 +210,6 @@ export default {
|
||||
this.details.language = this.mediaMetadata.language || ''
|
||||
this.details.explicit = !!this.mediaMetadata.explicit
|
||||
|
||||
this.autoDownloadEpisodes = !!this.media.autoDownloadEpisodes
|
||||
this.newTags = [...(this.media.tags || [])]
|
||||
},
|
||||
submitForm() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<ui-multi-select-query-input v-model="seriesItems" text-key="displayName" label="Series" readonly show-edit @edit="editSeriesItem" @add="addNewSeries" />
|
||||
<ui-multi-select-query-input v-model="seriesItems" text-key="displayName" label="Series" :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" />
|
||||
</div>
|
||||
@@ -12,7 +12,8 @@ export default {
|
||||
value: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
disabled: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -46,7 +46,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
cardScaleMulitiplier() {
|
||||
return this.height / 192
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<modals-podcast-edit-episode />
|
||||
<modals-podcast-view-episode />
|
||||
<modals-authors-edit-modal />
|
||||
<modals-batch-quick-match-model />
|
||||
<prompt-confirm />
|
||||
<readers-reader />
|
||||
</div>
|
||||
@@ -111,21 +112,8 @@ export default {
|
||||
reconnectFailed() {
|
||||
console.error('[SOCKET] reconnect failed')
|
||||
},
|
||||
init(payload, count = 0) {
|
||||
if (!this.$refs.streamContainer) {
|
||||
if (count > 20) {
|
||||
console.error('Stream container never mounted')
|
||||
return
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.init(payload, ++count)
|
||||
}, 100)
|
||||
return
|
||||
}
|
||||
init(payload) {
|
||||
console.log('Init Payload', payload)
|
||||
if (payload.session) {
|
||||
this.$refs.streamContainer.sessionOpen(payload.session)
|
||||
}
|
||||
|
||||
// Start scans currently running
|
||||
if (payload.librariesScanning) {
|
||||
@@ -282,6 +270,14 @@ export default {
|
||||
|
||||
this.$store.commit('scanners/addUpdate', data)
|
||||
},
|
||||
taskStarted(task) {
|
||||
console.log('Task started', task)
|
||||
this.$store.commit('tasks/addUpdateTask', task)
|
||||
},
|
||||
taskFinished(task) {
|
||||
console.log('Task finished', task)
|
||||
this.$store.commit('tasks/addUpdateTask', task)
|
||||
},
|
||||
userUpdated(user) {
|
||||
if (this.$store.state.user.user.id === user.id) {
|
||||
this.$store.commit('user/setUser', user)
|
||||
@@ -314,53 +310,6 @@ export default {
|
||||
}
|
||||
this.$store.commit('user/removeCollection', collection)
|
||||
},
|
||||
abmergeStarted(download) {
|
||||
download.status = this.$constants.DownloadStatus.PENDING
|
||||
download.toastId = this.$toast(`Preparing download "${download.filename}"`, { timeout: false, draggable: false, closeOnClick: false })
|
||||
this.$store.commit('downloads/addUpdateDownload', download)
|
||||
},
|
||||
abmergeReady(download) {
|
||||
download.status = this.$constants.DownloadStatus.READY
|
||||
var existingDownload = this.$store.getters['downloads/getDownload'](download.id)
|
||||
|
||||
if (existingDownload && existingDownload.toastId !== undefined) {
|
||||
download.toastId = existingDownload.toastId
|
||||
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" is ready!`, options: { timeout: 5000, type: 'success' } }, true)
|
||||
} else {
|
||||
this.$toast.success(`Download "${download.filename}" is ready!`)
|
||||
}
|
||||
this.$store.commit('downloads/addUpdateDownload', download)
|
||||
},
|
||||
abmergeFailed(download) {
|
||||
download.status = this.$constants.DownloadStatus.FAILED
|
||||
var existingDownload = this.$store.getters['downloads/getDownload'](download.id)
|
||||
|
||||
var failedMsg = download.isTimedOut ? 'timed out' : 'failed'
|
||||
|
||||
if (existingDownload && existingDownload.toastId !== undefined) {
|
||||
download.toastId = existingDownload.toastId
|
||||
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" ${failedMsg}`, options: { timeout: 5000, type: 'error' } }, true)
|
||||
} else {
|
||||
console.warn('Download failed no existing download', existingDownload)
|
||||
this.$toast.error(`Download "${download.filename}" ${failedMsg}`)
|
||||
}
|
||||
this.$store.commit('downloads/addUpdateDownload', download)
|
||||
},
|
||||
abmergeKilled(download) {
|
||||
var existingDownload = this.$store.getters['downloads/getDownload'](download.id)
|
||||
if (existingDownload && existingDownload.toastId !== undefined) {
|
||||
download.toastId = existingDownload.toastId
|
||||
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" was terminated`, options: { timeout: 5000, type: 'error' } }, true)
|
||||
} else {
|
||||
console.warn('Download killed no existing download found', existingDownload)
|
||||
this.$toast.error(`Download "${download.filename}" was terminated`)
|
||||
}
|
||||
this.$store.commit('downloads/removeDownload', download)
|
||||
},
|
||||
abmergeExpired(download) {
|
||||
download.status = this.$constants.DownloadStatus.EXPIRED
|
||||
this.$store.commit('downloads/addUpdateDownload', download)
|
||||
},
|
||||
rssFeedOpen(data) {
|
||||
this.$store.commit('feeds/addFeed', data)
|
||||
},
|
||||
@@ -371,6 +320,18 @@ export default {
|
||||
// Force refresh
|
||||
location.reload()
|
||||
},
|
||||
batchQuickMatchComplete(result) {
|
||||
var success = result.success || false
|
||||
var toast = 'Batch quick match complete!\n' + result.updates + ' Updated'
|
||||
if (result.unmatched && result.unmatched > 0) {
|
||||
toast += '\n' + result.unmatched + ' with no matches'
|
||||
}
|
||||
if (success) {
|
||||
this.$toast.success(toast)
|
||||
} else {
|
||||
this.$toast.info(toast)
|
||||
}
|
||||
},
|
||||
initializeSocket() {
|
||||
this.socket = this.$nuxtSocket({
|
||||
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
|
||||
@@ -430,18 +391,17 @@ export default {
|
||||
this.socket.on('scan_complete', this.scanComplete)
|
||||
this.socket.on('scan_progress', this.scanProgress)
|
||||
|
||||
// Download Listeners
|
||||
this.socket.on('abmerge_started', this.abmergeStarted)
|
||||
this.socket.on('abmerge_ready', this.abmergeReady)
|
||||
this.socket.on('abmerge_failed', this.abmergeFailed)
|
||||
this.socket.on('abmerge_killed', this.abmergeKilled)
|
||||
this.socket.on('abmerge_expired', this.abmergeExpired)
|
||||
// Task Listeners
|
||||
this.socket.on('task_started', this.taskStarted)
|
||||
this.socket.on('task_finished', this.taskFinished)
|
||||
|
||||
// Feed Listeners
|
||||
this.socket.on('rss_feed_open', this.rssFeedOpen)
|
||||
this.socket.on('rss_feed_closed', this.rssFeedClosed)
|
||||
|
||||
this.socket.on('backup_applied', this.backupApplied)
|
||||
|
||||
this.socket.on('batch_quickmatch_complete', this.batchQuickMatchComplete)
|
||||
},
|
||||
showUpdateToast(versionData) {
|
||||
var ignoreVersion = localStorage.getItem('ignoreVersion')
|
||||
@@ -535,6 +495,30 @@ export default {
|
||||
if (res && res.hasUpdate) this.showUpdateToast(res)
|
||||
})
|
||||
.catch((err) => console.error(err))
|
||||
},
|
||||
initLocalStorage() {
|
||||
// If experimental features set in local storage
|
||||
var experimentalFeaturesSaved = localStorage.getItem('experimental')
|
||||
if (experimentalFeaturesSaved === '1') {
|
||||
this.$store.commit('setExperimentalFeatures', true)
|
||||
}
|
||||
|
||||
// Queue auto play
|
||||
var playerQueueAutoPlay = localStorage.getItem('playerQueueAutoPlay')
|
||||
this.$store.commit('setPlayerQueueAutoPlay', playerQueueAutoPlay !== '0')
|
||||
},
|
||||
loadTasks() {
|
||||
this.$axios
|
||||
.$get('/api/tasks')
|
||||
.then((payload) => {
|
||||
console.log('Fetched tasks', payload)
|
||||
if (payload.tasks) {
|
||||
this.$store.commit('tasks/setTasks', payload.tasks)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load tasks', error)
|
||||
})
|
||||
}
|
||||
},
|
||||
beforeMount() {
|
||||
@@ -548,14 +532,12 @@ export default {
|
||||
|
||||
this.$store.dispatch('libraries/load')
|
||||
|
||||
// If experimental features set in local storage
|
||||
var experimentalFeaturesSaved = localStorage.getItem('experimental')
|
||||
if (experimentalFeaturesSaved === '1') {
|
||||
this.$store.commit('setExperimentalFeatures', true)
|
||||
}
|
||||
this.initLocalStorage()
|
||||
|
||||
this.checkVersionUpdate()
|
||||
|
||||
this.loadTasks()
|
||||
|
||||
if (this.$route.query.error) {
|
||||
this.$toast.error(this.$route.query.error)
|
||||
this.$router.replace(this.$route.path)
|
||||
|
||||
@@ -51,7 +51,7 @@ export default {
|
||||
index,
|
||||
width: this.entityWidth,
|
||||
height: this.entityHeight,
|
||||
bookCoverAspectRatio: this.bookCoverAspectRatio,
|
||||
bookCoverAspectRatio: this.coverAspectRatio,
|
||||
bookshelfView: this.bookshelfView,
|
||||
sortingIgnorePrefix: !!this.sortingIgnorePrefix
|
||||
}
|
||||
|
||||
@@ -6,13 +6,14 @@ module.exports = {
|
||||
target: 'static',
|
||||
dev: process.env.NODE_ENV !== 'production',
|
||||
env: {
|
||||
serverUrl: process.env.NODE_ENV === 'production' ? '' : 'http://localhost:3333',
|
||||
serverUrl: process.env.NODE_ENV === 'production' ? process.env.ROUTER_BASE_PATH : 'http://localhost:3333',
|
||||
chromecastReceiver: 'FD1F76C5'
|
||||
},
|
||||
telemetry: false,
|
||||
|
||||
publicRuntimeConfig: {
|
||||
version: pkg.version
|
||||
version: pkg.version,
|
||||
routerBasePath: process.env.ROUTER_BASE_PATH || ''
|
||||
},
|
||||
|
||||
// Global page headers: https://go.nuxtjs.dev/config-head
|
||||
@@ -28,15 +29,17 @@ module.exports = {
|
||||
],
|
||||
script: [
|
||||
{
|
||||
src: '/libs/sortable.js'
|
||||
src: (process.env.ROUTER_BASE_PATH || '') + '/libs/sortable.js'
|
||||
}
|
||||
],
|
||||
link: [
|
||||
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
|
||||
{ rel: 'icon', type: 'image/x-icon', href: (process.env.ROUTER_BASE_PATH || '') + '/favicon.ico' }
|
||||
]
|
||||
},
|
||||
|
||||
router: {},
|
||||
router: {
|
||||
base: process.env.ROUTER_BASE_PATH || ''
|
||||
},
|
||||
|
||||
// Global CSS: https://go.nuxtjs.dev/config-css
|
||||
css: [
|
||||
@@ -48,7 +51,8 @@ module.exports = {
|
||||
'@/plugins/constants.js',
|
||||
'@/plugins/init.client.js',
|
||||
'@/plugins/axios.js',
|
||||
'@/plugins/toast.js'
|
||||
'@/plugins/toast.js',
|
||||
'@/plugins/utils.js'
|
||||
],
|
||||
|
||||
// Auto import components: https://go.nuxtjs.dev/config-components
|
||||
@@ -72,8 +76,7 @@ module.exports = {
|
||||
proxy: {
|
||||
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } },
|
||||
'/ebook/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
|
||||
'/s/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
|
||||
'/metadata/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' }
|
||||
'/s/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' + process.env : '/' },
|
||||
},
|
||||
|
||||
io: {
|
||||
@@ -88,7 +91,7 @@ module.exports = {
|
||||
|
||||
// Axios module configuration: https://go.nuxtjs.dev/config-axios
|
||||
axios: {
|
||||
baseURL: process.env.serverUrl || ''
|
||||
baseURL: process.env.ROUTER_BASE_PATH || ''
|
||||
},
|
||||
|
||||
// nuxt/pwa https://pwa.nuxtjs.org
|
||||
@@ -108,15 +111,15 @@ module.exports = {
|
||||
background_color: '#373838',
|
||||
icons: [
|
||||
{
|
||||
src: '/icon.svg',
|
||||
src: (process.env.ROUTER_BASE_PATH || '') + '/icon.svg',
|
||||
sizes: "64x64"
|
||||
},
|
||||
{
|
||||
src: '/icon.svg',
|
||||
src: (process.env.ROUTER_BASE_PATH || '') + '/icon.svg',
|
||||
sizes: "192x192"
|
||||
},
|
||||
{
|
||||
src: '/icon.svg',
|
||||
src: (process.env.ROUTER_BASE_PATH || '') + '/icon.svg',
|
||||
sizes: "512x512"
|
||||
}
|
||||
]
|
||||
|
||||
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.1.2",
|
||||
"version": "2.2.1",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.1.2",
|
||||
"version": "2.2.1",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.1.2",
|
||||
"version": "2.2.1",
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</button>
|
||||
<div class="flex-grow" />
|
||||
<p class="text-base">Duration:</p>
|
||||
<p class="text-base font-mono ml-8">{{ mediaDuration }}</p>
|
||||
<p class="text-base font-mono ml-8">{{ $secondsToTimestamp(mediaDurationRounded) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap-reverse justify-center py-4">
|
||||
@@ -18,11 +18,35 @@
|
||||
<p class="text-lg mb-4 font-semibold">Audiobook Chapters</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-checkbox v-model="showSecondInputs" checkbox-bg="primary" small label-class="text-sm text-gray-200 pl-1" label="Show seconds" class="mx-2" />
|
||||
<div class="w-40" />
|
||||
</div>
|
||||
<div class="flex items-center mb-3 py-1">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="newChapters.length > 1" :color="showShiftTimes ? 'bg' : 'primary'" small @click="showShiftTimes = !showShiftTimes">Shift Times</ui-btn>
|
||||
<ui-btn color="primary" small class="mx-2" @click="showFindChaptersModal = true">Lookup</ui-btn>
|
||||
<ui-btn color="success" small @click="saveChapters">Save</ui-btn>
|
||||
<div class="w-40" />
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden">
|
||||
<transition name="slide">
|
||||
<div v-if="showShiftTimes" class="flex mb-4">
|
||||
<div class="w-12"></div>
|
||||
<div class="flex-grow">
|
||||
<div class="flex items-center">
|
||||
<p class="text-sm mb-1 font-semibold pr-2">Time to shift in seconds</p>
|
||||
<ui-text-input v-model="shiftAmount" type="number" class="max-w-20" style="height: 30px" />
|
||||
<ui-btn color="primary" class="mx-1" small @click="shiftChapterTimes">Add</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<span class="material-icons text-gray-200 hover:text-white cursor-pointer" @click="showShiftTimes = false">close</span>
|
||||
</div>
|
||||
<p class="text-xs py-1.5 text-gray-300 max-w-md">Note: First chapter start time must remain at 0:00 and the last chapter start time cannot exceed this audiobooks duration.</p>
|
||||
</div>
|
||||
<div class="w-40"></div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
|
||||
<div class="w-12"></div>
|
||||
<div class="w-32 px-2">Start</div>
|
||||
@@ -81,7 +105,7 @@
|
||||
<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">{{ track.duration }}</p>
|
||||
<p class="text-xs font-mono text-gray-200">{{ $secondsToTimestamp(Math.round(track.duration), false, true) }}</p>
|
||||
</div>
|
||||
<div class="w-20 flex justify-center" style="min-width: 80px">
|
||||
<span v-if="(track.chapters || []).length" class="material-icons text-success text-sm">check</span>
|
||||
@@ -107,10 +131,16 @@
|
||||
<ui-btn small color="primary" class="mt-5 ml-2" @click="findChapters">Find</ui-btn>
|
||||
</div>
|
||||
<div v-else class="w-full p-4">
|
||||
<p class="mb-4">Duration found: {{ chapterData.runtimeLengthSec }}</p>
|
||||
<div v-if="chapterData.runtimeLengthSec > mediaDuration" class="w-full bg-error bg-opacity-25 p-4 text-center mb-2 rounded border border-white border-opacity-10 text-gray-100 text-sm">
|
||||
<p>Chapter data invalid duration<br />Your media duration is shorter than duration found</p>
|
||||
<div class="flex justify-between mb-4">
|
||||
<p>
|
||||
Duration found: <span class="font-semibold">{{ $secondsToTimestamp(chapterData.runtimeLengthSec) }}</span>
|
||||
</p>
|
||||
<p>
|
||||
Your audiobook duration: <span class="font-semibold">{{ $secondsToTimestamp(mediaDurationRounded) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<widgets-alert v-if="chapterData.runtimeLengthSec > mediaDurationRounded" type="warning" class="mb-2"> Your audiobook duration is shorter than duration found </widgets-alert>
|
||||
<widgets-alert v-else-if="chapterData.runtimeLengthSec < mediaDurationRounded" type="warning" class="mb-2"> Your audiobook duration is longer than the duration found </widgets-alert>
|
||||
|
||||
<div class="flex py-0.5 text-xs font-semibold uppercase text-gray-300 mb-1">
|
||||
<div class="w-24 px-2">Start</div>
|
||||
@@ -126,7 +156,21 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex pt-2">
|
||||
<div v-if="chapterData.runtimeLengthSec > mediaDurationRounded" class="w-full pt-2">
|
||||
<div class="flex items-center">
|
||||
<div class="w-2 h-2 bg-warning bg-opacity-50" />
|
||||
<p class="pl-2">Chapter end is after the end of your audiobook</p>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="w-2 h-2 bg-error bg-opacity-50" />
|
||||
<p class="pl-2">Chapter start is after the end of your audiobook</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center pt-2">
|
||||
<ui-btn small color="primary" class="mr-1" @click="applyChapterNamesOnly">Map Chapter Titles</ui-btn>
|
||||
<ui-tooltip text="Map chapter titles to your existing audiobook chapters without adjusting timestamps" direction="top">
|
||||
<span class="material-icons-outlined">info</span>
|
||||
</ui-tooltip>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn small color="success" @click="applyChapterData">Apply Chapters</ui-btn>
|
||||
</div>
|
||||
@@ -166,6 +210,8 @@ export default {
|
||||
return {
|
||||
newChapters: [],
|
||||
selectedChapter: null,
|
||||
showShiftTimes: false,
|
||||
shiftAmount: 0,
|
||||
audioEl: null,
|
||||
isPlayingChapter: false,
|
||||
isLoadingChapter: false,
|
||||
@@ -197,6 +243,9 @@ export default {
|
||||
mediaDuration() {
|
||||
return this.media.duration
|
||||
},
|
||||
mediaDurationRounded() {
|
||||
return Math.round(this.mediaDuration)
|
||||
},
|
||||
chapters() {
|
||||
return this.media.chapters || []
|
||||
},
|
||||
@@ -214,6 +263,32 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
shiftChapterTimes() {
|
||||
if (!this.shiftAmount || isNaN(this.shiftAmount) || this.newChapters.length <= 1) {
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if (this.newChapters[0].end + amount <= 0) {
|
||||
this.$toast.error('Invalid shift amount. First chapter would have zero or negative length.')
|
||||
return
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.newChapters.length; i++) {
|
||||
const chap = this.newChapters[i]
|
||||
chap.end = Math.min(chap.end + amount, this.mediaDuration)
|
||||
if (i > 0) {
|
||||
chap.start = Math.max(0, chap.start + amount)
|
||||
}
|
||||
}
|
||||
},
|
||||
editItem() {
|
||||
this.$store.commit('showEditModal', this.libraryItem)
|
||||
},
|
||||
@@ -256,7 +331,6 @@ export default {
|
||||
console.log('Chapter already playing', this.isLoadingChapter, this.isPlayingChapter)
|
||||
if (this.isLoadingChapter) return
|
||||
if (this.isPlayingChapter) {
|
||||
console.log('Destroying chapter')
|
||||
this.destroyAudioEl()
|
||||
return
|
||||
}
|
||||
@@ -282,7 +356,7 @@ export default {
|
||||
const audioEl = this.audioEl || document.createElement('audio')
|
||||
var src = audioTrack.contentUrl + `?token=${this.userToken}`
|
||||
if (this.$isDev) {
|
||||
src = `http://localhost:3333${src}`
|
||||
src = `http://localhost:3333${this.$config.routerBasePath}${src}`
|
||||
}
|
||||
console.log('src', src)
|
||||
|
||||
@@ -370,6 +444,16 @@ export default {
|
||||
this.$toast.error('Failed to update chapters')
|
||||
})
|
||||
},
|
||||
applyChapterNamesOnly() {
|
||||
this.newChapters.forEach((chapter, index) => {
|
||||
if (this.chapterData.chapters[index]) {
|
||||
chapter.title = this.chapterData.chapters[index].title
|
||||
}
|
||||
})
|
||||
|
||||
this.showFindChaptersModal = false
|
||||
this.chapterData = null
|
||||
},
|
||||
applyChapterData() {
|
||||
var index = 0
|
||||
this.newChapters = this.chapterData.chapters
|
||||
@@ -427,6 +511,9 @@ export default {
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.destroyAudioEl()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
362
client/pages/audiobook/_id/manage.vue
Normal file
362
client/pages/audiobook/_id/manage.vue
Normal file
@@ -0,0 +1,362 @@
|
||||
<template>
|
||||
<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">Audiobook File Management Tools</p>
|
||||
</div>
|
||||
<div class="w-full max-w-2xl">
|
||||
<div class="flex justify-end">
|
||||
<ui-dropdown v-model="selectedTool" :items="availableTools" :disabled="processing" class="max-w-sm" @input="selectedToolUpdated" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<div class="w-full max-w-2xl">
|
||||
<p class="text-xl mb-1">Metadata to embed</p>
|
||||
<p class="mb-2 text-base text-gray-300">audiobookshelf uses <a href="https://github.com/sandreas/tone" target="_blank" class="hover:underline text-blue-400 hover:text-blue-300">tone</a> to write metadata.</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 border-opacity-10 bg-bg mx-2">
|
||||
<div class="flex py-2 px-4">
|
||||
<div class="w-1/3 text-xs font-semibold uppercase text-gray-200">Meta Tag</div>
|
||||
<div class="w-2/3 text-xs font-semibold uppercase text-gray-200">Value</div>
|
||||
</div>
|
||||
<div class="w-full max-h-72 overflow-auto">
|
||||
<template v-for="(value, key, index) in toneObject">
|
||||
<div :key="key" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
|
||||
<div class="w-1/3 font-semibold">{{ key }}</div>
|
||||
<div class="w-2/3">
|
||||
{{ value }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full max-w-2xl border border-white border-opacity-10 bg-bg mx-2">
|
||||
<div class="flex py-2 px-4 bg-primary bg-opacity-25">
|
||||
<div class="flex-grow text-xs font-semibold uppercase text-gray-200">Chapter Title</div>
|
||||
<div class="w-24 text-xs font-semibold uppercase text-gray-200">Start</div>
|
||||
<div class="w-24 text-xs font-semibold uppercase text-gray-200">End</div>
|
||||
</div>
|
||||
<div class="w-full max-h-72 overflow-auto">
|
||||
<p v-if="!metadataChapters.length" class="py-5 text-center text-gray-200">No chapters</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 bg-opacity-25' : ''">
|
||||
<div class="flex-grow font-semibold">{{ chapter.title }}</div>
|
||||
<div class="w-24">
|
||||
{{ $secondsToTimestamp(chapter.start) }}
|
||||
</div>
|
||||
<div class="w-24">
|
||||
{{ $secondsToTimestamp(chapter.end) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-8" />
|
||||
|
||||
<div class="w-full max-w-4xl mx-auto">
|
||||
<div v-if="selectedTool === 'embed'" class="w-full flex justify-end items-center mb-4">
|
||||
<ui-btn v-if="!isFinished" color="primary" :loading="processing" @click.stop="embedClick">Start Metadata Embed</ui-btn>
|
||||
<p v-else class="text-success text-lg font-semibold">Embed Finished!</p>
|
||||
</div>
|
||||
<div v-else class="w-full flex justify-end items-center mb-4">
|
||||
<ui-btn v-if="!isTaskFinished && processing" color="error" :loading="isCancelingEncode" class="mr-2" @click.stop="cancelEncodeClick">Cancel Encode</ui-btn>
|
||||
<ui-btn v-if="!isTaskFinished" color="primary" :loading="processing" @click.stop="encodeM4bClick">Start M4B Encode</ui-btn>
|
||||
<p v-else-if="taskFailed" class="text-error text-lg font-semibold">M4B Failed! {{ taskError }}</p>
|
||||
<p v-else class="text-success text-lg font-semibold">M4B Finished!</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div v-if="selectedTool === 'embed'" class="flex items-start mb-2">
|
||||
<span class="material-icons text-base text-warning pt-1">star</span>
|
||||
<p class="text-gray-200 ml-2">Metadata will be embedded on the audio tracks inside your audiobook folder.</p>
|
||||
</div>
|
||||
<div v-else class="flex items-start mb-2">
|
||||
<span class="material-icons text-base text-warning pt-1">star</span>
|
||||
<p class="text-gray-200 ml-2">
|
||||
Finished M4B will be put into your audiobook folder at <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">.../{{ libraryItemRelPath }}/</span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start mb-2">
|
||||
<span class="material-icons text-base text-warning pt-1">star</span>
|
||||
<p class="text-gray-200 ml-2">
|
||||
A backup of your original audio files will be stored in <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">/metadata/cache/items/{{ libraryItemId }}/</span>. Make sure to periodically purge items cache.
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="selectedTool === 'm4b'" class="flex items-start mb-2">
|
||||
<span class="material-icons text-base text-warning pt-1">star</span>
|
||||
<p class="text-gray-200 ml-2">Encoding can take up to 30 minutes.</p>
|
||||
</div>
|
||||
<div v-if="selectedTool === 'm4b'" class="flex items-start mb-2">
|
||||
<span class="material-icons text-base text-warning pt-1">star</span>
|
||||
<p class="text-gray-200 ml-2">If you have the watcher disabled you will need to re-scan this audiobook afterwards.</p>
|
||||
</div>
|
||||
<div class="flex items-start mb-2">
|
||||
<span class="material-icons text-base text-warning pt-1">star</span>
|
||||
<p class="text-gray-200 ml-2">Once the task is started you can navigate away from this page.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-4xl mx-auto">
|
||||
<p class="mb-2 font-semibold">Audio Tracks</p>
|
||||
<div class="w-full mx-auto border border-white border-opacity-10 bg-bg">
|
||||
<div class="flex py-2 px-4 bg-primary bg-opacity-25">
|
||||
<div class="w-10 text-xs font-semibold text-gray-200">#</div>
|
||||
<div class="flex-grow text-xs font-semibold uppercase text-gray-200">Filename</div>
|
||||
<div class="w-16 text-xs font-semibold uppercase text-gray-200">Size</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 bg-opacity-25' : ''">
|
||||
<div class="w-10">{{ file.index }}</div>
|
||||
<div class="flex-grow">
|
||||
{{ file.metadata.filename }}
|
||||
</div>
|
||||
<div class="w-16 font-mono text-gray-200">
|
||||
{{ $bytesPretty(file.metadata.size) }}
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<div class="flex justify-center">
|
||||
<span v-if="audiofilesFinished[file.ino]" class="material-icons text-xl text-success leading-none">check_circle</span>
|
||||
<div v-else-if="audiofilesEncoding[file.ino]">
|
||||
<widgets-loading-spinner />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ store, params, app, redirect, route }) {
|
||||
if (!store.state.user.user) {
|
||||
return redirect(`/login?redirect=${route.path}`)
|
||||
}
|
||||
if (!store.getters['user/getIsAdminOrUp']) {
|
||||
return redirect('/?error=unauthorized')
|
||||
}
|
||||
var libraryItem = await app.$axios.$get(`/api/items/${params.id}?expanded=1`).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return false
|
||||
})
|
||||
if (!libraryItem) {
|
||||
console.error('Not found...', params.id)
|
||||
return redirect('/?error=not found')
|
||||
}
|
||||
if (libraryItem.mediaType !== 'book') {
|
||||
console.error('Invalid media type')
|
||||
return redirect('/?error=invalid media type')
|
||||
}
|
||||
if (!libraryItem.media.audioFiles.length) {
|
||||
cnosole.error('No audio files')
|
||||
return redirect('/?error=no audio files')
|
||||
}
|
||||
return {
|
||||
libraryItem
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
audiofilesEncoding: {},
|
||||
audiofilesFinished: {},
|
||||
isFinished: false,
|
||||
toneObject: null,
|
||||
selectedTool: 'embed',
|
||||
isCancelingEncode: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
task: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.taskUpdated(newVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
libraryItemId() {
|
||||
return this.libraryItem.id
|
||||
},
|
||||
libraryItemRelPath() {
|
||||
return this.libraryItem.relPath
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
audioFiles() {
|
||||
return (this.media.audioFiles || []).filter((af) => !af.exclude && !af.invalid)
|
||||
},
|
||||
isSingleM4b() {
|
||||
return this.audioFiles.length === 1 && this.audioFiles[0].metadata.ext.toLowerCase() === '.m4b'
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
metadataChapters() {
|
||||
return this.media.chapters || []
|
||||
},
|
||||
availableTools() {
|
||||
if (this.isSingleM4b) {
|
||||
return [{ value: 'embed', text: 'Embed Metadata' }]
|
||||
} else {
|
||||
return [
|
||||
{ value: 'embed', text: 'Embed Metadata' },
|
||||
{ value: 'm4b', text: 'M4B Encoder' }
|
||||
]
|
||||
}
|
||||
},
|
||||
taskFailed() {
|
||||
return this.isTaskFinished && this.task.isFailed
|
||||
},
|
||||
taskError() {
|
||||
return this.taskFailed ? this.task.error || 'Unknown Error' : null
|
||||
},
|
||||
isTaskFinished() {
|
||||
return this.task && this.task.isFinished
|
||||
},
|
||||
task() {
|
||||
return this.$store.getters['tasks/getTaskByLibraryItemId'](this.libraryItemId)
|
||||
},
|
||||
taskRunning() {
|
||||
return this.task && !this.task.isFinished
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
cancelEncodeClick() {
|
||||
this.isCancelingEncode = true
|
||||
this.$axios
|
||||
.$post(`/api/encode-m4b/${this.libraryItemId}/cancel`)
|
||||
.then(() => {
|
||||
this.$toast.success('Encode canceled')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to cancel encode', error)
|
||||
this.$toast.error('Failed to cancel encode')
|
||||
})
|
||||
.finally(() => {
|
||||
this.isCancelingEncode = false
|
||||
})
|
||||
},
|
||||
encodeM4bClick() {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$get(`/api/encode-m4b/${this.libraryItemId}`)
|
||||
.then(() => {
|
||||
console.log('Ab m4b merge started')
|
||||
})
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||
this.$toast.error(errorMsg)
|
||||
this.processing = true
|
||||
})
|
||||
},
|
||||
embedClick() {
|
||||
const payload = {
|
||||
message: `Are you sure you want to embed metadata in ${this.audioFiles.length} audio files?`,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.updateAudioFileMetadata()
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
updateAudioFileMetadata() {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$get(`/api/items/${this.libraryItemId}/audio-metadata?tone=1`)
|
||||
.then(() => {
|
||||
console.log('Audio metadata encode started')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Audio metadata encode failed', error)
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
audioMetadataStarted(data) {
|
||||
console.log('audio metadata started', data)
|
||||
if (data.libraryItemId !== this.libraryItemId) return
|
||||
this.audiofilesFinished = {}
|
||||
},
|
||||
audioMetadataFinished(data) {
|
||||
console.log('audio metadata finished', data)
|
||||
if (data.libraryItemId !== this.libraryItemId) return
|
||||
this.processing = false
|
||||
this.isFinished = true
|
||||
this.audiofilesEncoding = {}
|
||||
this.$toast.success('Audio file metadata updated')
|
||||
},
|
||||
audiofileMetadataStarted(data) {
|
||||
if (data.libraryItemId !== this.libraryItemId) return
|
||||
this.$set(this.audiofilesEncoding, data.ino, true)
|
||||
},
|
||||
audiofileMetadataFinished(data) {
|
||||
if (data.libraryItemId !== this.libraryItemId) return
|
||||
this.$set(this.audiofilesEncoding, data.ino, false)
|
||||
this.$set(this.audiofilesFinished, data.ino, true)
|
||||
},
|
||||
selectedToolUpdated() {
|
||||
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + `?tool=${this.selectedTool}`
|
||||
window.history.replaceState({ path: newurl }, '', newurl)
|
||||
},
|
||||
init() {
|
||||
this.fetchToneObject()
|
||||
if (this.$route.query.tool === 'm4b') {
|
||||
if (this.availableTools.some((t) => t.value === 'm4b')) {
|
||||
this.selectedTool = 'm4b'
|
||||
} else {
|
||||
this.selectedToolUpdated()
|
||||
}
|
||||
}
|
||||
|
||||
if (this.task) this.taskUpdated(this.task)
|
||||
},
|
||||
fetchToneObject() {
|
||||
this.$axios
|
||||
.$get(`/api/items/${this.libraryItemId}/tone-object`)
|
||||
.then((toneObject) => {
|
||||
delete toneObject.CoverFile
|
||||
this.toneObject = toneObject
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to fetch tone object', error)
|
||||
})
|
||||
},
|
||||
taskUpdated(task) {
|
||||
this.processing = !task.isFinished
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
this.$root.socket.on('audio_metadata_started', this.audioMetadataStarted)
|
||||
this.$root.socket.on('audio_metadata_finished', this.audioMetadataFinished)
|
||||
this.$root.socket.on('audiofile_metadata_started', this.audiofileMetadataStarted)
|
||||
this.$root.socket.on('audiofile_metadata_finished', this.audiofileMetadataFinished)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$root.socket.off('audio_metadata_started', this.audioMetadataStarted)
|
||||
this.$root.socket.off('audio_metadata_finished', this.audioMetadataFinished)
|
||||
this.$root.socket.off('audiofile_metadata_started', this.audiofileMetadataStarted)
|
||||
this.$root.socket.off('audiofile_metadata_finished', this.audiofileMetadataFinished)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -44,7 +44,7 @@
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ store, app, params, redirect }) {
|
||||
const author = await app.$axios.$get(`/api/authors/${params.id}?include=items,series`).catch((error) => {
|
||||
const author = await app.$axios.$get(`/api/authors/${params.id}?library=${store.state.libraries.currentLibraryId}&include=items,series`).catch((error) => {
|
||||
console.error('Failed to get author', error)
|
||||
return null
|
||||
})
|
||||
|
||||
@@ -144,12 +144,6 @@ export default {
|
||||
isPodcastLibrary() {
|
||||
return this.mediaType === 'podcast'
|
||||
},
|
||||
coverAspectRatio() {
|
||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
{{ streaming ? 'Streaming' : 'Play' }}
|
||||
</ui-btn>
|
||||
|
||||
<ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" />
|
||||
<ui-icon-btn v-if="userCanUpdate" icon="edit" class="mx-0.5" @click="editClick" />
|
||||
|
||||
<ui-icon-btn icon="delete" class="mx-0.5" @click="removeClick" />
|
||||
<ui-icon-btn v-if="userCanDelete" icon="delete" class="mx-0.5" @click="removeClick" />
|
||||
</div>
|
||||
|
||||
<div class="my-8 max-w-2xl">
|
||||
@@ -65,7 +65,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
@@ -92,6 +92,12 @@ export default {
|
||||
},
|
||||
showPlayButton() {
|
||||
return this.playableBooks.length
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -5,25 +5,29 @@
|
||||
<h1 class="text-xl">Backups</h1>
|
||||
</div>
|
||||
|
||||
<p class="text-base mb-4 text-gray-300">Backups include users, user progress, book details, server settings and covers stored in <span class="font-mono text-gray-100">/metadata/items</span>. <br />Backups <strong>do not</strong> include any files stored in your library folders.</p>
|
||||
<p class="text-base mb-4 text-gray-300">Backups include users, user progress, library item details, server settings, and images stored in <span class="font-mono text-gray-100">/metadata/items</span> & <span class="font-mono text-gray-100">/metadata/authors</span>. <br />Backups <strong>do not</strong> include any files stored in your library folders.</p>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="dailyBackups" small :disabled="updatingServerSettings" @input="updateBackupsSettings" />
|
||||
<ui-tooltip :text="dailyBackupsTooltip">
|
||||
<p class="pl-4 text-lg">Run daily backups <span class="material-icons icon-text">info_outlined</span></p>
|
||||
<ui-toggle-switch v-model="enableBackups" small :disabled="updatingServerSettings" @input="updateBackupsSettings" />
|
||||
<ui-tooltip :text="backupsTooltip">
|
||||
<p class="pl-4 text-lg">Enable automatic backups <span class="material-icons icon-text">info_outlined</span></p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- <div class="flex items-center py-2">
|
||||
<ui-text-input v-model="cronExpression" :disabled="updatingServerSettings" class="w-32" @change="changedCronExpression" />
|
||||
|
||||
<p class="pl-4 text-lg">Cron expression</p>
|
||||
</div> -->
|
||||
<div v-if="enableBackups" class="mb-6">
|
||||
<div class="flex items-center pl-6">
|
||||
<span class="material-icons-outlined text-black-50">schedule</span>
|
||||
<p class="text-gray-100 px-2">{{ scheduleDescription }}</p>
|
||||
<span class="material-icons text-lg text-black-50 hover:text-yellow-500 cursor-pointer" @click="showCronBuilder = !showCronBuilder">edit</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-text-input type="number" v-model="backupsToKeep" no-spinner :disabled="updatingServerSettings" :padding-x="1" text-center class="w-10" @change="updateBackupsSettings" />
|
||||
|
||||
<p class="pl-4 text-lg">Number of backups to keep</p>
|
||||
<ui-tooltip :text="numBackupsToKeepTooltip">
|
||||
<p class="pl-4 text-lg">Number of backups to keep <span class="material-icons icon-text">info_outlined</span></p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
@@ -36,6 +40,8 @@
|
||||
|
||||
<tables-backups-table />
|
||||
</div>
|
||||
|
||||
<modals-backup-schedule-modal v-model="showCronBuilder" :cron-expression.sync="cronExpression" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -44,11 +50,15 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
updatingServerSettings: false,
|
||||
dailyBackups: true,
|
||||
enableBackups: true,
|
||||
backupsToKeep: 2,
|
||||
maxBackupSize: 1,
|
||||
// cronExpression: '',
|
||||
newServerSettings: {}
|
||||
cronExpression: '',
|
||||
newServerSettings: {},
|
||||
showCronBuilder: false,
|
||||
backupsTooltip: 'Backups saved in /metadata/backups',
|
||||
numBackupsToKeepTooltip: 'Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.',
|
||||
maxBackupSizeTooltip: 'As a safeguard against misconfiguration, backups will fail if they exceed the configured size.'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -60,29 +70,16 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
dailyBackupsTooltip() {
|
||||
return 'Runs at 1:30am every day (your server time). Saved in /metadata/backups.'
|
||||
},
|
||||
maxBackupSizeTooltip() {
|
||||
return 'As a safeguard against misconfiguration, backups will fail if they exceed the configured size.'
|
||||
},
|
||||
serverSettings() {
|
||||
return this.$store.state.serverSettings
|
||||
},
|
||||
scheduleDescription() {
|
||||
if (!this.cronExpression) return ''
|
||||
const parsed = this.$parseCronExpression(this.cronExpression)
|
||||
return parsed ? parsed.description : 'Custom cron expression ' + this.cronExpression
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// changedCronExpression() {
|
||||
// this.$axios
|
||||
// .$post('/api/validate-cron', { expression: this.cronExpression })
|
||||
// .then(() => {
|
||||
// console.log('Cron is valid')
|
||||
// })
|
||||
// .catch((error) => {
|
||||
// console.error('Cron validation failed', error)
|
||||
// const msg = (error.response ? error.response.data : null) || 'Unknown cron validation error'
|
||||
// this.$toast.error(msg)
|
||||
// })
|
||||
// },
|
||||
updateBackupsSettings() {
|
||||
if (isNaN(this.maxBackupSize) || this.maxBackupSize <= 0) {
|
||||
this.$toast.error('Invalid maximum backup size')
|
||||
@@ -93,7 +90,7 @@ export default {
|
||||
return
|
||||
}
|
||||
var updatePayload = {
|
||||
backupSchedule: this.dailyBackups ? '30 1 * * *' : false,
|
||||
backupSchedule: this.enableBackups ? this.cronExpression : false,
|
||||
backupsToKeep: Number(this.backupsToKeep),
|
||||
maxBackupSize: Number(this.maxBackupSize)
|
||||
}
|
||||
@@ -116,9 +113,9 @@ export default {
|
||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||
|
||||
this.backupsToKeep = this.newServerSettings.backupsToKeep || 2
|
||||
this.dailyBackups = !!this.newServerSettings.backupSchedule
|
||||
this.enableBackups = !!this.newServerSettings.backupSchedule
|
||||
this.maxBackupSize = this.newServerSettings.maxBackupSize || 1
|
||||
// this.cronExpression = '30 1 * * *'
|
||||
this.cronExpression = this.newServerSettings.backupSchedule || '30 1 * * *'
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -53,10 +53,10 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="useSquareBookCovers" :disabled="updatingServerSettings" @input="updateBookCoverAspectRatio" />
|
||||
<ui-tooltip :text="tooltips.coverAspectRatio">
|
||||
<ui-toggle-switch v-model="homeUseAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateHomeAlternativeBookshelfView" />
|
||||
<ui-tooltip :text="tooltips.bookshelfView">
|
||||
<p class="pl-4">
|
||||
Square book covers
|
||||
Alternative bookshelf view for home page
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
@@ -172,17 +172,15 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="showExperimentalFeatures" />
|
||||
<ui-tooltip :text="tooltips.experimentalFeatures">
|
||||
<p class="pl-4">
|
||||
Experimental Features
|
||||
<a href="https://github.com/advplyr/audiobookshelf/discussions/75" target="_blank">
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</a>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<ui-toggle-switch v-model="showExperimentalFeatures" />
|
||||
<ui-tooltip :text="tooltips.experimentalFeatures">
|
||||
<p class="pl-4">
|
||||
Experimental Features
|
||||
<a href="https://github.com/advplyr/audiobookshelf/discussions/75" target="_blank">
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</a>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
@@ -196,10 +194,10 @@
|
||||
</div>
|
||||
|
||||
<!-- <div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerUseSingleThreadedProber" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerUseSingleThreadedProber', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerUseSingleThreadedProber">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerUseTone" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerUseTone', val)" />
|
||||
<ui-tooltip text="Tone library for metadata">
|
||||
<p class="pl-4">
|
||||
Scanner use old single threaded audio prober
|
||||
Use Tone library for metadata
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
@@ -211,8 +209,12 @@
|
||||
<div class="h-0.5 bg-primary bg-opacity-30 w-full" />
|
||||
|
||||
<div class="flex items-center py-4">
|
||||
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block mr-2" :loading="isPurgingCache" @click="purgeCache">Purge Cache</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block mr-2" :loading="isPurgingCache" @click.stop="purgeCache">Purge All Cache</ui-btn>
|
||||
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block mr-2" :loading="isPurgingCache" @click.stop="purgeItemsCache">Purge Items Cache</ui-btn>
|
||||
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block mr-2" :loading="isResettingLibraryItems" @click="resetLibraryItems">Remove All Library Items</ui-btn>
|
||||
</div>
|
||||
<div class="flex items-center py-4">
|
||||
<div class="flex-grow" />
|
||||
<p class="pr-2 text-sm font-book text-yellow-400">
|
||||
Report bugs, request features, and contribute on
|
||||
@@ -270,7 +272,7 @@ export default {
|
||||
return {
|
||||
isResettingLibraryItems: false,
|
||||
updatingServerSettings: false,
|
||||
useSquareBookCovers: false,
|
||||
homeUseAlternativeBookshelfView: false,
|
||||
useAlternativeBookshelfView: false,
|
||||
isPurgingCache: false,
|
||||
newServerSettings: {},
|
||||
@@ -362,6 +364,11 @@ export default {
|
||||
coverAspectRatio: val ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD
|
||||
})
|
||||
},
|
||||
updateHomeAlternativeBookshelfView(val) {
|
||||
this.updateServerSettings({
|
||||
homeBookshelfView: val ? this.$constants.BookshelfView.TITLES : this.$constants.BookshelfView.STANDARD
|
||||
})
|
||||
},
|
||||
updateAlternativeBookshelfView(val) {
|
||||
this.updateServerSettings({
|
||||
bookshelfView: val ? this.$constants.BookshelfView.TITLES : this.$constants.BookshelfView.STANDARD
|
||||
@@ -391,8 +398,7 @@ export default {
|
||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||
this.newServerSettings.sortingPrefixes = [...(this.newServerSettings.sortingPrefixes || [])]
|
||||
|
||||
this.useSquareBookCovers = this.newServerSettings.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
||||
|
||||
this.homeUseAlternativeBookshelfView = this.newServerSettings.homeBookshelfView === this.$constants.BookshelfView.TITLES
|
||||
this.useAlternativeBookshelfView = this.newServerSettings.bookshelfView === this.$constants.BookshelfView.TITLES
|
||||
},
|
||||
resetLibraryItems() {
|
||||
@@ -419,7 +425,7 @@ export default {
|
||||
this.showConfirmPurgeCache = false
|
||||
this.isPurgingCache = true
|
||||
await this.$axios
|
||||
.$post('/api/purgecache')
|
||||
.$post('/api/cache/purge')
|
||||
.then(() => {
|
||||
this.$toast.success('Cache Purged!')
|
||||
})
|
||||
@@ -428,6 +434,31 @@ export default {
|
||||
this.$toast.error('Failed to purge cache')
|
||||
})
|
||||
this.isPurgingCache = false
|
||||
},
|
||||
purgeItemsCache() {
|
||||
const payload = {
|
||||
message: `<span class="text-warning text-base">Warning! This will delete the entire folder at /metadata/cache/items.</span><br />Are you sure you want to purge items cache?`,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.sendPurgeItemsCache()
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
async sendPurgeItemsCache() {
|
||||
this.isPurgingCache = true
|
||||
await this.$axios
|
||||
.$post('/api/cache/items/purge')
|
||||
.then(() => {
|
||||
this.$toast.success('Items Cache Purged!')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to purge items cache', error)
|
||||
this.$toast.error('Failed to purge items cache')
|
||||
})
|
||||
this.isPurgingCache = false
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
176
client/pages/config/notifications.vue
Normal file
176
client/pages/config/notifications.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-3 md:p-8 mb-2 max-w-3xl mx-auto">
|
||||
<h2 class="text-xl font-semibold mb-4">Apprise Notification Settings</h2>
|
||||
<p class="mb-6 text-gray-200">
|
||||
In order to use this feature you will need to have an instance of <a href="https://github.com/caronc/apprise-api" target="_blank" class="hover:underline text-blue-400 hover:text-blue-300">Apprise API</a> running or an api that will handle those same requests. <br />The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at
|
||||
<span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">http://192.168.1.1:8337</span> then you would put <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">http://192.168.1.1:8337/notify</span>.
|
||||
</p>
|
||||
|
||||
<form @submit.prevent="submitForm">
|
||||
<ui-text-input-with-label ref="apiUrlInput" v-model="appriseApiUrl" :disabled="savingSettings" label="Apprise API Url" class="mb-2" />
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-text-input ref="maxNotificationQueueInput" type="number" v-model="maxNotificationQueue" no-spinner :disabled="savingSettings" :padding-x="1" text-center class="w-10" />
|
||||
|
||||
<ui-tooltip text="Events are limited to firing 1 per second. Events will be ignored if the queue is at max size. This prevents notification spamming." direction="right">
|
||||
<p class="pl-2 md:pl-4 text-base md:text-lg">Max queue size for notification events<span class="material-icons icon-text ml-1">info_outlined</span></p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-text-input ref="maxFailedAttemptsInput" type="number" v-model="maxFailedAttempts" no-spinner :disabled="savingSettings" :padding-x="1" text-center class="w-10" />
|
||||
|
||||
<ui-tooltip text="Notifications are disabled once they fail to send this many times." direction="right">
|
||||
<p class="pl-2 md:pl-4 text-base md:text-lg">Max failed attempts<span class="material-icons icon-text ml-1">info_outlined</span></p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end pt-4">
|
||||
<ui-btn :loading="savingSettings" type="submit">Save</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-6" />
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold">Notifications</h2>
|
||||
<ui-btn small color="success" class="flex items-center" @click="clickCreate">Create <span class="material-icons text-lg pl-2">add</span></ui-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="!notifications.length" class="flex justify-center text-center">
|
||||
<p class="text-lg text-gray-200">No notifications</p>
|
||||
</div>
|
||||
<template v-for="notification in notifications">
|
||||
<cards-notification-card :key="notification.id" :notification="notification" @update="updateSettings" @edit="editNotification" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<modals-notification-edit-modal v-model="showEditModal" :notification="selectedNotification" :notification-data="notificationData" @update="updateSettings" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
savingSettings: false,
|
||||
appriseApiUrl: null,
|
||||
maxNotificationQueue: 0,
|
||||
maxFailedAttempts: 0,
|
||||
notifications: [],
|
||||
notificationSettings: null,
|
||||
notificationData: null,
|
||||
showEditModal: false,
|
||||
selectedNotification: null,
|
||||
sendingTest: false
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
updateSettings(settings) {
|
||||
this.notificationSettings = settings
|
||||
this.notifications = settings.notifications
|
||||
},
|
||||
editNotification(notification) {
|
||||
this.selectedNotification = notification
|
||||
this.showEditModal = true
|
||||
},
|
||||
clickCreate() {
|
||||
this.selectedNotification = null
|
||||
this.showEditModal = true
|
||||
},
|
||||
validateAppriseApiUrl() {
|
||||
try {
|
||||
return new URL(this.appriseApiUrl)
|
||||
} catch (error) {
|
||||
console.log('URL error', error)
|
||||
this.$toast.error(error.message)
|
||||
return false
|
||||
}
|
||||
},
|
||||
validateForm() {
|
||||
if (this.$refs.apiUrlInput) {
|
||||
this.$refs.apiUrlInput.blur()
|
||||
}
|
||||
if (this.$refs.maxNotificationQueueInput) {
|
||||
this.$refs.maxNotificationQueueInput.blur()
|
||||
}
|
||||
if (this.$refs.maxFailedAttemptsInput) {
|
||||
this.$refs.maxFailedAttemptsInput.blur()
|
||||
}
|
||||
|
||||
if (!this.validateAppriseApiUrl()) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isNaN(this.maxNotificationQueue) || this.maxNotificationQueue <= 0) {
|
||||
this.$toast.error('Max notification queue must be >= 0')
|
||||
return false
|
||||
}
|
||||
|
||||
if (isNaN(this.maxFailedAttempts) || this.maxFailedAttempts <= 0) {
|
||||
this.$toast.error('Max failed attempts must be >= 0')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
submitForm() {
|
||||
if (!this.validateForm()) return
|
||||
|
||||
const updatePayload = {
|
||||
appriseApiUrl: this.appriseApiUrl || null,
|
||||
maxNotificationQueue: Number(this.maxNotificationQueue),
|
||||
maxFailedAttempts: Number(this.maxFailedAttempts)
|
||||
}
|
||||
this.savingSettings = true
|
||||
this.$axios
|
||||
.$patch('/api/notifications', updatePayload)
|
||||
.then(() => {
|
||||
this.$toast.success('Notification settings updated')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update notification settings', error)
|
||||
this.$toast.error('Failed to update notification settings')
|
||||
})
|
||||
.finally(() => {
|
||||
this.savingSettings = false
|
||||
})
|
||||
},
|
||||
async init() {
|
||||
this.loading = true
|
||||
const notificationResponse = await this.$axios.$get('/api/notifications').catch((error) => {
|
||||
console.error('Failed to get notification settings', error)
|
||||
this.$toast.error('Failed to load notification settings')
|
||||
return null
|
||||
})
|
||||
this.loading = false
|
||||
if (!notificationResponse) {
|
||||
return
|
||||
}
|
||||
this.notificationData = notificationResponse.data
|
||||
this.setNotificationSettings(notificationResponse.settings)
|
||||
},
|
||||
setNotificationSettings(notificationSettings) {
|
||||
this.notificationSettings = notificationSettings
|
||||
this.appriseApiUrl = notificationSettings.appriseApiUrl
|
||||
this.maxNotificationQueue = notificationSettings.maxNotificationQueue
|
||||
this.maxFailedAttempts = notificationSettings.maxFailedAttempts
|
||||
this.notifications = notificationSettings.notifications || []
|
||||
},
|
||||
notificationsUpdated(notificationSettings) {
|
||||
console.log('Notifications updated', notificationSettings)
|
||||
this.setNotificationSettings(notificationSettings)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
this.$root.socket.on('notifications_updated', this.notificationsUpdated)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$root.socket.off('notifications_updated', this.notificationsUpdated)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user