mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-01-01 20:17:51 -05:00
Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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.0.9 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
|
||||
|
||||
@@ -8,8 +8,6 @@ CONFIG_PATH="/etc/default/audiobookshelf"
|
||||
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.0.9/tone-0.0.9-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.0.9-linux-x64.tar.gz --strip-components=1
|
||||
rm tone-0.0.9-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"
|
||||
|
||||
|
||||
@@ -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,11 +3,11 @@
|
||||
<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-8 min-w-8 h-8 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" />
|
||||
@@ -15,7 +15,7 @@
|
||||
<controls-global-search v-if="currentLibrary" class="mr-1 sm:mr-0" />
|
||||
<div class="flex-grow" />
|
||||
|
||||
<span v-if="showExperimentalFeatures" class="material-icons text-2xl md: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>
|
||||
@@ -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="coverAspectRatio" />
|
||||
<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>
|
||||
@@ -175,7 +175,6 @@ export default {
|
||||
}
|
||||
this.shelves = shelves
|
||||
},
|
||||
settingsUpdated(settings) {},
|
||||
scan() {
|
||||
this.$store
|
||||
.dispatch('libraries/requestLibraryScan', { libraryId: this.$store.state.libraries.currentLibraryId })
|
||||
@@ -187,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
|
||||
@@ -244,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') {
|
||||
@@ -267,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)
|
||||
@@ -285,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">
|
||||
@@ -57,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" />
|
||||
@@ -89,7 +83,7 @@ export default {
|
||||
authors: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -247,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()
|
||||
},
|
||||
|
||||
@@ -82,6 +82,11 @@ export default {
|
||||
id: 'config-log',
|
||||
title: 'Logs',
|
||||
path: '/config/log'
|
||||
},
|
||||
{
|
||||
id: 'config-notifications',
|
||||
title: 'Notifications',
|
||||
path: '/config/notifications'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
|
||||
@@ -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 * 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>
|
||||
|
||||
@@ -128,7 +128,8 @@ export default {
|
||||
},
|
||||
orderBy: String,
|
||||
filterBy: String,
|
||||
sortingIgnorePrefix: Boolean
|
||||
sortingIgnorePrefix: Boolean,
|
||||
continueListeningShelf: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -181,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)
|
||||
@@ -368,7 +370,7 @@ export default {
|
||||
},
|
||||
moreMenuItems() {
|
||||
if (this.recentEpisode) {
|
||||
return [
|
||||
const items = [
|
||||
{
|
||||
func: 'editPodcast',
|
||||
text: 'Edit Podcast'
|
||||
@@ -378,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 = []
|
||||
@@ -411,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() {
|
||||
@@ -572,11 +593,12 @@ export default {
|
||||
} else if (result === 'REMOVED') {
|
||||
this.$toast.error(`Re-Scan complete item was removed`)
|
||||
}
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to scan library item', error)
|
||||
this.$toast.error('Failed to scan library item')
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
@@ -588,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)
|
||||
|
||||
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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
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>
|
||||
|
||||
@@ -66,9 +66,9 @@ 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
|
||||
},
|
||||
@@ -141,10 +141,10 @@ export default {
|
||||
if (tab.mediaType && this.mediaType !== tab.mediaType) return false
|
||||
if (tab.admin && !this.userIsAdminOrUp) return false
|
||||
|
||||
if (tab.id === 'manage' && this.isMissing) return false
|
||||
if (tab.id === 'tools' && this.isMissing) 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' || 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
|
||||
})
|
||||
|
||||
@@ -164,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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,16 +25,16 @@
|
||||
<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 px-2 py-6 md:p-8 max-h-full overflow-y-auto overflow-x-hidden">
|
||||
<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="selectedMatch = null">
|
||||
<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">
|
||||
<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">
|
||||
@@ -43,46 +43,49 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.title" class="flex items-center py-2">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<div v-if="selectedMatchOrig.description" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.description" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" label="Description" class="flex-grow ml-4" />
|
||||
<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">
|
||||
<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">
|
||||
<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" />
|
||||
@@ -90,46 +93,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedMatch.series" class="flex items-center py-2">
|
||||
<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" @input="checkboxToggled" />
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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" />
|
||||
@@ -137,28 +136,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedMatch.itunesId" class="flex items-center py-2">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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" />
|
||||
@@ -193,6 +192,7 @@ export default {
|
||||
searchResults: [],
|
||||
hasSearched: false,
|
||||
selectedMatch: null,
|
||||
selectedMatchOrig: null,
|
||||
selectedMatchUsage: {
|
||||
title: true,
|
||||
subtitle: true,
|
||||
@@ -203,7 +203,6 @@ export default {
|
||||
publisher: true,
|
||||
publishedYear: true,
|
||||
series: true,
|
||||
volumeNumber: true,
|
||||
genres: true,
|
||||
tags: true,
|
||||
language: true,
|
||||
@@ -241,14 +240,13 @@ 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
|
||||
}
|
||||
},
|
||||
@@ -338,7 +336,7 @@ export default {
|
||||
this.hasSearched = true
|
||||
},
|
||||
init() {
|
||||
this.selectedMatch = null
|
||||
this.clearSelectedMatch()
|
||||
this.selectedMatchUsage = {
|
||||
title: true,
|
||||
subtitle: true,
|
||||
@@ -349,7 +347,6 @@ export default {
|
||||
publisher: true,
|
||||
publishedYear: true,
|
||||
series: true,
|
||||
volumeNumber: true,
|
||||
genres: true,
|
||||
tags: true,
|
||||
language: true,
|
||||
@@ -392,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,
|
||||
@@ -430,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)) {
|
||||
@@ -449,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') {
|
||||
@@ -500,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
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>
|
||||
@@ -55,7 +55,7 @@ export default {
|
||||
return this.item.coverPath
|
||||
},
|
||||
coverUrl() {
|
||||
if (!this.coverPath) return '/book_placeholder.jpg'
|
||||
if (!this.coverPath) return `${this.$config.routerBasePath}/book_placeholder.jpg`
|
||||
return this.$store.getters['globals/getLibraryItemCoverSrcById'](this.libraryItemId)
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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() {}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -269,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)
|
||||
@@ -301,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)
|
||||
},
|
||||
@@ -358,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',
|
||||
@@ -417,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')
|
||||
@@ -533,6 +506,19 @@ export default {
|
||||
// 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() {
|
||||
@@ -550,6 +536,8 @@ export default {
|
||||
|
||||
this.checkVersionUpdate()
|
||||
|
||||
this.loadTasks()
|
||||
|
||||
if (this.$route.query.error) {
|
||||
this.$toast.error(this.$route.query.error)
|
||||
this.$router.replace(this.$route.path)
|
||||
|
||||
@@ -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: [
|
||||
@@ -73,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: {
|
||||
@@ -89,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
|
||||
@@ -109,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"
|
||||
}
|
||||
]
|
||||
|
||||
2
client/package-lock.json
generated
2
client/package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.1.5",
|
||||
"version": "2.2.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.1.5",
|
||||
"version": "2.2.0",
|
||||
"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">
|
||||
@@ -81,7 +81,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 +107,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 +132,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>
|
||||
@@ -197,6 +217,9 @@ export default {
|
||||
mediaDuration() {
|
||||
return this.media.duration
|
||||
},
|
||||
mediaDurationRounded() {
|
||||
return Math.round(this.mediaDuration)
|
||||
},
|
||||
chapters() {
|
||||
return this.media.chapters || []
|
||||
},
|
||||
@@ -281,7 +304,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)
|
||||
|
||||
@@ -369,6 +392,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
|
||||
|
||||
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>
|
||||
@@ -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
|
||||
@@ -423,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!')
|
||||
})
|
||||
@@ -432,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>
|
||||
@@ -46,7 +46,14 @@
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
|
||||
<div class="py-2">
|
||||
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Saved Media Progress</h1>
|
||||
<table v-if="mediaProgress.length" class="userAudiobooksTable">
|
||||
|
||||
<div v-if="mediaProgressWithoutMedia.length" class="flex items-center py-2 mb-2">
|
||||
<p class="text-error">User has media progress for {{ mediaProgressWithoutMedia.length }} items that no longer exist.</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn small :loading="purgingMediaProgress" @click.stop="purgeMediaProgress">Purge Media Progress</ui-btn>
|
||||
</div>
|
||||
|
||||
<table v-if="mediaProgressWithMedia.length" class="userAudiobooksTable">
|
||||
<tr class="bg-primary bg-opacity-40">
|
||||
<th class="w-16 text-left">Item</th>
|
||||
<th class="text-left"></th>
|
||||
@@ -54,13 +61,19 @@
|
||||
<th class="w-40 hidden sm:table-cell">Started At</th>
|
||||
<th class="w-40 hidden sm:table-cell">Last Update</th>
|
||||
</tr>
|
||||
<tr v-for="item in mediaProgress" :key="item.id" :class="!item.isFinished ? '' : 'isFinished'">
|
||||
<tr v-for="item in mediaProgressWithMedia" :key="item.id" :class="!item.isFinished ? '' : 'isFinished'">
|
||||
<td>
|
||||
<covers-book-cover :width="50" :library-item="item" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</td>
|
||||
<td class="font-book">
|
||||
<p>{{ item.media && item.media.metadata ? item.media.metadata.title : 'Unknown' }}</p>
|
||||
<p v-if="item.media && item.media.metadata && item.media.metadata.authorName" class="text-white text-opacity-50 text-sm font-sans">by {{ item.media.metadata.authorName }}</p>
|
||||
<template v-if="item.media && item.media.metadata && item.episode">
|
||||
<p>{{ item.episode.title || 'Unknown' }}</p>
|
||||
<p class="text-white text-opacity-50 text-sm font-sans">{{ item.media.metadata.title }}</p>
|
||||
</template>
|
||||
<template v-else-if="item.media && item.media.metadata">
|
||||
<p>{{ item.media.metadata.title || 'Unknown' }}</p>
|
||||
<p v-if="item.media.metadata.authorName" class="text-white text-opacity-50 text-sm font-sans">by {{ item.media.metadata.authorName }}</p>
|
||||
</template>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<p class="text-sm">{{ Math.floor(item.progress * 100) }}%</p>
|
||||
@@ -98,7 +111,8 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
listeningSessions: [],
|
||||
listeningStats: {}
|
||||
listeningStats: {},
|
||||
purgingMediaProgress: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -117,6 +131,12 @@ export default {
|
||||
mediaProgress() {
|
||||
return this.user.mediaProgress.sort((a, b) => b.lastUpdate - a.lastUpdate)
|
||||
},
|
||||
mediaProgressWithMedia() {
|
||||
return this.mediaProgress.filter((mp) => mp.media)
|
||||
},
|
||||
mediaProgressWithoutMedia() {
|
||||
return this.mediaProgress.filter((mp) => !mp.media)
|
||||
},
|
||||
totalListeningTime() {
|
||||
return this.listeningStats.totalTime || 0
|
||||
},
|
||||
@@ -150,6 +170,24 @@ export default {
|
||||
return []
|
||||
})
|
||||
console.log('Loaded user listening data', this.listeningSessions, this.listeningStats)
|
||||
},
|
||||
purgeMediaProgress() {
|
||||
this.purgingMediaProgress = true
|
||||
|
||||
this.$axios
|
||||
.$post(`/api/users/${this.user.id}/purge-media-progress`)
|
||||
.then((updatedUser) => {
|
||||
console.log('Updated user', updatedUser)
|
||||
this.$toast.success('Media progress purged')
|
||||
this.user = updatedUser
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to purge media progress', error)
|
||||
this.$toast.error('Failed to purge media progress')
|
||||
})
|
||||
.finally(() => {
|
||||
this.purgingMediaProgress = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
<template>
|
||||
<div id="page-wrapper" class="bg-bg page p-8 overflow-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<div class="flex justify-center mb-2">
|
||||
<div class="w-full max-w-2xl">
|
||||
<p class="text-xl">Metadata to embed</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-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="(keyValue, index) in metadataKeyValues">
|
||||
<div :key="keyValue.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">{{ keyValue.key }}</div>
|
||||
<div class="w-2/3">
|
||||
{{ keyValue.value }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full max-w-2xl border border-opacity-10 bg-bg mx-2">
|
||||
<div class="flex py-2 px-4">
|
||||
<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">
|
||||
<template v-for="(chapter, index) in metadataChapters">
|
||||
<div :key="index" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
|
||||
<div class="flex-grow font-semibold">{{ chapter.title }}</div>
|
||||
<div class="w-24">
|
||||
{{ chapter.start.toFixed(2) }}
|
||||
</div>
|
||||
<div class="w-24">
|
||||
{{ chapter.end.toFixed(2) }}
|
||||
</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 class="w-full flex justify-between items-center mb-4">
|
||||
<p class="text-warning text-lg font-semibold">Warning: Modifies your audio files</p>
|
||||
<ui-btn v-if="!embedFinished" color="primary" :loading="updatingMetadata" @click="updateAudioFileMetadata">Embed Metadata</ui-btn>
|
||||
<p v-else class="text-success text-lg font-semibold">Embed Finished!</p>
|
||||
</div>
|
||||
<div class="w-full mx-auto border border-opacity-10 bg-bg">
|
||||
<div class="flex py-2 px-4">
|
||||
<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 {
|
||||
audiofilesEncoding: {},
|
||||
audiofilesFinished: {},
|
||||
updatingMetadata: false,
|
||||
embedFinished: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
libraryItemId() {
|
||||
return this.libraryItem.id
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
audioFiles() {
|
||||
return this.media.audioFiles || []
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
metadataKeyValues() {
|
||||
const keyValues = [
|
||||
{
|
||||
key: 'title',
|
||||
value: this.mediaMetadata.title
|
||||
},
|
||||
{
|
||||
key: 'artist',
|
||||
value: this.mediaMetadata.authorName
|
||||
},
|
||||
{
|
||||
key: 'album_artist',
|
||||
value: this.mediaMetadata.authorName
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
value: this.mediaMetadata.publishedYear
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
value: this.mediaMetadata.description
|
||||
},
|
||||
{
|
||||
key: 'genre',
|
||||
value: this.mediaMetadata.genres.join(';')
|
||||
},
|
||||
{
|
||||
key: 'performer',
|
||||
value: this.mediaMetadata.narratorName
|
||||
}
|
||||
]
|
||||
|
||||
if (this.mediaMetadata.subtitle) {
|
||||
keyValues.push({
|
||||
key: 'subtitle',
|
||||
value: this.mediaMetadata.subtitle
|
||||
})
|
||||
}
|
||||
|
||||
if (this.mediaMetadata.asin) {
|
||||
keyValues.push({
|
||||
key: 'asin',
|
||||
value: this.mediaMetadata.asin
|
||||
})
|
||||
}
|
||||
if (this.mediaMetadata.isbn) {
|
||||
keyValues.push({
|
||||
key: 'isbn',
|
||||
value: this.mediaMetadata.isbn
|
||||
})
|
||||
}
|
||||
if (this.mediaMetadata.language) {
|
||||
keyValues.push({
|
||||
key: 'language',
|
||||
value: this.mediaMetadata.language
|
||||
})
|
||||
}
|
||||
if (this.mediaMetadata.series.length) {
|
||||
var firstSeries = this.mediaMetadata.series[0]
|
||||
keyValues.push({
|
||||
key: 'series',
|
||||
value: firstSeries.name
|
||||
})
|
||||
if (firstSeries.sequence) {
|
||||
keyValues.push({
|
||||
key: 'series-part',
|
||||
value: firstSeries.sequence
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return keyValues
|
||||
},
|
||||
metadataChapters() {
|
||||
var chapters = this.media.chapters || []
|
||||
return chapters.concat(chapters)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateAudioFileMetadata() {
|
||||
if (confirm(`Warning!\n\nThis will modify the audio files for this audiobook.\nMake sure your audio files are backed up before using this feature.`)) {
|
||||
this.updatingMetadata = true
|
||||
this.$axios
|
||||
.$get(`/api/items/${this.libraryItemId}/audio-metadata`)
|
||||
.then(() => {
|
||||
console.log('Audio metadata encode started')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Audio metadata encode failed', error)
|
||||
this.updatingMetadata = false
|
||||
})
|
||||
}
|
||||
},
|
||||
audioMetadataStarted(data) {
|
||||
console.log('audio metadata started', data)
|
||||
if (data.libraryItemId !== this.libraryItemId) return
|
||||
this.audiofilesFinished = {}
|
||||
this.updatingMetadata = true
|
||||
},
|
||||
audioMetadataFinished(data) {
|
||||
console.log('audio metadata finished', data)
|
||||
if (data.libraryItemId !== this.libraryItemId) return
|
||||
this.updatingMetadata = false
|
||||
this.embedFinished = 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)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
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>
|
||||
161
client/pages/library/_library/podcast/latest.vue
Normal file
161
client/pages/library/_library/podcast/latest.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<app-book-shelf-toolbar page="recent-episodes" />
|
||||
|
||||
<div id="bookshelf" class="w-full overflow-y-auto px-2 py-6 sm:px-4 md:p-12 relative">
|
||||
<div class="w-full max-w-3xl mx-auto py-4">
|
||||
<p class="text-xl mb-2 font-semibold">Latest episodes</p>
|
||||
<p v-if="!recentEpisodes.length && !processing" class="text-center text-xl">No podcasts found</p>
|
||||
<template v-for="(episode, index) in episodesMapped">
|
||||
<div :key="episode.id" class="flex py-5 cursor-pointer relative" @click.stop="clickEpisode(episode)">
|
||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](episode.libraryItemId)" :width="96" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" />
|
||||
<div class="flex-grow pl-4 max-w-2xl">
|
||||
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcast.metadata.title }}</nuxt-link>
|
||||
|
||||
<p class="text-xs text-gray-300 mb-1">{{ $dateDistanceFromNow(episode.publishedAt) }}</p>
|
||||
|
||||
<p class="font-semibold mb-2">{{ episode.title }}</p>
|
||||
|
||||
<p class="text-sm text-gray-200 mb-4">{{ episode.subtitle }}</p>
|
||||
|
||||
<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="episode.progress && episode.progress.isFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick(episode)">
|
||||
<span v-if="episodeIdStreaming === episode.id" class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||
<span v-else class="material-icons text-success">play_arrow</span>
|
||||
<p class="pl-2 pr-1 text-sm font-semibold">{{ getButtonText(episode) }}</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="episode.progress" class="absolute bottom-0 left-0 h-0.5 pointer-events-none bg-warning" :style="{ width: episode.progress.progress * 100 + '%' }" />
|
||||
</div>
|
||||
<div :key="index" v-if="index !== recentEpisodes.length" class="w-full h-px bg-white bg-opacity-10" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ params, query, store, app, redirect }) {
|
||||
var libraryId = params.library
|
||||
var libraryData = await store.dispatch('libraries/fetch', libraryId)
|
||||
if (!libraryData) {
|
||||
return redirect('/oops?message=Library not found')
|
||||
}
|
||||
|
||||
// Redirect book libraries
|
||||
const library = libraryData.library
|
||||
if (library.mediaType === 'book') {
|
||||
return redirect(`/library/${libraryId}`)
|
||||
}
|
||||
|
||||
return {
|
||||
libraryId
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
recentEpisodes: [],
|
||||
totalEpisodes: 0,
|
||||
currentPage: 0,
|
||||
processing: false,
|
||||
openingItem: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
libraryItemIdStreaming() {
|
||||
return this.$store.getters['getLibraryItemIdStreaming']
|
||||
},
|
||||
episodeIdStreaming() {
|
||||
return this.$store.state.streamEpisodeId
|
||||
},
|
||||
streamIsPlaying() {
|
||||
return this.$store.state.streamIsPlaying
|
||||
},
|
||||
episodesMapped() {
|
||||
return this.recentEpisodes.map((ep) => {
|
||||
return {
|
||||
...ep,
|
||||
progress: this.$store.getters['user/getUserMediaProgress'](ep.libraryItemId, ep.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async clickEpisode(episode) {
|
||||
if (this.openingItem) return
|
||||
this.openingItem = true
|
||||
const fullLibraryItem = await this.$axios.$get(`/api/items/${episode.libraryItemId}`).catch((error) => {
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
this.$toast.error(errMsg || 'Failed to get library item')
|
||||
return null
|
||||
})
|
||||
this.openingItem = false
|
||||
if (!fullLibraryItem) return
|
||||
|
||||
this.$store.commit('setSelectedLibraryItem', fullLibraryItem)
|
||||
this.$store.commit('globals/setSelectedEpisode', episode)
|
||||
this.$store.commit('globals/setShowViewPodcastEpisodeModal', true)
|
||||
},
|
||||
getButtonText(episode) {
|
||||
if (this.episodeIdStreaming === episode.id) return this.streamIsPlaying ? 'Streaming' : 'Play'
|
||||
if (!episode.progress) return this.$elapsedPretty(episode.duration)
|
||||
if (episode.progress.isFinished) return 'Finished'
|
||||
var remaining = Math.floor(episode.progress.duration - episode.progress.currentTime)
|
||||
return `${this.$elapsedPretty(remaining)} left`
|
||||
},
|
||||
playClick(episodeToPlay) {
|
||||
if (episodeToPlay.id === this.episodeIdStreaming && this.streamIsPlaying) {
|
||||
return this.$eventBus.$emit('pause-item')
|
||||
}
|
||||
|
||||
// Queue up more recent items
|
||||
const queueItems = []
|
||||
const episodeIndex = this.episodesMapped.findIndex((e) => e.id === episodeToPlay.id)
|
||||
const indexFromBack = this.episodesMapped.length - episodeIndex - 1
|
||||
for (let i = this.episodesMapped.length - 1 - indexFromBack; i >= 0; i--) {
|
||||
const episode = this.episodesMapped[i]
|
||||
if (!episode.progress || !episode.isFinished) {
|
||||
queueItems.push({
|
||||
libraryItemId: episode.libraryItemId,
|
||||
episodeId: episode.id,
|
||||
title: episode.title,
|
||||
subtitle: episode.podcast.metadata.title,
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
|
||||
duration: episode.duration || null,
|
||||
coverPath: episode.podcast.coverPath || null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: episodeToPlay.libraryItemId,
|
||||
episodeId: episodeToPlay.id,
|
||||
queueItems
|
||||
})
|
||||
},
|
||||
async loadRecentEpisodes(page = 0) {
|
||||
this.processing = true
|
||||
const episodePayload = await this.$axios.$get(`/api/libraries/${this.libraryId}/recent-episodes?limit=25&page=${page}`).catch((error) => {
|
||||
console.error('Failed to get recent episodes', error)
|
||||
this.$toast.error('Failed to get recent episodes')
|
||||
return null
|
||||
})
|
||||
this.processing = false
|
||||
console.log('Episodes', episodePayload)
|
||||
this.recentEpisodes = episodePayload.episodes || []
|
||||
this.totalEpisodes = episodePayload.total
|
||||
this.currentPage = page
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadRecentEpisodes()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<app-book-shelf-toolbar page="podcast-search" />
|
||||
|
||||
<div class="w-full h-full overflow-y-auto px-2 py-6 sm:px-4 md:p-12 relative">
|
||||
<div id="bookshelf" class="w-full overflow-y-auto px-2 py-6 sm:px-4 md:p-12 relative">
|
||||
<div class="w-full max-w-4xl mx-auto flex">
|
||||
<form @submit.prevent="submit" class="flex flex-grow">
|
||||
<ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2 text-sm md:text-base" />
|
||||
|
||||
@@ -40,7 +40,20 @@ export default {
|
||||
return this.$store.state.streamLibraryItem
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
beforeDestroy() {}
|
||||
methods: {
|
||||
seriesUpdated(series) {
|
||||
this.series = series
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.$root.socket) {
|
||||
this.$root.socket.on('series_updated', this.seriesUpdated)
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.$root.socket) {
|
||||
this.$root.socket.off('series_updated', this.seriesUpdated)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -296,7 +296,7 @@ export default class PlayerHandler {
|
||||
currentTime
|
||||
}
|
||||
this.listeningTimeSinceSync = 0
|
||||
this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 1000 }).then(() => {
|
||||
this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 3000 }).then(() => {
|
||||
this.failedProgressSyncs = 0
|
||||
}).catch((error) => {
|
||||
console.error('Failed to update session progress', error)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export default function ({ $axios, store }) {
|
||||
export default function ({ $axios, store, $config }) {
|
||||
$axios.onRequest(config => {
|
||||
if (!config.url) {
|
||||
console.error('Axios request invalid config', config)
|
||||
|
||||
@@ -143,8 +143,10 @@ export {
|
||||
encode,
|
||||
decode
|
||||
}
|
||||
export default ({ app }, inject) => {
|
||||
export default ({ app, store }, inject) => {
|
||||
app.$decode = decode
|
||||
app.$encode = encode
|
||||
inject('isDev', process.env.NODE_ENV !== 'production')
|
||||
|
||||
store.commit('setRouterBasePath', app.$config.routerBasePath)
|
||||
}
|
||||
@@ -27,18 +27,27 @@ Vue.prototype.$elapsedPretty = (seconds, useFullNames = false) => {
|
||||
return `${hours} ${useFullNames ? `hour${hours === 1 ? '' : 's'}` : 'hr'} ${minutes} ${useFullNames ? `minute${minutes === 1 ? '' : 's'}` : 'min'}`
|
||||
}
|
||||
|
||||
Vue.prototype.$secondsToTimestamp = (seconds) => {
|
||||
if (!seconds) return '0:00'
|
||||
Vue.prototype.$secondsToTimestamp = (seconds, includeMs = false, alwaysIncludeHours = false) => {
|
||||
if (!seconds) {
|
||||
return alwaysIncludeHours ? '00:00:00' : '0:00'
|
||||
}
|
||||
var _seconds = seconds
|
||||
var _minutes = Math.floor(seconds / 60)
|
||||
_seconds -= _minutes * 60
|
||||
var _hours = Math.floor(_minutes / 60)
|
||||
_minutes -= _hours * 60
|
||||
|
||||
var ms = _seconds - Math.floor(seconds)
|
||||
_seconds = Math.floor(_seconds)
|
||||
if (!_hours) {
|
||||
return `${_minutes}:${_seconds.toString().padStart(2, '0')}`
|
||||
|
||||
var msString = includeMs ? '.' + ms.toFixed(3).split('.')[1] : ''
|
||||
if (alwaysIncludeHours) {
|
||||
return `${_hours.toString().padStart(2, '0')}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}${msString}`
|
||||
}
|
||||
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
|
||||
if (!_hours) {
|
||||
return `${_minutes}:${_seconds.toString().padStart(2, '0')}${msString}`
|
||||
}
|
||||
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}${msString}`
|
||||
}
|
||||
|
||||
Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true) => {
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
|
||||
export const state = () => ({
|
||||
downloads: []
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
getDownloads: (state) => (libraryItemId) => {
|
||||
return state.downloads.filter(d => d.libraryItemId === libraryItemId)
|
||||
},
|
||||
getDownload: (state) => (id) => {
|
||||
return state.downloads.find(d => d.id === id)
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
setDownloads(state, downloads) {
|
||||
state.downloads = downloads
|
||||
},
|
||||
addUpdateDownload(state, download) {
|
||||
var index = state.downloads.findIndex(d => d.id === download.id)
|
||||
if (index >= 0) {
|
||||
state.downloads.splice(index, 1, download)
|
||||
} else {
|
||||
state.downloads.push(download)
|
||||
}
|
||||
},
|
||||
removeDownload(state, download) {
|
||||
state.downloads = state.downloads.filter(d => d.id !== download.id)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export const state = () => ({
|
||||
selectedAuthor: null,
|
||||
isCasting: false, // Actively casting
|
||||
isChromecastInitialized: false, // Script loaded
|
||||
showBatchQuickMatchModal: false,
|
||||
dateFormats: [
|
||||
{
|
||||
text: 'MM/DD/YYYY',
|
||||
@@ -31,7 +32,8 @@ export const state = () => ({
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
getLibraryItemCoverSrc: (state, getters, rootState, rootGetters) => (libraryItem, placeholder = '/book_placeholder.jpg') => {
|
||||
getLibraryItemCoverSrc: (state, getters, rootState, rootGetters) => (libraryItem, placeholder = null) => {
|
||||
if (!placeholder) placeholder = `${rootState.routerBasePath}/book_placeholder.jpg`
|
||||
if (!libraryItem) return placeholder
|
||||
var media = libraryItem.media
|
||||
if (!media || !media.coverPath || media.coverPath === placeholder) return placeholder
|
||||
@@ -39,21 +41,24 @@ export const getters = {
|
||||
// Absolute URL covers (should no longer be used)
|
||||
if (media.coverPath.startsWith('http:') || media.coverPath.startsWith('https:')) return media.coverPath
|
||||
|
||||
var userToken = rootGetters['user/getToken']
|
||||
var lastUpdate = libraryItem.updatedAt || Date.now()
|
||||
const userToken = rootGetters['user/getToken']
|
||||
const lastUpdate = libraryItem.updatedAt || Date.now()
|
||||
const libraryItemId = libraryItem.libraryItemId || libraryItem.id // Workaround for /users/:id page showing media progress covers
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') { // Testing
|
||||
return `http://localhost:3333/api/items/${libraryItem.id}/cover?token=${userToken}&ts=${lastUpdate}`
|
||||
return `http://localhost:3333${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}`
|
||||
}
|
||||
return `/api/items/${libraryItem.id}/cover?token=${userToken}&ts=${lastUpdate}`
|
||||
|
||||
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}`
|
||||
},
|
||||
getLibraryItemCoverSrcById: (state, getters, rootState, rootGetters) => (libraryItemId, placeholder = '/book_placeholder.jpg') => {
|
||||
getLibraryItemCoverSrcById: (state, getters, rootState, rootGetters) => (libraryItemId, placeholder = null) => {
|
||||
if (!placeholder) placeholder = `${rootState.routerBasePath}/book_placeholder.jpg`
|
||||
if (!libraryItemId) return placeholder
|
||||
var userToken = rootGetters['user/getToken']
|
||||
if (process.env.NODE_ENV !== 'production') { // Testing
|
||||
return `http://localhost:3333/api/items/${libraryItemId}/cover?token=${userToken}`
|
||||
return `http://localhost:3333${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}`
|
||||
}
|
||||
return `/api/items/${libraryItemId}/cover?token=${userToken}`
|
||||
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,5 +113,8 @@ export const mutations = {
|
||||
},
|
||||
setCasting(state, val) {
|
||||
state.isCasting = val
|
||||
},
|
||||
setShowBatchQuickMatchModal(state, val) {
|
||||
state.showBatchQuickMatchModal = val
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,8 @@ export const state = () => ({
|
||||
bookshelfBookIds: [],
|
||||
openModal: null,
|
||||
innerModalOpen: false,
|
||||
lastBookshelfScrollData: {}
|
||||
lastBookshelfScrollData: {},
|
||||
routerBasePath: '/'
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
@@ -119,6 +120,9 @@ export const actions = {
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
setRouterBasePath(state, rbp) {
|
||||
state.routerBasePath = rbp
|
||||
},
|
||||
setSource(state, source) {
|
||||
state.Source = source
|
||||
},
|
||||
|
||||
31
client/store/tasks.js
Normal file
31
client/store/tasks.js
Normal file
@@ -0,0 +1,31 @@
|
||||
|
||||
export const state = () => ({
|
||||
tasks: []
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
getTaskByLibraryItemId: (state) => (libraryItemId) => {
|
||||
return state.tasks.find(t => t.data && t.data.libraryItemId === libraryItemId)
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
setTasks(state, tasks) {
|
||||
state.tasks = tasks
|
||||
},
|
||||
addUpdateTask(state, task) {
|
||||
var index = state.tasks.findIndex(d => d.id === task.id)
|
||||
if (index >= 0) {
|
||||
state.tasks.splice(index, 1, task)
|
||||
} else {
|
||||
state.tasks.push(task)
|
||||
}
|
||||
},
|
||||
removeTask(state, task) {
|
||||
state.tasks = state.tasks.filter(d => d.id !== task.id)
|
||||
}
|
||||
}
|
||||
4
index.js
4
index.js
@@ -11,6 +11,7 @@ if (isDev) {
|
||||
process.env.FFMPEG_PATH = devEnv.FFmpegPath
|
||||
process.env.FFPROBE_PATH = devEnv.FFProbePath
|
||||
process.env.SOURCE = 'local'
|
||||
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || ''
|
||||
}
|
||||
|
||||
const PORT = process.env.PORT || 80
|
||||
@@ -20,8 +21,9 @@ const METADATA_PATH = process.env.METADATA_PATH || '/metadata'
|
||||
const UID = process.env.AUDIOBOOKSHELF_UID || 99
|
||||
const GID = process.env.AUDIOBOOKSHELF_GID || 100
|
||||
const SOURCE = process.env.SOURCE || 'docker'
|
||||
const ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH || ''
|
||||
|
||||
console.log('Config', CONFIG_PATH, METADATA_PATH)
|
||||
|
||||
const Server = new server(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH)
|
||||
const Server = new server(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH)
|
||||
Server.start()
|
||||
|
||||
13
package-lock.json
generated
13
package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.1.5",
|
||||
"version": "2.2.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
@@ -13,6 +13,7 @@
|
||||
"express": "^4.17.1",
|
||||
"graceful-fs": "^4.2.10",
|
||||
"htmlparser2": "^8.0.1",
|
||||
"node-tone": "^1.0.1",
|
||||
"socket.io": "^4.4.1",
|
||||
"xml2js": "^0.4.23"
|
||||
},
|
||||
@@ -594,6 +595,11 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-tone": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/node-tone/-/node-tone-1.0.1.tgz",
|
||||
"integrity": "sha512-wi7L0taDZMN6tM5l85TDKHsYzdhqJTtPNgvgpk2zHeZzPt6ZIUZ9vBLTJRRDpm0xzCvbsvFHjAaudeQjLHTE4w=="
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -1360,6 +1366,11 @@
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="
|
||||
},
|
||||
"node-tone": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/node-tone/-/node-tone-1.0.1.tgz",
|
||||
"integrity": "sha512-wi7L0taDZMN6tM5l85TDKHsYzdhqJTtPNgvgpk2zHeZzPt6ZIUZ9vBLTJRRDpm0xzCvbsvFHjAaudeQjLHTE4w=="
|
||||
},
|
||||
"object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.1.5",
|
||||
"version": "2.2.0",
|
||||
"description": "Self-hosted audiobook and podcast server",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -34,6 +34,7 @@
|
||||
"express": "^4.17.1",
|
||||
"graceful-fs": "^4.2.10",
|
||||
"htmlparser2": "^8.0.1",
|
||||
"node-tone": "^1.0.1",
|
||||
"socket.io": "^4.4.1",
|
||||
"xml2js": "^0.4.23"
|
||||
}
|
||||
|
||||
3
prod.js
3
prod.js
@@ -26,8 +26,9 @@ const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve
|
||||
const UID = 99
|
||||
const GID = 100
|
||||
const SOURCE = options.source || 'debian'
|
||||
const ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH || ''
|
||||
|
||||
console.log(process.env.NODE_ENV, 'Config', CONFIG_PATH, METADATA_PATH)
|
||||
|
||||
const Server = new server(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH)
|
||||
const Server = new server(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH)
|
||||
Server.start()
|
||||
|
||||
12
server/Db.js
12
server/Db.js
@@ -9,8 +9,8 @@ const Library = require('./objects/Library')
|
||||
const Author = require('./objects/entities/Author')
|
||||
const Series = require('./objects/entities/Series')
|
||||
const ServerSettings = require('./objects/settings/ServerSettings')
|
||||
const NotificationSettings = require('./objects/settings/NotificationSettings')
|
||||
const PlaybackSession = require('./objects/PlaybackSession')
|
||||
const Feed = require('./objects/Feed')
|
||||
|
||||
class Db {
|
||||
constructor() {
|
||||
@@ -43,6 +43,7 @@ class Db {
|
||||
this.series = []
|
||||
|
||||
this.serverSettings = null
|
||||
this.notificationSettings = null
|
||||
|
||||
// Stores previous version only if upgraded
|
||||
this.previousVersion = null
|
||||
@@ -125,6 +126,10 @@ class Db {
|
||||
this.serverSettings = new ServerSettings()
|
||||
await this.insertEntity('settings', this.serverSettings)
|
||||
}
|
||||
if (!this.notificationSettings) {
|
||||
this.notificationSettings = new NotificationSettings()
|
||||
await this.insertEntity('settings', this.notificationSettings)
|
||||
}
|
||||
global.ServerSettings = this.serverSettings.toJSON()
|
||||
}
|
||||
|
||||
@@ -166,6 +171,11 @@ class Db {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var notificationSettings = this.settings.find(s => s.id === 'notification-settings')
|
||||
if (notificationSettings) {
|
||||
this.notificationSettings = new NotificationSettings(notificationSettings)
|
||||
}
|
||||
}
|
||||
})
|
||||
var p5 = this.collectionsDb.select(() => true).then((results) => {
|
||||
|
||||
103
server/Server.js
103
server/Server.js
@@ -23,6 +23,7 @@ const ApiRouter = require('./routers/ApiRouter')
|
||||
const HlsRouter = require('./routers/HlsRouter')
|
||||
const StaticRouter = require('./routers/StaticRouter')
|
||||
|
||||
const NotificationManager = require('./managers/NotificationManager')
|
||||
const CoverManager = require('./managers/CoverManager')
|
||||
const AbMergeManager = require('./managers/AbMergeManager')
|
||||
const CacheManager = require('./managers/CacheManager')
|
||||
@@ -33,9 +34,10 @@ const PodcastManager = require('./managers/PodcastManager')
|
||||
const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
|
||||
const RssFeedManager = require('./managers/RssFeedManager')
|
||||
const CronManager = require('./managers/CronManager')
|
||||
const TaskManager = require('./managers/TaskManager')
|
||||
|
||||
class Server {
|
||||
constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH) {
|
||||
constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) {
|
||||
this.Port = PORT
|
||||
this.Host = HOST
|
||||
global.Source = SOURCE
|
||||
@@ -43,6 +45,7 @@ class Server {
|
||||
global.Gid = isNaN(GID) ? 0 : Number(GID)
|
||||
global.ConfigPath = Path.normalize(CONFIG_PATH)
|
||||
global.MetadataPath = Path.normalize(METADATA_PATH)
|
||||
global.RouterBasePath = ROUTER_BASE_PATH
|
||||
|
||||
// Fix backslash if not on Windows
|
||||
if (process.platform !== 'win32') {
|
||||
@@ -64,21 +67,23 @@ class Server {
|
||||
this.auth = new Auth(this.db)
|
||||
|
||||
// Managers
|
||||
this.taskManager = new TaskManager(this.emitter.bind(this))
|
||||
this.notificationManager = new NotificationManager(this.db, this.emitter.bind(this))
|
||||
this.backupManager = new BackupManager(this.db, this.emitter.bind(this))
|
||||
this.logManager = new LogManager(this.db)
|
||||
this.cacheManager = new CacheManager()
|
||||
this.abMergeManager = new AbMergeManager(this.db, this.clientEmitter.bind(this))
|
||||
this.abMergeManager = new AbMergeManager(this.db, this.taskManager, this.clientEmitter.bind(this))
|
||||
this.playbackSessionManager = new PlaybackSessionManager(this.db, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.coverManager = new CoverManager(this.db, this.cacheManager)
|
||||
this.podcastManager = new PodcastManager(this.db, this.watcher, this.emitter.bind(this))
|
||||
this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.podcastManager = new PodcastManager(this.db, this.watcher, this.emitter.bind(this), this.notificationManager)
|
||||
this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.taskManager, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.rssFeedManager = new RssFeedManager(this.db, this.emitter.bind(this))
|
||||
|
||||
this.scanner = new Scanner(this.db, this.coverManager, this.emitter.bind(this))
|
||||
this.cronManager = new CronManager(this.db, this.scanner, this.podcastManager)
|
||||
|
||||
// Routers
|
||||
this.apiRouter = new ApiRouter(this.db, this.auth, this.scanner, this.playbackSessionManager, this.abMergeManager, this.coverManager, this.backupManager, this.watcher, this.cacheManager, this.podcastManager, this.audioMetadataManager, this.rssFeedManager, this.cronManager, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.apiRouter = new ApiRouter(this.db, this.auth, this.scanner, this.playbackSessionManager, this.abMergeManager, this.coverManager, this.backupManager, this.watcher, this.cacheManager, this.podcastManager, this.audioMetadataManager, this.rssFeedManager, this.cronManager, this.notificationManager, this.taskManager, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.hlsRouter = new HlsRouter(this.db, this.auth, this.playbackSessionManager, this.emitter.bind(this))
|
||||
this.staticRouter = new StaticRouter(this.db)
|
||||
|
||||
@@ -124,7 +129,6 @@ class Server {
|
||||
|
||||
async init() {
|
||||
Logger.info('[Server] Init v' + version)
|
||||
await this.abMergeManager.removeOrphanDownloads()
|
||||
await this.playbackSessionManager.removeOrphanStreams()
|
||||
|
||||
var previousVersion = await this.db.checkPreviousVersion() // Returns null if same server version
|
||||
@@ -143,7 +147,7 @@ class Server {
|
||||
await this.auth.initTokenSecret()
|
||||
}
|
||||
|
||||
await this.checkUserMediaProgress() // Remove invalid user item progress
|
||||
await this.cleanUserData() // Remove invalid user item progress
|
||||
await this.purgeMetadata() // Remove metadata folders without library item
|
||||
await this.playbackSessionManager.removeInvalidSessions()
|
||||
await this.cacheManager.ensureCachePaths()
|
||||
@@ -168,29 +172,32 @@ class Server {
|
||||
await this.init()
|
||||
|
||||
const app = express()
|
||||
const router = express.Router()
|
||||
app.use(global.RouterBasePath, router)
|
||||
|
||||
this.server = http.createServer(app)
|
||||
|
||||
app.use(this.auth.cors)
|
||||
app.use(fileUpload())
|
||||
app.use(express.urlencoded({ extended: true, limit: "5mb" }));
|
||||
app.use(express.json({ limit: "5mb" }))
|
||||
router.use(this.auth.cors)
|
||||
router.use(fileUpload())
|
||||
router.use(express.urlencoded({ extended: true, limit: "5mb" }));
|
||||
router.use(express.json({ limit: "5mb" }))
|
||||
|
||||
// Static path to generated nuxt
|
||||
const distPath = Path.join(global.appRoot, '/client/dist')
|
||||
app.use(express.static(distPath))
|
||||
router.use(express.static(distPath))
|
||||
|
||||
// Metadata folder static path
|
||||
app.use('/metadata', this.authMiddleware.bind(this), express.static(global.MetadataPath))
|
||||
router.use('/metadata', this.authMiddleware.bind(this), express.static(global.MetadataPath))
|
||||
|
||||
// Static folder
|
||||
app.use(express.static(Path.join(global.appRoot, 'static')))
|
||||
router.use(express.static(Path.join(global.appRoot, 'static')))
|
||||
|
||||
app.use('/api', this.authMiddleware.bind(this), this.apiRouter.router)
|
||||
app.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router)
|
||||
app.use('/s', this.authMiddleware.bind(this), this.staticRouter.router)
|
||||
router.use('/api', this.authMiddleware.bind(this), this.apiRouter.router)
|
||||
router.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router)
|
||||
router.use('/s', this.authMiddleware.bind(this), this.staticRouter.router)
|
||||
|
||||
// EBook static file routes
|
||||
app.get('/ebook/:library/:folder/*', (req, res) => {
|
||||
router.get('/ebook/:library/:folder/*', (req, res) => {
|
||||
var library = this.db.libraries.find(lib => lib.id === req.params.library)
|
||||
if (!library) return res.sendStatus(404)
|
||||
var folder = library.folders.find(fol => fol.id === req.params.folder)
|
||||
@@ -202,14 +209,14 @@ class Server {
|
||||
})
|
||||
|
||||
// RSS Feed temp route
|
||||
app.get('/feed/:id', (req, res) => {
|
||||
router.get('/feed/:id', (req, res) => {
|
||||
Logger.info(`[Server] Requesting rss feed ${req.params.id}`)
|
||||
this.rssFeedManager.getFeed(req, res)
|
||||
})
|
||||
app.get('/feed/:id/cover', (req, res) => {
|
||||
router.get('/feed/:id/cover', (req, res) => {
|
||||
this.rssFeedManager.getFeedCover(req, res)
|
||||
})
|
||||
app.get('/feed/:id/item/:episodeId/*', (req, res) => {
|
||||
router.get('/feed/:id/item/:episodeId/*', (req, res) => {
|
||||
Logger.debug(`[Server] Requesting rss feed episode ${req.params.id}/${req.params.episodeId}`)
|
||||
this.rssFeedManager.getFeedItem(req, res)
|
||||
})
|
||||
@@ -217,31 +224,33 @@ class Server {
|
||||
// Client dynamic routes
|
||||
const dyanimicRoutes = [
|
||||
'/item/:id',
|
||||
'/item/:id/manage',
|
||||
'/author/:id',
|
||||
'/audiobook/:id/chapters',
|
||||
'/audiobook/:id/edit',
|
||||
'/audiobook/:id/manage',
|
||||
'/library/:library',
|
||||
'/library/:library/search',
|
||||
'/library/:library/bookshelf/:id?',
|
||||
'/library/:library/authors',
|
||||
'/library/:library/series/:id?',
|
||||
'/library/:library/podcast/search',
|
||||
'/library/:library/podcast/latest',
|
||||
'/config/users/:id',
|
||||
'/config/users/:id/sessions',
|
||||
'/collection/:id'
|
||||
]
|
||||
dyanimicRoutes.forEach((route) => app.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
|
||||
dyanimicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
|
||||
|
||||
app.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res, this.rssFeedManager.feedsArray))
|
||||
app.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
|
||||
app.post('/init', (req, res) => {
|
||||
router.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res, this.rssFeedManager.feedsArray))
|
||||
router.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
|
||||
router.post('/init', (req, res) => {
|
||||
if (this.db.hasRootUser) {
|
||||
Logger.error(`[Server] attempt to init server when server already has a root user`)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
this.initializeServer(req, res)
|
||||
})
|
||||
app.get('/status', (req, res) => {
|
||||
router.get('/status', (req, res) => {
|
||||
// status check for client to see if server has been initialized
|
||||
// server has been initialized if a root user exists
|
||||
const payload = {
|
||||
@@ -253,7 +262,7 @@ class Server {
|
||||
}
|
||||
res.json(payload)
|
||||
})
|
||||
app.get('/ping', (req, res) => {
|
||||
router.get('/ping', (req, res) => {
|
||||
Logger.info('Received ping')
|
||||
res.json({ success: true })
|
||||
})
|
||||
@@ -364,21 +373,37 @@ class Server {
|
||||
return purged
|
||||
}
|
||||
|
||||
// Remove user media progress entries that dont have a library item
|
||||
// TODO: Check podcast episode exists still
|
||||
async checkUserMediaProgress() {
|
||||
// Remove user media progress with items that no longer exist & remove seriesHideFrom that no longer exist
|
||||
async cleanUserData() {
|
||||
for (let i = 0; i < this.db.users.length; i++) {
|
||||
var _user = this.db.users[i]
|
||||
if (_user.mediaProgress) {
|
||||
var itemProgressIdsToRemove = _user.mediaProgress.map(lip => lip.id).filter(lipId => !this.db.libraryItems.find(_li => _li.id == lipId))
|
||||
if (itemProgressIdsToRemove.length) {
|
||||
Logger.debug(`[Server] Found ${itemProgressIdsToRemove.length} media progress data to remove from user ${_user.username}`)
|
||||
for (const lipId of itemProgressIdsToRemove) {
|
||||
_user.removeMediaProgress(lipId)
|
||||
}
|
||||
await this.db.updateEntity('user', _user)
|
||||
var hasUpdated = false
|
||||
if (_user.mediaProgress.length) {
|
||||
const lengthBefore = _user.mediaProgress.length
|
||||
_user.mediaProgress = _user.mediaProgress.filter(mp => {
|
||||
const libraryItem = this.db.libraryItems.find(li => li.id === mp.libraryItemId)
|
||||
if (!libraryItem) return false
|
||||
if (mp.episodeId && (libraryItem.mediaType !== 'podcast' || !libraryItem.media.checkHasEpisode(mp.episodeId))) return false // Episode not found
|
||||
return true
|
||||
})
|
||||
|
||||
if (lengthBefore > _user.mediaProgress.length) {
|
||||
Logger.debug(`[Server] Removing ${_user.mediaProgress.length - lengthBefore} media progress data from user ${_user.username}`)
|
||||
hasUpdated = true
|
||||
}
|
||||
}
|
||||
if (_user.seriesHideFromContinueListening.length) {
|
||||
_user.seriesHideFromContinueListening = _user.seriesHideFromContinueListening.filter(seriesId => {
|
||||
if (!this.db.series.some(se => se.id === seriesId)) { // Series removed
|
||||
hasUpdated = true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
if (hasUpdated) {
|
||||
await this.db.updateEntity('user', _user)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -485,7 +485,7 @@ class LibraryController {
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// GET: api/scan (Root)
|
||||
// GET: api/libraries/:id/scan
|
||||
async scan(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryController] Non-root user attempted to scan library`, req.user)
|
||||
@@ -499,6 +499,45 @@ class LibraryController {
|
||||
Logger.info('[LibraryController] Scan complete')
|
||||
}
|
||||
|
||||
// GET: api/libraries/:id/recent-episode
|
||||
async getRecentEpisodes(req, res) {
|
||||
if (!req.library.isPodcast) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const payload = {
|
||||
episodes: [],
|
||||
total: 0,
|
||||
limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0,
|
||||
page: req.query.page && !isNaN(req.query.page) ? Number(req.query.page) : 0,
|
||||
}
|
||||
|
||||
var allUnfinishedEpisodes = []
|
||||
for (const libraryItem of req.libraryItems) {
|
||||
const unfinishedEpisodes = libraryItem.media.episodes.filter(ep => {
|
||||
const userProgress = req.user.getMediaProgress(libraryItem.id, ep.id)
|
||||
return !userProgress || !userProgress.isFinished
|
||||
}).map(_ep => {
|
||||
const ep = _ep.toJSONExpanded()
|
||||
ep.podcast = libraryItem.media.toJSONMinified()
|
||||
ep.libraryItemId = libraryItem.id
|
||||
return ep
|
||||
})
|
||||
allUnfinishedEpisodes.push(...unfinishedEpisodes)
|
||||
}
|
||||
|
||||
payload.total = allUnfinishedEpisodes.length
|
||||
|
||||
allUnfinishedEpisodes = sort(allUnfinishedEpisodes).desc(ep => ep.publishedAt)
|
||||
|
||||
if (payload.limit) {
|
||||
var startIndex = payload.page * payload.limit
|
||||
allUnfinishedEpisodes = allUnfinishedEpisodes.slice(startIndex, startIndex + payload.limit)
|
||||
}
|
||||
payload.episodes = allUnfinishedEpisodes
|
||||
res.json(payload)
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
if (!req.user.checkCanAccessLibrary(req.params.id)) {
|
||||
Logger.warn(`[LibraryController] Library ${req.params.id} not accessible to user ${req.user.username}`)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const Logger = require('../Logger')
|
||||
const { reqSupportsWebp } = require('../utils/index')
|
||||
const { reqSupportsWebp, isNullOrNaN } = require('../utils/index')
|
||||
const { ScanResult } = require('../utils/constants')
|
||||
|
||||
class LibraryItemController {
|
||||
@@ -305,6 +305,42 @@ class LibraryItemController {
|
||||
res.json(libraryItems)
|
||||
}
|
||||
|
||||
// POST: api/items/batch/quickmatch
|
||||
async batchQuickMatch(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.warn('User other than admin attempted to batch quick match library items', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
var itemsUpdated = 0
|
||||
var itemsUnmatched = 0
|
||||
|
||||
var matchData = req.body
|
||||
var options = matchData.options || {}
|
||||
var items = matchData.libraryItemIds
|
||||
if (!items || !items.length) {
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
res.sendStatus(200)
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
var libraryItem = this.db.libraryItems.find(_li => _li.id === items[i])
|
||||
var matchResult = await this.scanner.quickMatchLibraryItem(libraryItem, options)
|
||||
if (matchResult.updated) {
|
||||
itemsUpdated++
|
||||
} else if (matchResult.warning) {
|
||||
itemsUnmatched++
|
||||
}
|
||||
}
|
||||
|
||||
var result = {
|
||||
success: itemsUpdated > 0,
|
||||
updates: itemsUpdated,
|
||||
unmatched: itemsUnmatched
|
||||
}
|
||||
this.clientEmitter(req.user.id, 'batch_quickmatch_complete', result)
|
||||
}
|
||||
|
||||
// DELETE: api/items/all
|
||||
async deleteAll(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
@@ -335,6 +371,20 @@ class LibraryItemController {
|
||||
})
|
||||
}
|
||||
|
||||
getToneMetadataObject(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryItemController] Non-root user attempted to get tone metadata object`, req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
|
||||
Logger.error(`[LibraryItemController] Invalid library item`)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
res.json(this.audioMetadataManager.getToneMetadataObjectForApi(req.libraryItem))
|
||||
}
|
||||
|
||||
// GET: api/items/:id/audio-metadata
|
||||
async updateAudioFileMetadata(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
@@ -347,7 +397,8 @@ class LibraryItemController {
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
this.audioMetadataManager.updateAudioFileMetadataForItem(req.user, req.libraryItem)
|
||||
const useTone = req.query.tone === '1'
|
||||
this.audioMetadataManager.updateMetadataForItem(req.user, req.libraryItem, useTone)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
@@ -413,6 +464,22 @@ class LibraryItemController {
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async toneScan(req, res) {
|
||||
if (!req.libraryItem.media.audioFiles.length) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const audioFileIndex = isNullOrNaN(req.params.index) ? 1 : Number(req.params.index)
|
||||
const audioFile = req.libraryItem.media.audioFiles.find(af => af.index === audioFileIndex)
|
||||
if (!audioFile) {
|
||||
Logger.error(`[LibraryItemController] toneScan: Audio file not found with index ${audioFileIndex}`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const toneData = await this.scanner.probeAudioFileWithTone(audioFile)
|
||||
res.json(toneData)
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
var item = this.db.libraryItems.find(li => li.id === req.params.id)
|
||||
if (!item || !item.media) return res.sendStatus(404)
|
||||
|
||||
@@ -276,5 +276,31 @@ class MeController {
|
||||
libraryItems: itemsInProgress
|
||||
})
|
||||
}
|
||||
|
||||
// GET: api/me/series/:id/remove-from-continue-listening
|
||||
async removeSeriesFromContinueListening(req, res) {
|
||||
const series = this.db.series.find(se => se.id === req.params.id)
|
||||
if (!series) {
|
||||
Logger.error(`[MeController] removeSeriesFromContinueListening: Series ${req.params.id} not found`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const hasUpdated = req.user.addSeriesToHideFromContinueListening(req.params.id)
|
||||
if (hasUpdated) {
|
||||
await this.db.updateEntity('user', req.user)
|
||||
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||
}
|
||||
res.json(req.user.toJSONForBrowser())
|
||||
}
|
||||
|
||||
// GET: api/me/progress/:id/remove-from-continue-listening
|
||||
async removeItemFromContinueListening(req, res) {
|
||||
const hasUpdated = req.user.removeProgressFromContinueListening(req.params.id)
|
||||
if (hasUpdated) {
|
||||
await this.db.updateEntity('user', req.user)
|
||||
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||
}
|
||||
res.json(req.user.toJSONForBrowser())
|
||||
}
|
||||
}
|
||||
module.exports = new MeController()
|
||||
@@ -82,26 +82,26 @@ class MiscController {
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// GET: api/audiobook-merge/:id
|
||||
async mergeAudiobook(req, res) {
|
||||
if (!req.user.canDownload) {
|
||||
Logger.error('User attempting to download without permission', req.user)
|
||||
// GET: api/encode-m4b/:id
|
||||
async encodeM4b(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error('[MiscController] encodeM4b: Non-admin user attempting to make m4b', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
var libraryItem = this.db.getLibraryItem(req.params.id)
|
||||
if (!libraryItem || libraryItem.isMissing || libraryItem.isInvalid) {
|
||||
Logger.error(`[MiscController] mergeAudiboook: library item not found or invalid ${req.params.id}`)
|
||||
Logger.error(`[MiscController] encodeM4b: library item not found or invalid ${req.params.id}`)
|
||||
return res.status(404).send('Audiobook not found')
|
||||
}
|
||||
|
||||
if (libraryItem.mediaType !== 'book') {
|
||||
Logger.error(`[MiscController] mergeAudiboook: Invalid library item ${req.params.id}: not a book`)
|
||||
Logger.error(`[MiscController] encodeM4b: Invalid library item ${req.params.id}: not a book`)
|
||||
return res.status(500).send('Invalid library item: not a book')
|
||||
}
|
||||
|
||||
if (libraryItem.media.tracks.length <= 0) {
|
||||
Logger.error(`[MiscController] mergeAudiboook: Invalid audiobook ${req.params.id}: no audio tracks`)
|
||||
Logger.error(`[MiscController] encodeM4b: Invalid audiobook ${req.params.id}: no audio tracks`)
|
||||
return res.status(500).send('Invalid audiobook: no audio tracks')
|
||||
}
|
||||
|
||||
@@ -110,53 +110,26 @@ class MiscController {
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// GET: api/download/:id
|
||||
async getDownload(req, res) {
|
||||
if (!req.user.canDownload) {
|
||||
Logger.error('User attempting to download without permission', req.user)
|
||||
// POST: api/encode-m4b/:id/cancel
|
||||
async cancelM4bEncode(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error('[MiscController] cancelM4bEncode: Non-admin user attempting to cancel m4b encode', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var downloadId = req.params.id
|
||||
Logger.info('Download Request', downloadId)
|
||||
var download = this.abMergeManager.getDownload(downloadId)
|
||||
if (!download) {
|
||||
Logger.error('Download request not found', downloadId)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
var options = {
|
||||
headers: {
|
||||
'Content-Type': download.mimeType
|
||||
}
|
||||
}
|
||||
res.download(download.path, download.filename, options, (err) => {
|
||||
if (err) {
|
||||
Logger.error('Download Error', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
const workerTask = this.abMergeManager.getPendingTaskByLibraryItemId(req.params.id)
|
||||
if (!workerTask) return res.sendStatus(404)
|
||||
|
||||
this.abMergeManager.cancelEncode(workerTask.task)
|
||||
|
||||
// DELETE: api/download/:id
|
||||
async removeDownload(req, res) {
|
||||
if (!req.user.canDownload || !req.user.canDelete) {
|
||||
Logger.error('User attempting to remove download without permission', req.user.username)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
this.abMergeManager.removeDownloadById(req.params.id)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// GET: api/downloads
|
||||
async getDownloads(req, res) {
|
||||
if (!req.user.canDownload) {
|
||||
Logger.error('User attempting to get downloads without permission', req.user.username)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var downloads = {
|
||||
downloads: this.abMergeManager.downloads,
|
||||
pendingDownloads: this.abMergeManager.pendingDownloads
|
||||
}
|
||||
res.json(downloads)
|
||||
// GET: api/tasks
|
||||
getTasks(req, res) {
|
||||
res.json({
|
||||
tasks: this.taskManager.tasks.map(t => t.toJSON())
|
||||
})
|
||||
}
|
||||
|
||||
// PATCH: api/settings (admin)
|
||||
@@ -185,16 +158,26 @@ class MiscController {
|
||||
})
|
||||
}
|
||||
|
||||
// POST: api/purgecache (admin)
|
||||
// POST: api/cache/purge (admin)
|
||||
async purgeCache(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
Logger.info(`[ApiRouter] Purging all cache`)
|
||||
Logger.info(`[MiscController] Purging all cache`)
|
||||
await this.cacheManager.purgeAll()
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// POST: api/cache/items/purge
|
||||
async purgeItemsCache(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
Logger.info(`[MiscController] Purging items cache`)
|
||||
await this.cacheManager.purgeItems()
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async findBooks(req, res) {
|
||||
var provider = req.query.provider || 'google'
|
||||
var title = req.query.title || ''
|
||||
|
||||
79
server/controllers/NotificationController.js
Normal file
79
server/controllers/NotificationController.js
Normal file
@@ -0,0 +1,79 @@
|
||||
const Logger = require('../Logger')
|
||||
const { version } = require('../../package.json')
|
||||
|
||||
class NotificationController {
|
||||
constructor() { }
|
||||
|
||||
get(req, res) {
|
||||
res.json({
|
||||
data: this.notificationManager.getData(),
|
||||
settings: this.db.notificationSettings
|
||||
})
|
||||
}
|
||||
|
||||
async update(req, res) {
|
||||
const updated = this.db.notificationSettings.update(req.body)
|
||||
if (updated) {
|
||||
await this.db.updateEntity('settings', this.db.notificationSettings)
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
getData(req, res) {
|
||||
res.json(this.notificationManager.getData())
|
||||
}
|
||||
|
||||
async fireTestEvent(req, res) {
|
||||
await this.notificationManager.triggerNotification('onTest', { version: `v${version}` }, req.query.fail === '1')
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async createNotification(req, res) {
|
||||
const success = this.db.notificationSettings.createNotification(req.body)
|
||||
|
||||
if (success) {
|
||||
await this.db.updateEntity('settings', this.db.notificationSettings)
|
||||
}
|
||||
res.json(this.db.notificationSettings)
|
||||
}
|
||||
|
||||
async deleteNotification(req, res) {
|
||||
if (this.db.notificationSettings.removeNotification(req.notification.id)) {
|
||||
await this.db.updateEntity('settings', this.db.notificationSettings)
|
||||
}
|
||||
res.json(this.db.notificationSettings)
|
||||
}
|
||||
|
||||
async updateNotification(req, res) {
|
||||
const success = this.db.notificationSettings.updateNotification(req.body)
|
||||
if (success) {
|
||||
await this.db.updateEntity('settings', this.db.notificationSettings)
|
||||
}
|
||||
res.json(this.db.notificationSettings)
|
||||
}
|
||||
|
||||
async sendNotificationTest(req, res) {
|
||||
if (!this.db.notificationSettings.isUseable) return res.status(500).send('Apprise is not configured')
|
||||
|
||||
const success = await this.notificationManager.sendTestNotification(req.notification)
|
||||
if (success) res.sendStatus(200)
|
||||
else res.sendStatus(500)
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
if (req.params.id) {
|
||||
const notification = this.db.notificationSettings.getNotification(req.params.id)
|
||||
if (!notification) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
req.notification = notification
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
module.exports = new NotificationController()
|
||||
@@ -1,10 +1,9 @@
|
||||
const axios = require('axios')
|
||||
const fs = require('../libs/fsExtra')
|
||||
const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
const { parsePodcastRssFeedXml } = require('../utils/podcastUtils')
|
||||
const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils')
|
||||
const LibraryItem = require('../objects/LibraryItem')
|
||||
const { getFileTimestampsWithIno, sanitizeFilename } = require('../utils/fileUtils')
|
||||
const { getFileTimestampsWithIno } = require('../utils/fileUtils')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
|
||||
class PodcastController {
|
||||
@@ -91,32 +90,17 @@ class PodcastController {
|
||||
}
|
||||
}
|
||||
|
||||
getPodcastFeed(req, res) {
|
||||
async getPodcastFeed(req, res) {
|
||||
var url = req.body.rssFeed
|
||||
if (!url) {
|
||||
return res.status(400).send('Bad request')
|
||||
}
|
||||
var includeRaw = req.query.raw == 1 // Include raw json
|
||||
|
||||
axios.get(url).then(async (data) => {
|
||||
if (!data || !data.data) {
|
||||
Logger.error('Invalid podcast feed request response')
|
||||
return res.status(500).send('Bad response from feed request')
|
||||
}
|
||||
Logger.debug(`[PodcastController] Podcast feed size ${(data.data.length / 1024 / 1024).toFixed(2)}MB`)
|
||||
var payload = await parsePodcastRssFeedXml(data.data, false, includeRaw)
|
||||
if (!payload) {
|
||||
return res.status(500).send('Invalid podcast RSS feed')
|
||||
}
|
||||
|
||||
// RSS feed may be a private RSS feed
|
||||
payload.podcast.metadata.feedUrl = url
|
||||
|
||||
res.json(payload)
|
||||
}).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
res.status(500).send(error)
|
||||
})
|
||||
const podcast = await getPodcastFeed(url)
|
||||
if (!podcast) {
|
||||
return res.status(404).send('Podcast RSS feed request failed or invalid response data')
|
||||
}
|
||||
res.json({ podcast })
|
||||
}
|
||||
|
||||
async getOPMLFeeds(req, res) {
|
||||
@@ -177,9 +161,7 @@ class PodcastController {
|
||||
if (!searchTitle) {
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
searchTitle = searchTitle.toLowerCase().trim()
|
||||
|
||||
const episodes = await this.podcastManager.findEpisode(rssFeedUrl, searchTitle)
|
||||
const episodes = await findMatchingEpisodes(rssFeedUrl, searchTitle)
|
||||
res.json({
|
||||
episodes: episodes || []
|
||||
})
|
||||
|
||||
@@ -34,6 +34,15 @@ class SeriesController {
|
||||
res.json(series)
|
||||
}
|
||||
|
||||
async update(req, res) {
|
||||
const hasUpdated = req.series.update(req.body)
|
||||
if (hasUpdated) {
|
||||
await this.db.updateEntity('series', req.series)
|
||||
this.emitter('series_updated', req.series)
|
||||
}
|
||||
res.json(req.series)
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
var series = this.db.series.find(se => se.id === req.params.id)
|
||||
if (!series) return res.sendStatus(404)
|
||||
|
||||
@@ -28,10 +28,6 @@ class UserController {
|
||||
}
|
||||
|
||||
async create(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.warn('Non-admin user attempted to create user', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var account = req.body
|
||||
|
||||
var username = account.username
|
||||
@@ -58,15 +54,7 @@ class UserController {
|
||||
}
|
||||
|
||||
async update(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error('[UserController] User other than admin attempting to update user', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
var user = this.db.users.find(u => u.id === req.params.id)
|
||||
if (!user) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
var user = req.reqUser
|
||||
|
||||
if (user.type === 'root' && !req.user.isRoot) {
|
||||
Logger.error(`[UserController] Admin user attempted to update root user`, req.user.username)
|
||||
@@ -97,9 +85,9 @@ class UserController {
|
||||
Logger.info(`[UserController] User ${user.username} was generated a new api token`)
|
||||
}
|
||||
await this.db.updateEntity('user', user)
|
||||
this.clientEmitter(req.user.id, 'user_updated', user.toJSONForBrowser())
|
||||
}
|
||||
|
||||
this.clientEmitter(req.user.id, 'user_updated', user.toJSONForBrowser())
|
||||
res.json({
|
||||
success: true,
|
||||
user: user.toJSONForBrowser()
|
||||
@@ -107,24 +95,15 @@ class UserController {
|
||||
}
|
||||
|
||||
async delete(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error('User other than admin attempting to delete user', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
if (req.params.id === 'root') {
|
||||
Logger.error('[UserController] Attempt to delete root user. Root user cannot be deleted')
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
if (req.user.id === req.params.id) {
|
||||
Logger.error('Attempting to delete themselves...')
|
||||
Logger.error(`[UserController] ${req.user.username} is attempting to delete themselves... why? WHY?`)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
var user = this.db.users.find(u => u.id === req.params.id)
|
||||
if (!user) {
|
||||
Logger.error('User not found')
|
||||
return res.json({
|
||||
error: 'User not found'
|
||||
})
|
||||
}
|
||||
var user = req.reqUser
|
||||
|
||||
// delete user collections
|
||||
var userCollections = this.db.collections.filter(c => c.userId === user.id)
|
||||
@@ -145,10 +124,6 @@ class UserController {
|
||||
|
||||
// GET: api/users/:id/listening-sessions
|
||||
async getListeningSessions(req, res) {
|
||||
if (!req.user.isAdminOrUp && req.user.id !== req.params.id) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
var listeningSessions = await this.getUserListeningSessionsHelper(req.params.id)
|
||||
|
||||
const itemsPerPage = toNumber(req.query.itemsPerPage, 10) || 10
|
||||
@@ -170,11 +145,59 @@ class UserController {
|
||||
|
||||
// GET: api/users/:id/listening-stats
|
||||
async getListeningStats(req, res) {
|
||||
if (!req.user.isAdminOrUp && req.user.id !== req.params.id) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var listeningStats = await this.getUserListeningStatsHelpers(req.params.id)
|
||||
res.json(listeningStats)
|
||||
}
|
||||
|
||||
// POST: api/users/:id/purge-media-progress
|
||||
async purgeMediaProgress(req, res) {
|
||||
const user = req.reqUser
|
||||
|
||||
if (user.type === 'root' && !req.user.isRoot) {
|
||||
Logger.error(`[UserController] Admin user attempted to purge media progress of root user`, req.user.username)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
var progressPurged = 0
|
||||
user.mediaProgress = user.mediaProgress.filter(mp => {
|
||||
const libraryItem = this.db.libraryItems.find(li => li.id === mp.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
progressPurged++
|
||||
return false
|
||||
} else if (mp.episodeId) {
|
||||
const episode = libraryItem.mediaType === 'podcast' ? libraryItem.media.getEpisode(mp.episodeId) : null
|
||||
if (!episode) { // Episode not found
|
||||
progressPurged++
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (progressPurged) {
|
||||
Logger.info(`[UserController] Purged ${progressPurged} media progress for user ${user.username}`)
|
||||
await this.db.updateEntity('user', user)
|
||||
this.clientEmitter(req.user.id, 'user_updated', user.toJSONForBrowser())
|
||||
}
|
||||
|
||||
res.json(this.userJsonWithItemProgressDetails(user, !req.user.isRoot))
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
if (!req.user.isAdminOrUp && req.user.id !== req.params.id) {
|
||||
return res.sendStatus(403)
|
||||
} else if ((req.method == 'PATCH' || req.method == 'POST' || req.method == 'DELETE') && !req.user.isAdminOrUp) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
if (req.params.id) {
|
||||
req.reqUser = this.db.users.find(u => u.id === req.params.id)
|
||||
if (!req.reqUser) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
module.exports = new UserController()
|
||||
@@ -20,7 +20,13 @@ module.exports = (function () {
|
||||
|
||||
proc.on('exit', code => { exitCode = code })
|
||||
proc.on('error', err => reject(err))
|
||||
proc.on('close', () => resolve(JSON.parse(probeData.join(''))))
|
||||
proc.on('close', () => {
|
||||
try {
|
||||
resolve(JSON.parse(probeData.join('')))
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -4,22 +4,30 @@ const fs = require('../libs/fsExtra')
|
||||
|
||||
const workerThreads = require('worker_threads')
|
||||
const Logger = require('../Logger')
|
||||
const Download = require('../objects/Download')
|
||||
const Task = require('../objects/Task')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
const { getId } = require('../utils/index')
|
||||
const { writeConcatFile, writeMetadataFile } = require('../utils/ffmpegHelpers')
|
||||
const { getFileSize } = require('../utils/fileUtils')
|
||||
const { writeConcatFile } = require('../utils/ffmpegHelpers')
|
||||
const toneHelpers = require('../utils/toneHelpers')
|
||||
|
||||
class AbMergeManager {
|
||||
constructor(db, clientEmitter) {
|
||||
constructor(db, taskManager, clientEmitter) {
|
||||
this.db = db
|
||||
this.taskManager = taskManager
|
||||
this.clientEmitter = clientEmitter
|
||||
|
||||
this.itemsCacheDir = Path.join(global.MetadataPath, 'cache/items')
|
||||
this.downloadDirPath = Path.join(global.MetadataPath, 'downloads')
|
||||
this.downloadDirPathExist = false
|
||||
|
||||
this.pendingDownloads = []
|
||||
this.downloads = []
|
||||
this.pendingTasks = []
|
||||
}
|
||||
|
||||
getPendingTaskByLibraryItemId(libraryItemId) {
|
||||
return this.pendingTasks.find(t => t.task.data.libraryItemId === libraryItemId)
|
||||
}
|
||||
|
||||
cancelEncode(task) {
|
||||
return this.removeTask(task, true)
|
||||
}
|
||||
|
||||
async ensureDownloadDirPath() { // Creates download path if necessary and sets owner and permissions
|
||||
@@ -38,85 +46,47 @@ class AbMergeManager {
|
||||
this.downloadDirPathExist = true
|
||||
}
|
||||
|
||||
getDownload(downloadId) {
|
||||
return this.downloads.find(d => d.id === downloadId)
|
||||
}
|
||||
|
||||
removeDownloadById(downloadId) {
|
||||
var download = this.getDownload(downloadId)
|
||||
if (download) {
|
||||
this.removeDownload(download)
|
||||
}
|
||||
}
|
||||
|
||||
async removeOrphanDownloads() {
|
||||
try {
|
||||
var dirs = await fs.readdir(this.downloadDirPath)
|
||||
if (!dirs || !dirs.length) return true
|
||||
|
||||
dirs = dirs.filter(d => d.startsWith('abmerge'))
|
||||
|
||||
await Promise.all(dirs.map(async (dirname) => {
|
||||
var fullPath = Path.join(this.downloadDirPath, dirname)
|
||||
Logger.info(`Removing Orphan Download ${dirname}`)
|
||||
return fs.remove(fullPath)
|
||||
}))
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async startAudiobookMerge(user, libraryItem) {
|
||||
var downloadId = getId('abmerge')
|
||||
var dlpath = Path.join(this.downloadDirPath, downloadId)
|
||||
Logger.info(`Start audiobook merge for ${libraryItem.id} - DownloadId: ${downloadId} - ${dlpath}`)
|
||||
const task = new Task()
|
||||
|
||||
var audiobookDirname = Path.basename(libraryItem.path)
|
||||
var filename = audiobookDirname + '.m4b'
|
||||
var downloadData = {
|
||||
id: downloadId,
|
||||
const audiobookDirname = Path.basename(libraryItem.path)
|
||||
const targetFilename = audiobookDirname + '.m4b'
|
||||
const itemCachePath = Path.join(this.itemsCacheDir, libraryItem.id)
|
||||
const tempFilepath = Path.join(itemCachePath, targetFilename)
|
||||
const taskData = {
|
||||
libraryItemId: libraryItem.id,
|
||||
type: 'abmerge',
|
||||
dirpath: dlpath,
|
||||
path: Path.join(dlpath, filename),
|
||||
filename,
|
||||
ext: '.m4b',
|
||||
userId: user.id
|
||||
libraryItemPath: libraryItem.path,
|
||||
userId: user.id,
|
||||
originalTrackPaths: libraryItem.media.tracks.map(t => t.metadata.path),
|
||||
tempFilepath,
|
||||
targetFilename,
|
||||
targetFilepath: Path.join(libraryItem.path, targetFilename),
|
||||
itemCachePath,
|
||||
toneMetadataObject: null
|
||||
}
|
||||
var download = new Download()
|
||||
download.setData(downloadData)
|
||||
download.setTimeoutTimer(this.downloadTimedOut.bind(this))
|
||||
const taskDescription = `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.`
|
||||
task.setData('encode-m4b', 'Encoding M4b', taskDescription, taskData)
|
||||
this.taskManager.addTask(task)
|
||||
Logger.info(`Start m4b encode for ${libraryItem.id} - TaskId: ${task.id}`)
|
||||
|
||||
|
||||
try {
|
||||
await fs.mkdir(download.dirpath)
|
||||
} catch (error) {
|
||||
Logger.error(`[AbMergeManager] Failed to make directory ${download.dirpath}`)
|
||||
Logger.debug(`[AbMergeManager] Make directory error: ${error}`)
|
||||
var downloadJson = download.toJSON()
|
||||
this.clientEmitter(user.id, 'abmerge_failed', downloadJson)
|
||||
return
|
||||
if (!await fs.pathExists(taskData.itemCachePath)) {
|
||||
await fs.mkdir(taskData.itemCachePath)
|
||||
}
|
||||
|
||||
this.clientEmitter(user.id, 'abmerge_started', download.toJSON())
|
||||
this.runAudiobookMerge(libraryItem, download)
|
||||
this.runAudiobookMerge(libraryItem, task)
|
||||
}
|
||||
|
||||
async runAudiobookMerge(libraryItem, download) {
|
||||
|
||||
async runAudiobookMerge(libraryItem, task) {
|
||||
// If changing audio file type then encoding is needed
|
||||
var audioTracks = libraryItem.media.tracks
|
||||
var audioRequiresEncode = audioTracks[0].metadata.ext !== download.ext
|
||||
var shouldIncludeCover = libraryItem.media.coverPath
|
||||
var audioRequiresEncode = audioTracks[0].metadata.ext !== '.m4b'
|
||||
var firstTrackIsM4b = audioTracks[0].metadata.ext.toLowerCase() === '.m4b'
|
||||
var isOneTrack = audioTracks.length === 1
|
||||
|
||||
const ffmpegInputs = []
|
||||
|
||||
if (!isOneTrack) {
|
||||
var concatFilePath = Path.join(download.dirpath, 'files.txt')
|
||||
console.log('Write files.txt', concatFilePath)
|
||||
var concatFilePath = Path.join(task.data.itemCachePath, 'files.txt')
|
||||
await writeConcatFile(audioTracks, concatFilePath)
|
||||
ffmpegInputs.push({
|
||||
input: concatFilePath,
|
||||
@@ -131,53 +101,47 @@ class AbMergeManager {
|
||||
|
||||
const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'warning'
|
||||
var ffmpegOptions = [`-loglevel ${logLevel}`]
|
||||
var ffmpegOutputOptions = []
|
||||
var ffmpegOutputOptions = ['-f mp4']
|
||||
|
||||
if (audioRequiresEncode) {
|
||||
ffmpegOptions = ffmpegOptions.concat([
|
||||
'-map 0:a',
|
||||
'-acodec aac',
|
||||
'-ac 2',
|
||||
'-b:a 64k',
|
||||
'-movflags use_metadata_tags'
|
||||
'-b:a 64k'
|
||||
])
|
||||
} else {
|
||||
ffmpegOptions.push('-max_muxing_queue_size 1000')
|
||||
|
||||
if (isOneTrack && firstTrackIsM4b && !shouldIncludeCover) {
|
||||
if (isOneTrack && firstTrackIsM4b) {
|
||||
ffmpegOptions.push('-c copy')
|
||||
} else {
|
||||
ffmpegOptions.push('-c:a copy')
|
||||
}
|
||||
}
|
||||
if (download.ext === '.m4b') {
|
||||
ffmpegOutputOptions.push('-f mp4')
|
||||
|
||||
var chaptersFilePath = null
|
||||
if (libraryItem.media.chapters.length) {
|
||||
chaptersFilePath = Path.join(task.data.itemCachePath, 'chapters.txt')
|
||||
try {
|
||||
await toneHelpers.writeToneChaptersFile(libraryItem.media.chapters, chaptersFilePath)
|
||||
} catch (error) {
|
||||
Logger.error(`[AbMergeManager] Write chapters.txt failed`, error)
|
||||
chaptersFilePath = null
|
||||
}
|
||||
}
|
||||
|
||||
// Create ffmetadata file
|
||||
var metadataFilePath = Path.join(download.dirpath, 'metadata.txt')
|
||||
await writeMetadataFile(libraryItem, metadataFilePath)
|
||||
ffmpegInputs.push({
|
||||
input: metadataFilePath
|
||||
})
|
||||
ffmpegOptions.push('-map_metadata 1')
|
||||
const toneMetadataObject = toneHelpers.getToneMetadataObject(libraryItem, chaptersFilePath)
|
||||
toneMetadataObject.TrackNumber = 1
|
||||
task.data.toneMetadataObject = toneMetadataObject
|
||||
|
||||
// Embed cover art
|
||||
if (shouldIncludeCover) {
|
||||
var coverPath = libraryItem.media.coverPath.replace(/\\/g, '/')
|
||||
ffmpegInputs.push({
|
||||
input: coverPath,
|
||||
options: ['-f image2pipe']
|
||||
})
|
||||
ffmpegOptions.push('-c:v copy')
|
||||
ffmpegOptions.push('-map 2:v')
|
||||
}
|
||||
Logger.debug(`[AbMergeManager] Book "${libraryItem.media.metadata.title}" tone metadata object=`, toneMetadataObject)
|
||||
|
||||
var workerData = {
|
||||
inputs: ffmpegInputs,
|
||||
options: ffmpegOptions,
|
||||
outputOptions: ffmpegOutputOptions,
|
||||
output: download.path,
|
||||
output: task.data.tempFilepath
|
||||
}
|
||||
|
||||
var worker = null
|
||||
@@ -186,117 +150,105 @@ class AbMergeManager {
|
||||
worker = new workerThreads.Worker(workerPath, { workerData })
|
||||
} catch (error) {
|
||||
Logger.error(`[AbMergeManager] Start worker thread failed`, error)
|
||||
if (download.userId) {
|
||||
var downloadJson = download.toJSON()
|
||||
this.clientEmitter(download.userId, 'abmerge_failed', downloadJson)
|
||||
}
|
||||
this.removeDownload(download)
|
||||
task.setFailed('Failed to start worker thread')
|
||||
this.removeTask(task, true)
|
||||
return
|
||||
}
|
||||
|
||||
worker.on('message', (message) => {
|
||||
if (message != null && typeof message === 'object') {
|
||||
if (message.type === 'RESULT') {
|
||||
if (!download.isTimedOut) {
|
||||
this.sendResult(download, message)
|
||||
}
|
||||
this.sendResult(task, message)
|
||||
} else if (message.type === 'FFMPEG') {
|
||||
if (Logger[message.level]) {
|
||||
Logger[message.level](message.log)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Logger.error('Invalid worker message', message)
|
||||
}
|
||||
})
|
||||
this.pendingDownloads.push({
|
||||
id: download.id,
|
||||
download,
|
||||
this.pendingTasks.push({
|
||||
id: task.id,
|
||||
task,
|
||||
worker
|
||||
})
|
||||
}
|
||||
|
||||
async sendResult(download, result) {
|
||||
download.clearTimeoutTimer()
|
||||
|
||||
// Remove pending download
|
||||
this.pendingDownloads = this.pendingDownloads.filter(d => d.id !== download.id)
|
||||
async sendResult(task, result) {
|
||||
// Remove pending task
|
||||
this.pendingTasks = this.pendingTasks.filter(d => d.id !== task.id)
|
||||
|
||||
if (result.isKilled) {
|
||||
if (download.userId) {
|
||||
this.clientEmitter(download.userId, 'abmerge_killed', download.toJSON())
|
||||
}
|
||||
task.setFailed('Ffmpeg task killed')
|
||||
this.removeTask(task, true)
|
||||
return
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
if (download.userId) {
|
||||
this.clientEmitter(download.userId, 'abmerge_failed', download.toJSON())
|
||||
}
|
||||
this.removeDownload(download)
|
||||
task.setFailed('Encoding failed')
|
||||
this.removeTask(task, true)
|
||||
return
|
||||
}
|
||||
|
||||
// Write metadata to merged file
|
||||
const success = await toneHelpers.tagAudioFile(task.data.tempFilepath, task.data.toneMetadataObject)
|
||||
if (!success) {
|
||||
Logger.error(`[AbMergeManager] Failed to write metadata to file "${task.data.tempFilepath}"`)
|
||||
task.setFailed('Failed to write metadata to m4b file')
|
||||
this.removeTask(task, true)
|
||||
return
|
||||
}
|
||||
|
||||
// Move library item tracks to cache
|
||||
for (const trackPath of task.data.originalTrackPaths) {
|
||||
const trackFilename = Path.basename(trackPath)
|
||||
const moveToPath = Path.join(task.data.itemCachePath, trackFilename)
|
||||
Logger.debug(`[AbMergeManager] Backing up original track "${trackPath}" to ${moveToPath}`)
|
||||
await fs.move(trackPath, moveToPath, { overwrite: true }).catch((err) => {
|
||||
Logger.error(`[AbMergeManager] Failed to move track "${trackPath}" to "${moveToPath}"`, err)
|
||||
})
|
||||
}
|
||||
|
||||
// Move m4b to target
|
||||
Logger.debug(`[AbMergeManager] Moving m4b from ${task.data.tempFilepath} to ${task.data.targetFilepath}`)
|
||||
await fs.move(task.data.tempFilepath, task.data.targetFilepath)
|
||||
|
||||
// Set file permissions and ownership
|
||||
await filePerms.setDefault(download.path)
|
||||
await filePerms.setDefault(task.data.targetFilepath)
|
||||
await filePerms.setDefault(task.data.itemCachePath)
|
||||
|
||||
var filesize = await getFileSize(download.path)
|
||||
download.setComplete(filesize)
|
||||
if (download.userId) {
|
||||
this.clientEmitter(download.userId, 'abmerge_ready', download.toJSON())
|
||||
}
|
||||
download.setExpirationTimer(this.downloadExpired.bind(this))
|
||||
|
||||
this.downloads.push(download)
|
||||
Logger.info(`[AbMergeManager] Download Ready ${download.id}`)
|
||||
task.setFinished()
|
||||
await this.removeTask(task, false)
|
||||
Logger.info(`[AbMergeManager] Ab task finished ${task.id}`)
|
||||
}
|
||||
|
||||
async downloadExpired(download) {
|
||||
Logger.info(`[AbMergeManager] Download ${download.id} expired`)
|
||||
|
||||
if (download.userId) {
|
||||
this.clientEmitter(download.userId, 'abmerge_expired', download.toJSON())
|
||||
}
|
||||
this.removeDownload(download)
|
||||
}
|
||||
|
||||
async downloadTimedOut(download) {
|
||||
Logger.info(`[AbMergeManager] Download ${download.id} timed out (${download.timeoutTimeMs}ms)`)
|
||||
|
||||
if (download.userId) {
|
||||
var downloadJson = download.toJSON()
|
||||
downloadJson.isTimedOut = true
|
||||
this.clientEmitter(download.userId, 'abmerge_failed', downloadJson)
|
||||
}
|
||||
this.removeDownload(download)
|
||||
}
|
||||
|
||||
async removeDownload(download) {
|
||||
Logger.info('[AbMergeManager] Removing download ' + download.id)
|
||||
|
||||
download.clearTimeoutTimer()
|
||||
download.clearExpirationTimer()
|
||||
|
||||
var pendingDl = this.pendingDownloads.find(d => d.id === download.id)
|
||||
async removeTask(task, removeTempFilepath = false) {
|
||||
Logger.info('[AbMergeManager] Removing task ' + task.id)
|
||||
|
||||
const pendingDl = this.pendingTasks.find(d => d.id === task.id)
|
||||
if (pendingDl) {
|
||||
this.pendingDownloads = this.pendingDownloads.filter(d => d.id !== download.id)
|
||||
this.pendingTasks = this.pendingTasks.filter(d => d.id !== task.id)
|
||||
Logger.warn(`[AbMergeManager] Removing download in progress - stopping worker`)
|
||||
if (pendingDl.worker) {
|
||||
try {
|
||||
pendingDl.worker.postMessage('STOP')
|
||||
return
|
||||
} catch (error) {
|
||||
Logger.error('[AbMergeManager] Error posting stop message to worker', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await fs.remove(download.dirpath).then(() => {
|
||||
Logger.info('[AbMergeManager] Deleted download', download.dirpath)
|
||||
}).catch((err) => {
|
||||
Logger.error('[AbMergeManager] Failed to delete download', err)
|
||||
})
|
||||
this.downloads = this.downloads.filter(d => d.id !== download.id)
|
||||
if (removeTempFilepath) { // On failed tasks remove the bad file if it exists
|
||||
if (await fs.pathExists(task.data.tempFilepath)) {
|
||||
await fs.remove(task.data.tempFilepath).then(() => {
|
||||
Logger.info('[AbMergeManager] Deleted target file', task.data.tempFilepath)
|
||||
}).catch((err) => {
|
||||
Logger.error('[AbMergeManager] Failed to delete target file', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.taskManager.taskFinished(task)
|
||||
}
|
||||
}
|
||||
module.exports = AbMergeManager
|
||||
|
||||
@@ -5,15 +5,111 @@ const Logger = require('../Logger')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
const { secondsToTimestamp } = require('../utils/index')
|
||||
const { writeMetadataFile } = require('../utils/ffmpegHelpers')
|
||||
const toneHelpers = require('../utils/toneHelpers')
|
||||
|
||||
class AudioMetadataMangaer {
|
||||
constructor(db, emitter, clientEmitter) {
|
||||
constructor(db, taskManager, emitter, clientEmitter) {
|
||||
this.db = db
|
||||
this.taskManager = taskManager
|
||||
this.emitter = emitter
|
||||
this.clientEmitter = clientEmitter
|
||||
}
|
||||
|
||||
async updateAudioFileMetadataForItem(user, libraryItem) {
|
||||
updateMetadataForItem(user, libraryItem, useTone = true) {
|
||||
if (useTone) {
|
||||
this.updateMetadataForItemWithTone(user, libraryItem)
|
||||
} else {
|
||||
this.updateMetadataForItemWithFfmpeg(user, libraryItem)
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// TONE
|
||||
//
|
||||
getToneMetadataObjectForApi(libraryItem) {
|
||||
return toneHelpers.getToneMetadataObject(libraryItem)
|
||||
}
|
||||
|
||||
async updateMetadataForItemWithTone(user, libraryItem) {
|
||||
var audioFiles = libraryItem.media.includedAudioFiles
|
||||
|
||||
const itemAudioMetadataPayload = {
|
||||
userId: user.id,
|
||||
libraryItemId: libraryItem.id,
|
||||
startedAt: Date.now(),
|
||||
audioFiles: audioFiles.map(af => ({ index: af.index, ino: af.ino, filename: af.metadata.filename }))
|
||||
}
|
||||
|
||||
this.emitter('audio_metadata_started', itemAudioMetadataPayload)
|
||||
|
||||
// Write chapters file
|
||||
var chaptersFilePath = null
|
||||
const itemCacheDir = Path.join(global.MetadataPath, `cache/items/${libraryItem.id}`)
|
||||
await fs.ensureDir(itemCacheDir)
|
||||
|
||||
if (libraryItem.media.chapters.length) {
|
||||
chaptersFilePath = Path.join(itemCacheDir, 'chapters.txt')
|
||||
try {
|
||||
await toneHelpers.writeToneChaptersFile(libraryItem.media.chapters, chaptersFilePath)
|
||||
} catch (error) {
|
||||
Logger.error(`[AudioMetadataManager] Write chapters.txt failed`, error)
|
||||
chaptersFilePath = null
|
||||
}
|
||||
}
|
||||
|
||||
const toneMetadataObject = toneHelpers.getToneMetadataObject(libraryItem, chaptersFilePath)
|
||||
Logger.debug(`[AudioMetadataManager] Book "${libraryItem.media.metadata.title}" tone metadata object=`, toneMetadataObject)
|
||||
|
||||
const results = []
|
||||
for (const af of audioFiles) {
|
||||
const result = await this.updateAudioFileMetadataWithTone(libraryItem.id, af, toneMetadataObject, itemCacheDir)
|
||||
results.push(result)
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - itemAudioMetadataPayload.startedAt
|
||||
Logger.debug(`[AudioMetadataManager] Elapsed ${secondsToTimestamp(elapsed)}`)
|
||||
itemAudioMetadataPayload.results = results
|
||||
itemAudioMetadataPayload.elapsed = elapsed
|
||||
itemAudioMetadataPayload.finishedAt = Date.now()
|
||||
this.emitter('audio_metadata_finished', itemAudioMetadataPayload)
|
||||
}
|
||||
|
||||
async updateAudioFileMetadataWithTone(libraryItemId, audioFile, toneMetadataObject, itemCacheDir) {
|
||||
const resultPayload = {
|
||||
libraryItemId,
|
||||
index: audioFile.index,
|
||||
ino: audioFile.ino,
|
||||
filename: audioFile.metadata.filename
|
||||
}
|
||||
this.emitter('audiofile_metadata_started', resultPayload)
|
||||
|
||||
// Backup audio file
|
||||
try {
|
||||
const backupFilePath = Path.join(itemCacheDir, audioFile.metadata.filename)
|
||||
await fs.copy(audioFile.metadata.path, backupFilePath)
|
||||
Logger.debug(`[AudioMetadataManager] Backed up audio file at "${backupFilePath}"`)
|
||||
} catch (err) {
|
||||
Logger.error(`[AudioMetadataManager] Failed to backup audio file "${audioFile.metadata.path}"`, err)
|
||||
}
|
||||
|
||||
const _toneMetadataObject = {
|
||||
...toneMetadataObject,
|
||||
'TrackNumber': audioFile.index
|
||||
}
|
||||
|
||||
resultPayload.success = await toneHelpers.tagAudioFile(audioFile.metadata.path, _toneMetadataObject)
|
||||
if (resultPayload.success) {
|
||||
Logger.info(`[AudioMetadataManager] Successfully tagged audio file "${audioFile.metadata.path}"`)
|
||||
}
|
||||
|
||||
this.emitter('audiofile_metadata_finished', resultPayload)
|
||||
return resultPayload
|
||||
}
|
||||
|
||||
//
|
||||
// FFMPEG
|
||||
//
|
||||
async updateMetadataForItemWithFfmpeg(user, libraryItem) {
|
||||
var audioFiles = libraryItem.media.audioFiles
|
||||
|
||||
const itemAudioMetadataPayload = {
|
||||
@@ -36,9 +132,8 @@ class AudioMetadataMangaer {
|
||||
var coverPath = libraryItem.media.coverPath.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
// TODO: Split into batches
|
||||
const proms = audioFiles.map(af => {
|
||||
return this.updateAudioFileMetadata(libraryItem.id, af, outputDir, metadataFilePath, coverPath)
|
||||
return this.updateAudioFileMetadataWithFfmpeg(libraryItem.id, af, outputDir, metadataFilePath, coverPath)
|
||||
})
|
||||
|
||||
const results = await Promise.all(proms)
|
||||
@@ -55,7 +150,7 @@ class AudioMetadataMangaer {
|
||||
this.emitter('audio_metadata_finished', itemAudioMetadataPayload)
|
||||
}
|
||||
|
||||
updateAudioFileMetadata(libraryItemId, audioFile, outputDir, metadataFilePath, coverPath = '') {
|
||||
updateAudioFileMetadataWithFfmpeg(libraryItemId, audioFile, outputDir, metadataFilePath, coverPath = '') {
|
||||
return new Promise((resolve) => {
|
||||
const resultPayload = {
|
||||
libraryItemId,
|
||||
|
||||
@@ -10,6 +10,7 @@ class CacheManager {
|
||||
this.CachePath = Path.join(global.MetadataPath, 'cache')
|
||||
this.CoverCachePath = Path.join(this.CachePath, 'covers')
|
||||
this.ImageCachePath = Path.join(this.CachePath, 'images')
|
||||
this.ItemCachePath = Path.join(this.CachePath, 'items')
|
||||
}
|
||||
|
||||
async ensureCachePaths() { // Creates cache paths if necessary and sets owner and permissions
|
||||
@@ -29,6 +30,11 @@ class CacheManager {
|
||||
pathsCreated = true
|
||||
}
|
||||
|
||||
if (!(await fs.pathExists(this.ItemCachePath))) {
|
||||
await fs.mkdir(this.ItemCachePath)
|
||||
pathsCreated = true
|
||||
}
|
||||
|
||||
if (pathsCreated) {
|
||||
await filePerms.setDefault(this.CachePath)
|
||||
}
|
||||
@@ -108,6 +114,15 @@ class CacheManager {
|
||||
await this.ensureCachePaths()
|
||||
}
|
||||
|
||||
async purgeItems() {
|
||||
if (await fs.pathExists(this.ItemCachePath)) {
|
||||
await fs.remove(this.ItemCachePath).catch((error) => {
|
||||
Logger.error(`[CacheManager] Failed to remove items cache dir "${this.ItemCachePath}"`, error)
|
||||
})
|
||||
}
|
||||
await this.ensureCachePaths()
|
||||
}
|
||||
|
||||
async handleAuthorCache(res, author, options = {}) {
|
||||
const format = options.format || 'webp'
|
||||
const width = options.width || 400
|
||||
|
||||
@@ -25,22 +25,6 @@ class DownloadManager {
|
||||
return this.downloads.find(d => d.id === downloadId)
|
||||
}
|
||||
|
||||
async removeOrphanDownloads() {
|
||||
try {
|
||||
var dirs = await fs.readdir(this.downloadDirPath)
|
||||
if (!dirs || !dirs.length) return true
|
||||
|
||||
await Promise.all(dirs.map(async (dirname) => {
|
||||
var fullPath = Path.join(this.downloadDirPath, dirname)
|
||||
Logger.info(`Removing Orphan Download ${dirname}`)
|
||||
return fs.remove(fullPath)
|
||||
}))
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
downloadSocketRequest(socket, payload) {
|
||||
var client = socket.sheepClient
|
||||
var audiobook = this.db.audiobooks.find(a => a.id === payload.audiobookId)
|
||||
|
||||
113
server/managers/NotificationManager.js
Normal file
113
server/managers/NotificationManager.js
Normal file
@@ -0,0 +1,113 @@
|
||||
const axios = require('axios')
|
||||
const Logger = require("../Logger")
|
||||
const { notificationData } = require('../utils/notifications')
|
||||
|
||||
class NotificationManager {
|
||||
constructor(db, emitter) {
|
||||
this.db = db
|
||||
this.emitter = emitter
|
||||
|
||||
this.sendingNotification = false
|
||||
this.notificationQueue = []
|
||||
}
|
||||
|
||||
getData() {
|
||||
return notificationData
|
||||
}
|
||||
|
||||
onPodcastEpisodeDownloaded(libraryItem, episode) {
|
||||
if (!this.db.notificationSettings.isUseable) return
|
||||
|
||||
Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: Episode "${episode.title}" for podcast ${libraryItem.media.metadata.title}`)
|
||||
const library = this.db.libraries.find(lib => lib.id === libraryItem.libraryId)
|
||||
const eventData = {
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryId: libraryItem.libraryId,
|
||||
libraryName: library ? library.name : 'Unknown',
|
||||
podcastTitle: libraryItem.media.metadata.title,
|
||||
episodeId: episode.id,
|
||||
episodeTitle: episode.title
|
||||
}
|
||||
this.triggerNotification('onPodcastEpisodeDownloaded', eventData)
|
||||
}
|
||||
|
||||
onTest() {
|
||||
this.triggerNotification('onTest')
|
||||
}
|
||||
|
||||
async triggerNotification(eventName, eventData, intentionallyFail = false) {
|
||||
if (!this.db.notificationSettings.isUseable) return
|
||||
|
||||
// Will queue the notification if sendingNotification and queue is not full
|
||||
if (!this.checkTriggerNotification(eventName, eventData)) return
|
||||
|
||||
const notifications = this.db.notificationSettings.getActiveNotificationsForEvent(eventName)
|
||||
for (const notification of notifications) {
|
||||
Logger.debug(`[NotificationManager] triggerNotification: Sending ${eventName} notification ${notification.id}`)
|
||||
const success = intentionallyFail ? false : await this.sendNotification(notification, eventData)
|
||||
|
||||
notification.updateNotificationFired(success)
|
||||
if (!success) { // Failed notification
|
||||
if (notification.numConsecutiveFailedAttempts >= this.db.notificationSettings.maxFailedAttempts) {
|
||||
Logger.error(`[NotificationManager] triggerNotification: ${notification.eventName}/${notification.id} reached max failed attempts`)
|
||||
notification.enabled = false
|
||||
} else {
|
||||
Logger.error(`[NotificationManager] triggerNotification: ${notification.eventName}/${notification.id} ${notification.numConsecutiveFailedAttempts} failed attempts`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.db.updateEntity('settings', this.db.notificationSettings)
|
||||
this.emitter('notifications_updated', this.db.notificationSettings)
|
||||
|
||||
this.notificationFinished()
|
||||
}
|
||||
|
||||
// Return TRUE if notification should be triggered now
|
||||
checkTriggerNotification(eventName, eventData) {
|
||||
if (this.sendingNotification) {
|
||||
if (this.notificationQueue.length >= this.db.notificationSettings.maxNotificationQueue) {
|
||||
Logger.warn(`[NotificationManager] Notification queue is full - ignoring event ${eventName}`)
|
||||
} else {
|
||||
Logger.debug(`[NotificationManager] Queueing notification ${eventName} (Queue size: ${this.notificationQueue.length})`)
|
||||
this.notificationQueue.push({ eventName, eventData })
|
||||
}
|
||||
return false
|
||||
}
|
||||
this.sendingNotification = true
|
||||
return true
|
||||
}
|
||||
|
||||
notificationFinished() {
|
||||
// Delay between events then run next notification in queue
|
||||
setTimeout(() => {
|
||||
this.sendingNotification = false
|
||||
if (this.notificationQueue.length) { // Send next notification in queue
|
||||
const nextNotificationEvent = this.notificationQueue.shift()
|
||||
this.triggerNotification(nextNotificationEvent.eventName, nextNotificationEvent.eventData)
|
||||
}
|
||||
}, this.db.notificationSettings.notificationDelay)
|
||||
}
|
||||
|
||||
sendTestNotification(notification) {
|
||||
const eventData = notificationData.events.find(e => e.name === notification.eventName)
|
||||
if (!eventData) {
|
||||
Logger.error(`[NotificationManager] sendTestNotification: Event not found ${notification.eventName}`)
|
||||
return false
|
||||
}
|
||||
|
||||
return this.sendNotification(notification, eventData.testData)
|
||||
}
|
||||
|
||||
sendNotification(notification, eventData) {
|
||||
const payload = notification.getApprisePayload(eventData)
|
||||
return axios.post(this.db.notificationSettings.appriseApiUrl, payload, { timeout: 6000 }).then((response) => {
|
||||
Logger.debug(`[NotificationManager] sendNotification: ${notification.eventName}/${notification.id} response=`, response.data)
|
||||
return true
|
||||
}).catch((error) => {
|
||||
Logger.error(`[NotificationManager] sendNotification: ${notification.eventName}/${notification.id} error=`, error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
}
|
||||
module.exports = NotificationManager
|
||||
@@ -1,10 +1,10 @@
|
||||
const fs = require('../libs/fsExtra')
|
||||
const axios = require('axios')
|
||||
|
||||
const { parsePodcastRssFeedXml } = require('../utils/podcastUtils')
|
||||
const { getPodcastFeed } = require('../utils/podcastUtils')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
const { downloadFile, removeFile } = require('../utils/fileUtils')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
const { levenshteinDistance } = require('../utils/index')
|
||||
const opmlParser = require('../utils/parsers/parseOPML')
|
||||
const prober = require('../utils/prober')
|
||||
@@ -14,10 +14,11 @@ const PodcastEpisode = require('../objects/entities/PodcastEpisode')
|
||||
const AudioFile = require('../objects/files/AudioFile')
|
||||
|
||||
class PodcastManager {
|
||||
constructor(db, watcher, emitter) {
|
||||
constructor(db, watcher, emitter, notificationManager) {
|
||||
this.db = db
|
||||
this.watcher = watcher
|
||||
this.emitter = emitter
|
||||
this.notificationManager = notificationManager
|
||||
|
||||
this.downloadQueue = []
|
||||
this.currentDownload = null
|
||||
@@ -72,6 +73,13 @@ class PodcastManager {
|
||||
// Ignores all added files to this dir
|
||||
this.watcher.addIgnoreDir(this.currentDownload.libraryItem.path)
|
||||
|
||||
// Make sure podcast library item folder exists
|
||||
if (!(await fs.pathExists(this.currentDownload.libraryItem.path))) {
|
||||
Logger.warn(`[PodcastManager] Podcast episode download: Podcast folder no longer exists at "${this.currentDownload.libraryItem.path}" - Creating it`)
|
||||
await fs.mkdir(this.currentDownload.libraryItem.path)
|
||||
await filePerms.setDefault(this.currentDownload.libraryItem.path)
|
||||
}
|
||||
|
||||
var success = await downloadFile(this.currentDownload.url, this.currentDownload.targetPath).then(() => true).catch((error) => {
|
||||
Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
|
||||
return false
|
||||
@@ -123,8 +131,8 @@ class PodcastManager {
|
||||
}
|
||||
libraryItem.libraryFiles.push(libraryFile)
|
||||
|
||||
// Check setting maxEpisodesToKeep and remove episode if necessary
|
||||
if (this.currentDownload.isAutoDownload) { // only applies for auto-downloaded episodes
|
||||
if (this.currentDownload.isAutoDownload) {
|
||||
// Check setting maxEpisodesToKeep and remove episode if necessary
|
||||
if (libraryItem.media.maxEpisodesToKeep && libraryItem.media.episodesWithPubDate.length > libraryItem.media.maxEpisodesToKeep) {
|
||||
Logger.info(`[PodcastManager] # of episodes (${libraryItem.media.episodesWithPubDate.length}) exceeds max episodes to keep (${libraryItem.media.maxEpisodesToKeep})`)
|
||||
await this.removeOldestEpisode(libraryItem, podcastEpisode.id)
|
||||
@@ -134,6 +142,11 @@ class PodcastManager {
|
||||
libraryItem.updatedAt = Date.now()
|
||||
await this.db.updateLibraryItem(libraryItem)
|
||||
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
|
||||
if (this.currentDownload.isAutoDownload) { // Notifications only for auto downloaded episodes
|
||||
this.notificationManager.onPodcastEpisodeDownloaded(libraryItem, podcastEpisode)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -226,7 +239,7 @@ class PodcastManager {
|
||||
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`)
|
||||
return false
|
||||
}
|
||||
var feed = await this.getPodcastFeed(podcastLibraryItem.media.metadata.feedUrl)
|
||||
var feed = await getPodcastFeed(podcastLibraryItem.media.metadata.feedUrl)
|
||||
if (!feed || !feed.episodes) {
|
||||
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`, feed)
|
||||
return false
|
||||
@@ -262,7 +275,7 @@ class PodcastManager {
|
||||
}
|
||||
|
||||
async findEpisode(rssFeedUrl, searchTitle) {
|
||||
const feed = await this.getPodcastFeed(rssFeedUrl).catch(() => {
|
||||
const feed = await getPodcastFeed(rssFeedUrl).catch(() => {
|
||||
return null
|
||||
})
|
||||
if (!feed || !feed.episodes) {
|
||||
@@ -292,25 +305,6 @@ class PodcastManager {
|
||||
return matches.sort((a, b) => a.levenshtein - b.levenshtein)
|
||||
}
|
||||
|
||||
getPodcastFeed(feedUrl, excludeEpisodeMetadata = false) {
|
||||
Logger.debug(`[PodcastManager] getPodcastFeed for "${feedUrl}"`)
|
||||
return axios.get(feedUrl, { timeout: 5000 }).then(async (data) => {
|
||||
if (!data || !data.data) {
|
||||
Logger.error('Invalid podcast feed request response')
|
||||
return false
|
||||
}
|
||||
Logger.debug(`[PodcastManager] getPodcastFeed for "${feedUrl}" success - parsing xml`)
|
||||
var payload = await parsePodcastRssFeedXml(data.data, excludeEpisodeMetadata)
|
||||
if (!payload) {
|
||||
return false
|
||||
}
|
||||
return payload.podcast
|
||||
}).catch((error) => {
|
||||
Logger.error('[PodcastManager] getPodcastFeed Error', error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
async getOPMLFeeds(opmlText) {
|
||||
var extractedFeeds = opmlParser.parse(opmlText)
|
||||
if (!extractedFeeds || !extractedFeeds.length) {
|
||||
@@ -323,7 +317,7 @@ class PodcastManager {
|
||||
var rssFeedData = []
|
||||
|
||||
for (let feed of extractedFeeds) {
|
||||
var feedData = await this.getPodcastFeed(feed.feedUrl, true)
|
||||
var feedData = await getPodcastFeed(feed.feedUrl, true)
|
||||
if (feedData) {
|
||||
feedData.metadata.feedUrl = feed.feedUrl
|
||||
rssFeedData.push(feedData)
|
||||
|
||||
20
server/managers/TaskManager.js
Normal file
20
server/managers/TaskManager.js
Normal file
@@ -0,0 +1,20 @@
|
||||
class TaskManager {
|
||||
constructor(emitter) {
|
||||
this.emitter = emitter
|
||||
|
||||
this.tasks = []
|
||||
}
|
||||
|
||||
addTask(task) {
|
||||
this.tasks.push(task)
|
||||
this.emitter('task_started', task.toJSON())
|
||||
}
|
||||
|
||||
taskFinished(task) {
|
||||
if (this.tasks.some(t => t.id === task.id)) {
|
||||
this.tasks = this.tasks.filter(t => t.id !== task.id)
|
||||
this.emitter('task_finished', task.toJSON())
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = TaskManager
|
||||
@@ -1,120 +0,0 @@
|
||||
const { AudioMimeType } = require('../utils/constants')
|
||||
const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils')
|
||||
const DEFAULT_EXPIRATION = 1000 * 60 * 60 // 60 minutes
|
||||
const DEFAULT_TIMEOUT = 1000 * 60 * 30 // 30 minutes
|
||||
|
||||
class Download {
|
||||
constructor(download) {
|
||||
this.id = null
|
||||
this.libraryItemId = null
|
||||
this.type = null
|
||||
|
||||
this.dirpath = null
|
||||
this.path = null
|
||||
this.ext = null
|
||||
this.filename = null
|
||||
this.size = 0
|
||||
|
||||
this.userId = null
|
||||
this.isReady = false
|
||||
this.isTimedOut = false
|
||||
|
||||
this.startedAt = null
|
||||
this.finishedAt = null
|
||||
this.expiresAt = null
|
||||
|
||||
this.expirationTimeMs = 0
|
||||
this.timeoutTimeMs = 0
|
||||
|
||||
this.timeoutTimer = null
|
||||
this.expirationTimer = null
|
||||
|
||||
if (download) {
|
||||
this.construct(download)
|
||||
}
|
||||
}
|
||||
|
||||
get mimeType() {
|
||||
return getAudioMimeTypeFromExtname(this.ext) || AudioMimeType.MP3
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
libraryItemId: this.libraryItemId,
|
||||
type: this.type,
|
||||
dirpath: this.dirpath,
|
||||
path: this.path,
|
||||
ext: this.ext,
|
||||
filename: this.filename,
|
||||
size: this.size,
|
||||
userId: this.userId,
|
||||
isReady: this.isReady,
|
||||
startedAt: this.startedAt,
|
||||
finishedAt: this.finishedAt,
|
||||
expirationSeconds: this.expirationSeconds
|
||||
}
|
||||
}
|
||||
|
||||
construct(download) {
|
||||
this.id = download.id
|
||||
this.libraryItemId = download.libraryItemId
|
||||
this.type = download.type
|
||||
|
||||
this.dirpath = download.dirpath
|
||||
this.path = download.path
|
||||
this.ext = download.ext
|
||||
this.filename = download.filename
|
||||
this.size = download.size || 0
|
||||
|
||||
this.userId = download.userId
|
||||
this.isReady = !!download.isReady
|
||||
|
||||
this.startedAt = download.startedAt
|
||||
this.finishedAt = download.finishedAt || null
|
||||
|
||||
this.expirationTimeMs = download.expirationTimeMs || DEFAULT_EXPIRATION
|
||||
this.timeoutTimeMs = download.timeoutTimeMs || DEFAULT_TIMEOUT
|
||||
|
||||
this.expiresAt = download.expiresAt || null
|
||||
}
|
||||
|
||||
setData(downloadData) {
|
||||
downloadData.startedAt = Date.now()
|
||||
downloadData.isProcessing = true
|
||||
this.construct(downloadData)
|
||||
}
|
||||
|
||||
setComplete(fileSize) {
|
||||
this.finishedAt = Date.now()
|
||||
this.size = fileSize
|
||||
this.isReady = true
|
||||
this.expiresAt = this.finishedAt + this.expirationTimeMs
|
||||
}
|
||||
|
||||
setExpirationTimer(callback) {
|
||||
this.expirationTimer = setTimeout(() => {
|
||||
if (callback) {
|
||||
callback(this)
|
||||
}
|
||||
}, this.expirationTimeMs)
|
||||
}
|
||||
|
||||
setTimeoutTimer(callback) {
|
||||
this.timeoutTimer = setTimeout(() => {
|
||||
if (callback) {
|
||||
this.isTimedOut = true
|
||||
callback(this)
|
||||
}
|
||||
}, this.timeoutTimeMs)
|
||||
}
|
||||
|
||||
clearTimeoutTimer() {
|
||||
clearTimeout(this.timeoutTimer)
|
||||
}
|
||||
|
||||
clearExpirationTimer() {
|
||||
clearTimeout(this.expirationTimer)
|
||||
}
|
||||
}
|
||||
module.exports = Download
|
||||
133
server/objects/Notification.js
Normal file
133
server/objects/Notification.js
Normal file
@@ -0,0 +1,133 @@
|
||||
const { getId } = require('../utils/index')
|
||||
|
||||
class Notification {
|
||||
constructor(notification = null) {
|
||||
this.id = null
|
||||
this.libraryId = null
|
||||
this.eventName = ''
|
||||
this.urls = []
|
||||
this.titleTemplate = ''
|
||||
this.bodyTemplate = ''
|
||||
this.type = 'info'
|
||||
this.enabled = false
|
||||
|
||||
this.lastFiredAt = null
|
||||
this.lastAttemptFailed = false
|
||||
this.numConsecutiveFailedAttempts = 0
|
||||
this.numTimesFired = 0
|
||||
this.createdAt = null
|
||||
|
||||
if (notification) {
|
||||
this.construct(notification)
|
||||
}
|
||||
}
|
||||
|
||||
construct(notification) {
|
||||
this.id = notification.id
|
||||
this.libraryId = notification.libraryId || null
|
||||
this.eventName = notification.eventName
|
||||
this.urls = notification.urls || []
|
||||
this.titleTemplate = notification.titleTemplate || ''
|
||||
this.bodyTemplate = notification.bodyTemplate || ''
|
||||
this.type = notification.type || 'info'
|
||||
this.enabled = !!notification.enabled
|
||||
this.lastFiredAt = notification.lastFiredAt || null
|
||||
this.lastAttemptFailed = !!notification.lastAttemptFailed
|
||||
this.numConsecutiveFailedAttempts = notification.numConsecutiveFailedAttempts || 0
|
||||
this.numTimesFired = notification.numTimesFired || 0
|
||||
this.createdAt = notification.createdAt
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
libraryId: this.libraryId,
|
||||
eventName: this.eventName,
|
||||
urls: this.urls,
|
||||
titleTemplate: this.titleTemplate,
|
||||
bodyTemplate: this.bodyTemplate,
|
||||
enabled: this.enabled,
|
||||
type: this.type,
|
||||
lastFiredAt: this.lastFiredAt,
|
||||
lastAttemptFailed: this.lastAttemptFailed,
|
||||
numConsecutiveFailedAttempts: this.numConsecutiveFailedAttempts,
|
||||
numTimesFired: this.numTimesFired,
|
||||
createdAt: this.createdAt
|
||||
}
|
||||
}
|
||||
|
||||
setData(payload) {
|
||||
this.id = getId('noti')
|
||||
this.libraryId = payload.libraryId || null
|
||||
this.eventName = payload.eventName
|
||||
this.urls = payload.urls
|
||||
this.titleTemplate = payload.titleTemplate
|
||||
this.bodyTemplate = payload.bodyTemplate
|
||||
this.enabled = !!payload.enabled
|
||||
this.type = payload.type || null
|
||||
this.createdAt = Date.now()
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
if (!this.enabled && payload.enabled) {
|
||||
// Reset
|
||||
this.lastFiredAt = null
|
||||
this.lastAttemptFailed = false
|
||||
this.numConsecutiveFailedAttempts = 0
|
||||
}
|
||||
|
||||
const keysToUpdate = ['libraryId', 'eventName', 'urls', 'titleTemplate', 'bodyTemplate', 'enabled', 'type']
|
||||
var hasUpdated = false
|
||||
for (const key of keysToUpdate) {
|
||||
if (payload[key] !== undefined) {
|
||||
if (key === 'urls') {
|
||||
if (payload[key].join(',') !== this.urls.join(',')) {
|
||||
this.urls = [...payload[key]]
|
||||
hasUpdated = true
|
||||
}
|
||||
} else if (payload[key] !== this[key]) {
|
||||
this[key] = payload[key]
|
||||
hasUpdated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return hasUpdated
|
||||
}
|
||||
|
||||
updateNotificationFired(success) {
|
||||
this.lastFiredAt = Date.now()
|
||||
this.lastAttemptFailed = !success
|
||||
this.numConsecutiveFailedAttempts = success ? 0 : this.numConsecutiveFailedAttempts + 1
|
||||
this.numTimesFired++
|
||||
}
|
||||
|
||||
replaceVariablesInTemplate(templateText, data) {
|
||||
const ptrn = /{{ ?([a-zA-Z]+) ?}}/mg
|
||||
|
||||
var match
|
||||
var updatedTemplate = templateText
|
||||
while ((match = ptrn.exec(templateText)) != null) {
|
||||
if (data[match[1]]) {
|
||||
updatedTemplate = updatedTemplate.replace(match[0], data[match[1]])
|
||||
}
|
||||
}
|
||||
return updatedTemplate
|
||||
}
|
||||
|
||||
parseTitleTemplate(data) {
|
||||
return this.replaceVariablesInTemplate(this.titleTemplate, data)
|
||||
}
|
||||
|
||||
parseBodyTemplate(data) {
|
||||
return this.replaceVariablesInTemplate(this.bodyTemplate, data)
|
||||
}
|
||||
|
||||
getApprisePayload(data) {
|
||||
return {
|
||||
urls: this.urls,
|
||||
title: this.parseTitleTemplate(data),
|
||||
body: this.parseBodyTemplate(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = Notification
|
||||
56
server/objects/Task.js
Normal file
56
server/objects/Task.js
Normal file
@@ -0,0 +1,56 @@
|
||||
const { getId } = require('../utils/index')
|
||||
|
||||
class Task {
|
||||
constructor() {
|
||||
this.id = null
|
||||
this.action = null // e.g. embed-metadata, encode-m4b, etc
|
||||
this.data = null // additional info for the action like libraryItemId
|
||||
|
||||
this.title = null
|
||||
this.description = null
|
||||
this.error = null
|
||||
|
||||
this.isFailed = false
|
||||
this.isFinished = false
|
||||
|
||||
this.startedAt = null
|
||||
this.finishedAt = null
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
action: this.action,
|
||||
data: this.data ? { ...this.data } : {},
|
||||
title: this.title,
|
||||
description: this.description,
|
||||
error: this.error,
|
||||
isFailed: this.isFailed,
|
||||
isFinished: this.isFinished,
|
||||
startedAt: this.startedAt,
|
||||
finishedAt: this.finishedAt
|
||||
}
|
||||
}
|
||||
|
||||
setData(action, title, description, data = {}) {
|
||||
this.id = getId(action)
|
||||
this.action = action
|
||||
this.data = { ...data }
|
||||
this.title = title
|
||||
this.description = description
|
||||
this.startedAt = Date.now()
|
||||
}
|
||||
|
||||
setFailed(message) {
|
||||
this.error = message
|
||||
this.isFailed = true
|
||||
this.failedAt = Date.now()
|
||||
this.setFinished()
|
||||
}
|
||||
|
||||
setFinished() {
|
||||
this.isFinished = true
|
||||
this.finishedAt = Date.now()
|
||||
}
|
||||
}
|
||||
module.exports = Task
|
||||
@@ -1,5 +1,6 @@
|
||||
const Logger = require('../../Logger')
|
||||
const { getId } = require('../../utils/index')
|
||||
const { checkNamesAreEqual } = require('../../utils/parsers/parseNameString')
|
||||
|
||||
class Author {
|
||||
constructor(author) {
|
||||
@@ -86,7 +87,7 @@ class Author {
|
||||
Logger.error(`[Author] Author name is null (${this.id})`)
|
||||
return false
|
||||
}
|
||||
return this.name.toLowerCase() == name.toLowerCase().trim()
|
||||
return checkNamesAreEqual(this.name, name)
|
||||
}
|
||||
}
|
||||
module.exports = Author
|
||||
@@ -107,6 +107,9 @@ class PodcastEpisode {
|
||||
if (this.episode) return `${this.episode} - ${this.title}`
|
||||
return this.title
|
||||
}
|
||||
get enclosureUrl() {
|
||||
return this.enclosure ? this.enclosure.url : null
|
||||
}
|
||||
|
||||
setData(data, index = 1) {
|
||||
this.id = getId('ep')
|
||||
|
||||
@@ -47,6 +47,19 @@ class Series {
|
||||
this.updatedAt = Date.now()
|
||||
}
|
||||
|
||||
update(series) {
|
||||
if (!series) return false
|
||||
const keysToUpdate = ['name', 'description']
|
||||
var hasUpdated = false
|
||||
for (const key of keysToUpdate) {
|
||||
if (series[key] !== undefined && series[key] !== this[key]) {
|
||||
this[key] = series[key]
|
||||
hasUpdated = true
|
||||
}
|
||||
}
|
||||
return hasUpdated
|
||||
}
|
||||
|
||||
checkNameEquals(name) {
|
||||
if (!name || !this.name) return false
|
||||
return this.name.toLowerCase() == name.toLowerCase().trim()
|
||||
|
||||
@@ -29,7 +29,7 @@ class AudioTrack {
|
||||
this.startOffset = startOffset
|
||||
this.duration = audioFile.duration
|
||||
this.title = audioFile.metadata.filename || ''
|
||||
this.contentUrl = Path.join(`/s/item/${itemId}`, encodeUriPath(audioFile.metadata.relPath))
|
||||
this.contentUrl = Path.join(`${global.RouterBasePath}/s/item/${itemId}`, encodeUriPath(audioFile.metadata.relPath))
|
||||
this.mimeType = audioFile.mimeType
|
||||
this.metadata = audioFile.metadata.clone()
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ class VideoTrack {
|
||||
this.index = videoFile.index
|
||||
this.duration = videoFile.duration
|
||||
this.title = videoFile.metadata.filename || ''
|
||||
this.contentUrl = Path.join(`/s/item/${itemId}`, encodeUriPath(videoFile.metadata.relPath))
|
||||
this.contentUrl = Path.join(`${global.RouterBasePath}/s/item/${itemId}`, encodeUriPath(videoFile.metadata.relPath))
|
||||
this.mimeType = videoFile.mimeType
|
||||
this.metadata = videoFile.metadata.clone()
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ const Logger = require('../../Logger')
|
||||
const BookMetadata = require('../metadata/BookMetadata')
|
||||
const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index')
|
||||
const { parseOpfMetadataXML } = require('../../utils/parsers/parseOpfMetadata')
|
||||
const { overdriveMediaMarkersExist, parseOverdriveMediaMarkersAsChapters } = require('../../utils/parsers/parseOverdriveMediaMarkers')
|
||||
const { parseOverdriveMediaMarkersAsChapters } = require('../../utils/parsers/parseOverdriveMediaMarkers')
|
||||
const abmetadataGenerator = require('../../utils/abmetadataGenerator')
|
||||
const { readTextFile } = require('../../utils/fileUtils')
|
||||
const AudioFile = require('../files/AudioFile')
|
||||
@@ -111,12 +111,15 @@ class Book {
|
||||
get invalidAudioFiles() {
|
||||
return this.audioFiles.filter(af => af.invalid)
|
||||
}
|
||||
get includedAudioFiles() {
|
||||
return this.audioFiles.filter(af => !af.exclude && !af.invalid)
|
||||
}
|
||||
get hasIssues() {
|
||||
return this.missingParts.length || this.invalidAudioFiles.length
|
||||
}
|
||||
get tracks() {
|
||||
var startOffset = 0
|
||||
return this.audioFiles.filter(af => !af.exclude && !af.invalid).map((af) => {
|
||||
return this.includedAudioFiles.map((af) => {
|
||||
var audioTrack = new AudioTrack()
|
||||
audioTrack.setData(this.libraryItemId, af, startOffset)
|
||||
startOffset += audioTrack.duration
|
||||
|
||||
@@ -87,6 +87,10 @@ class AudioMetaTags {
|
||||
this.tagOverdriveMediaMarker = payload.file_tag_overdrive_media_marker || null
|
||||
}
|
||||
|
||||
setDataFromTone(tags) {
|
||||
// TODO: Implement
|
||||
}
|
||||
|
||||
updateData(payload) {
|
||||
const dataMap = {
|
||||
tagAlbum: payload.file_tag_album || null,
|
||||
|
||||
@@ -133,6 +133,14 @@ class BookMetadata {
|
||||
return `${getTitleIgnorePrefix(se.name)} #${se.sequence}`
|
||||
}).join(', ')
|
||||
}
|
||||
get firstSeriesName() {
|
||||
if (!this.series.length) return ''
|
||||
return this.series[0].name
|
||||
}
|
||||
get firstSeriesSequence() {
|
||||
if (!this.series.length) return ''
|
||||
return this.series[0].sequence
|
||||
}
|
||||
get narratorName() {
|
||||
return this.narrators.join(', ')
|
||||
}
|
||||
|
||||
106
server/objects/settings/NotificationSettings.js
Normal file
106
server/objects/settings/NotificationSettings.js
Normal file
@@ -0,0 +1,106 @@
|
||||
const Logger = require('../../Logger')
|
||||
const Notification = require('../Notification')
|
||||
const { isNullOrNaN } = require('../../utils')
|
||||
|
||||
class NotificationSettings {
|
||||
constructor(settings = null) {
|
||||
this.id = 'notification-settings'
|
||||
this.appriseType = 'api'
|
||||
this.appriseApiUrl = null
|
||||
this.notifications = []
|
||||
this.maxFailedAttempts = 5
|
||||
this.maxNotificationQueue = 20 // once reached events will be ignored
|
||||
this.notificationDelay = 1000 // ms delay between firing notifications
|
||||
|
||||
if (settings) {
|
||||
this.construct(settings)
|
||||
}
|
||||
}
|
||||
|
||||
construct(settings) {
|
||||
this.appriseType = settings.appriseType
|
||||
this.appriseApiUrl = settings.appriseApiUrl || null
|
||||
this.notifications = (settings.notifications || []).map(n => new Notification(n))
|
||||
this.maxFailedAttempts = settings.maxFailedAttempts || 5
|
||||
this.maxNotificationQueue = settings.maxNotificationQueue || 20
|
||||
this.notificationDelay = settings.notificationDelay || 1000
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
appriseType: this.appriseType,
|
||||
appriseApiUrl: this.appriseApiUrl,
|
||||
notifications: this.notifications.map(n => n.toJSON()),
|
||||
maxFailedAttempts: this.maxFailedAttempts,
|
||||
maxNotificationQueue: this.maxNotificationQueue,
|
||||
notificationDelay: this.notificationDelay
|
||||
}
|
||||
}
|
||||
|
||||
get isUseable() {
|
||||
return !!this.appriseApiUrl
|
||||
}
|
||||
|
||||
getActiveNotificationsForEvent(eventName) {
|
||||
return this.notifications.filter(n => n.eventName === eventName && n.enabled)
|
||||
}
|
||||
|
||||
getNotification(id) {
|
||||
return this.notifications.find(n => n.id === id)
|
||||
}
|
||||
|
||||
removeNotification(id) {
|
||||
if (this.notifications.some(n => n.id === id)) {
|
||||
this.notifications = this.notifications.filter(n => n.id !== id)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
if (!payload) return false
|
||||
|
||||
var hasUpdates = false
|
||||
if (payload.appriseApiUrl !== this.appriseApiUrl) {
|
||||
this.appriseApiUrl = payload.appriseApiUrl || null
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
const _maxFailedAttempts = isNullOrNaN(payload.maxFailedAttempts) ? 5 : Number(payload.maxFailedAttempts)
|
||||
if (_maxFailedAttempts !== this.maxFailedAttempts) {
|
||||
this.maxFailedAttempts = _maxFailedAttempts
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
const _maxNotificationQueue = isNullOrNaN(payload.maxNotificationQueue) ? 20 : Number(payload.maxNotificationQueue)
|
||||
if (_maxNotificationQueue !== this.maxNotificationQueue) {
|
||||
this.maxNotificationQueue = _maxNotificationQueue
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
createNotification(payload) {
|
||||
if (!payload) return false
|
||||
if (!payload.eventName || !payload.urls.length) return false
|
||||
|
||||
const notification = new Notification()
|
||||
notification.setData(payload)
|
||||
this.notifications.push(notification)
|
||||
return true
|
||||
}
|
||||
|
||||
updateNotification(payload) {
|
||||
if (!payload) return false
|
||||
const notification = this.notifications.find(n => n.id === payload.id)
|
||||
if (!notification) {
|
||||
Logger.error(`[NotificationSettings] updateNotification: Notification not found ${payload.id}`)
|
||||
return false
|
||||
}
|
||||
|
||||
return notification.update(payload)
|
||||
}
|
||||
}
|
||||
module.exports = NotificationSettings
|
||||
@@ -17,7 +17,8 @@ class ServerSettings {
|
||||
this.scannerDisableWatcher = false
|
||||
this.scannerPreferOverdriveMediaMarker = false
|
||||
this.scannerUseSingleThreadedProber = true
|
||||
this.scannerMaxThreads = 0 // 0 = defaults to CPUs * 2
|
||||
this.scannerMaxThreads = 0 // Currently not being used
|
||||
this.scannerUseTone = false
|
||||
|
||||
// Metadata - choose to store inside users library item folder
|
||||
this.storeCoverWithItem = false
|
||||
@@ -82,6 +83,7 @@ class ServerSettings {
|
||||
this.scannerUseSingleThreadedProber = true
|
||||
}
|
||||
this.scannerMaxThreads = isNullOrNaN(settings.scannerMaxThreads) ? 0 : Number(settings.scannerMaxThreads)
|
||||
this.scannerUseTone = !!settings.scannerUseTone
|
||||
|
||||
this.storeCoverWithItem = !!settings.storeCoverWithItem
|
||||
this.storeMetadataWithItem = !!settings.storeMetadataWithItem
|
||||
@@ -139,6 +141,7 @@ class ServerSettings {
|
||||
scannerPreferOverdriveMediaMarker: this.scannerPreferOverdriveMediaMarker,
|
||||
scannerUseSingleThreadedProber: this.scannerUseSingleThreadedProber,
|
||||
scannerMaxThreads: this.scannerMaxThreads,
|
||||
scannerUseTone: this.scannerUseTone,
|
||||
storeCoverWithItem: this.storeCoverWithItem,
|
||||
storeMetadataWithItem: this.storeMetadataWithItem,
|
||||
rateLimitLoginRequests: this.rateLimitLoginRequests,
|
||||
|
||||
@@ -8,6 +8,7 @@ class MediaProgress {
|
||||
this.progress = null // 0 to 1
|
||||
this.currentTime = null // seconds
|
||||
this.isFinished = false
|
||||
this.hideFromContinueListening = false
|
||||
|
||||
this.lastUpdate = null
|
||||
this.startedAt = null
|
||||
@@ -27,6 +28,7 @@ class MediaProgress {
|
||||
progress: this.progress,
|
||||
currentTime: this.currentTime,
|
||||
isFinished: this.isFinished,
|
||||
hideFromContinueListening: this.hideFromContinueListening,
|
||||
lastUpdate: this.lastUpdate,
|
||||
startedAt: this.startedAt,
|
||||
finishedAt: this.finishedAt
|
||||
@@ -41,6 +43,7 @@ class MediaProgress {
|
||||
this.progress = progress.progress
|
||||
this.currentTime = progress.currentTime
|
||||
this.isFinished = !!progress.isFinished
|
||||
this.hideFromContinueListening = !!progress.hideFromContinueListening
|
||||
this.lastUpdate = progress.lastUpdate
|
||||
this.startedAt = progress.startedAt
|
||||
this.finishedAt = progress.finishedAt || null
|
||||
@@ -58,6 +61,7 @@ class MediaProgress {
|
||||
this.progress = Math.min(1, (progress.progress || 0))
|
||||
this.currentTime = progress.currentTime || 0
|
||||
this.isFinished = !!progress.isFinished || this.progress == 1
|
||||
this.hideFromContinueListening = !!progress.hideFromContinueListening
|
||||
this.lastUpdate = Date.now()
|
||||
this.startedAt = Date.now()
|
||||
this.finishedAt = null
|
||||
@@ -102,9 +106,21 @@ class MediaProgress {
|
||||
this.startedAt = Date.now()
|
||||
}
|
||||
if (hasUpdates) {
|
||||
if (payload.hideFromContinueListening === undefined) {
|
||||
// Reset this flag when the media progress is updated
|
||||
this.hideFromContinueListening = false
|
||||
}
|
||||
|
||||
this.lastUpdate = Date.now()
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
removeFromContinueListening() {
|
||||
if (this.hideFromContinueListening) return false
|
||||
|
||||
this.hideFromContinueListening = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
module.exports = MediaProgress
|
||||
@@ -15,6 +15,7 @@ class User {
|
||||
this.createdAt = null
|
||||
|
||||
this.mediaProgress = []
|
||||
this.seriesHideFromContinueListening = [] // Series IDs that should not show on home page continue listening
|
||||
this.bookmarks = []
|
||||
|
||||
this.settings = {}
|
||||
@@ -92,6 +93,7 @@ class User {
|
||||
type: this.type,
|
||||
token: this.token,
|
||||
mediaProgress: this.mediaProgress ? this.mediaProgress.map(li => li.toJSON()) : [],
|
||||
seriesHideFromContinueListening: [...this.seriesHideFromContinueListening],
|
||||
bookmarks: this.bookmarks ? this.bookmarks.map(b => b.toJSON()) : [],
|
||||
isActive: this.isActive,
|
||||
isLocked: this.isLocked,
|
||||
@@ -111,6 +113,7 @@ class User {
|
||||
type: this.type,
|
||||
token: this.token,
|
||||
mediaProgress: this.mediaProgress ? this.mediaProgress.map(li => li.toJSON()) : [],
|
||||
seriesHideFromContinueListening: [...this.seriesHideFromContinueListening],
|
||||
bookmarks: this.bookmarks ? this.bookmarks.map(b => b.toJSON()) : [],
|
||||
isActive: this.isActive,
|
||||
isLocked: this.isLocked,
|
||||
@@ -161,6 +164,9 @@ class User {
|
||||
this.bookmarks = user.bookmarks.filter(bm => typeof bm.libraryItemId == 'string').map(bm => new AudioBookmark(bm))
|
||||
}
|
||||
|
||||
this.seriesHideFromContinueListening = []
|
||||
if (user.seriesHideFromContinueListening) this.seriesHideFromContinueListening = [...user.seriesHideFromContinueListening]
|
||||
|
||||
this.isActive = (user.isActive === undefined || user.type === 'root') ? true : !!user.isActive
|
||||
this.isLocked = user.type === 'root' ? false : !!user.isLocked
|
||||
this.lastSeen = user.lastSeen || null
|
||||
@@ -196,6 +202,13 @@ class User {
|
||||
}
|
||||
})
|
||||
|
||||
if (payload.seriesHideFromContinueListening && Array.isArray(payload.seriesHideFromContinueListening)) {
|
||||
if (this.seriesHideFromContinueListening.join(',') !== payload.seriesHideFromContinueListening.join(',')) {
|
||||
hasUpdates = true
|
||||
this.seriesHideFromContinueListening = [...payload.seriesHideFromContinueListening]
|
||||
}
|
||||
}
|
||||
|
||||
// And update permissions
|
||||
if (payload.permissions) {
|
||||
for (const key in payload.permissions) {
|
||||
@@ -297,7 +310,13 @@ class User {
|
||||
return wasUpdated
|
||||
}
|
||||
|
||||
removeMediaProgress(libraryItemId) {
|
||||
removeMediaProgress(id) {
|
||||
if (!this.mediaProgress.some(mp => mp.id === id)) return false
|
||||
this.mediaProgress = this.mediaProgress.filter(mp => mp.id !== id)
|
||||
return true
|
||||
}
|
||||
|
||||
removeMediaProgressForLibraryItem(libraryItemId) {
|
||||
if (!this.mediaProgress.some(lip => lip.libraryItemId == libraryItemId)) return false
|
||||
this.mediaProgress = this.mediaProgress.filter(lip => lip.libraryItemId != libraryItemId)
|
||||
return true
|
||||
@@ -378,5 +397,21 @@ class User {
|
||||
removeBookmark(libraryItemId, time) {
|
||||
this.bookmarks = this.bookmarks.filter(bm => (bm.libraryItemId !== libraryItemId || bm.time !== time))
|
||||
}
|
||||
|
||||
checkShouldHideSeriesFromContinueListening(seriesId) {
|
||||
return this.seriesHideFromContinueListening.includes(seriesId)
|
||||
}
|
||||
|
||||
addSeriesToHideFromContinueListening(seriesId) {
|
||||
if (this.seriesHideFromContinueListening.includes(seriesId)) return false
|
||||
this.seriesHideFromContinueListening.push(seriesId)
|
||||
return true
|
||||
}
|
||||
|
||||
removeProgressFromContinueListening(progressId) {
|
||||
const progress = this.mediaProgress.find(mp => mp.id === progressId)
|
||||
if (!progress) return false
|
||||
return progress.removeFromContinueListening()
|
||||
}
|
||||
}
|
||||
module.exports = User
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user