mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-02 00:40:39 +02:00
Compare commits
133 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fdece944f4 | |||
| d7952dab04 | |||
| bec599f325 | |||
| affcc03c61 | |||
| db18c71857 | |||
| dcc223949a | |||
| 6a6d384d88 | |||
| cd57667444 | |||
| 3900db14d3 | |||
| 1fa94cbfad | |||
| 793233e782 | |||
| 94012e5dff | |||
| d440a9fd6a | |||
| 928c6cf5b3 | |||
| 23a25d420c | |||
| dc779a3fc5 | |||
| 876badbeea | |||
| 8563bdde74 | |||
| 803c9699ef | |||
| c254dc5144 | |||
| d22b475539 | |||
| 142205f060 | |||
| 02d997897c | |||
| 39979ff8a3 | |||
| 441b8c5bb7 | |||
| d456ec2786 | |||
| a729ce1512 | |||
| 3949896d88 | |||
| 14e5e11344 | |||
| c23f31216a | |||
| cd04533eea | |||
| 6701551289 | |||
| 1a4833f873 | |||
| 3a7639f690 | |||
| 63c55f08dc | |||
| 98e79f144c | |||
| 3b9236a7ce | |||
| ac30a971c5 | |||
| 9ee6eaade9 | |||
| 8c32fed911 | |||
| f36a5eae6d | |||
| b7bdaac163 | |||
| 162a1b7971 | |||
| 97da73baf3 | |||
| b6e3559aba | |||
| 39a13e3610 | |||
| 7aa89f16c9 | |||
| 88726bed86 | |||
| a35b35c062 | |||
| 951afaa568 | |||
| 5e8979876f | |||
| eb0ef8c696 | |||
| 066b6c13c6 | |||
| 014ad668a5 | |||
| 62c59c634c | |||
| f3f2d614b1 | |||
| 7fd70c1c86 | |||
| 46a3974b79 | |||
| f851cde1f4 | |||
| 0f772fd3cf | |||
| dd0d2e9f55 | |||
| 022c506eda | |||
| dd8577354b | |||
| 3e7a76574b | |||
| 0ef2a2e4b6 | |||
| 8e8046541e | |||
| 2d6f9bab8b | |||
| 11e3cf4f19 | |||
| 37a3fdb606 | |||
| 9983fe7d66 | |||
| 731cf8e4ed | |||
| c3f2e606dd | |||
| dbb62069ef | |||
| b08ad8785e | |||
| ff04eb8d5e | |||
| 9a7503cde2 | |||
| 7d4e7ce2c0 | |||
| 565bb4cd6b | |||
| be592a04d0 | |||
| ae4ac392c6 | |||
| f6b6c0a41e | |||
| 83e4a8f4ed | |||
| 70ef09f451 | |||
| b91b320006 | |||
| d139fffa96 | |||
| 845fc0794e | |||
| ac6c885878 | |||
| b2b5111c50 | |||
| e11629a161 | |||
| ff2fb2b2ba | |||
| b9a9c0e717 | |||
| c16e6d19ae | |||
| 0e98620939 | |||
| e32f51f58a | |||
| 1ec12a547e | |||
| baedced83f | |||
| 174decf8da | |||
| 0700f12896 | |||
| 3dc848a106 | |||
| c17612a233 | |||
| 7313d151f8 | |||
| 97dc9fbccf | |||
| 9a87e4af73 | |||
| 4ccb4243f7 | |||
| eb25ca7af5 | |||
| 872d5178e6 | |||
| d11501b2c6 | |||
| 7e05804bcf | |||
| a73b72a07b | |||
| 8ec4bd4279 | |||
| e362456895 | |||
| 8cd7de25ad | |||
| 99ea7866c5 | |||
| 3194b4cd87 | |||
| 149f52b33c | |||
| 575ec9d00b | |||
| 40e999fcae | |||
| ac57b2b867 | |||
| 3cafa87eda | |||
| dee4ca3559 | |||
| 772c7b3217 | |||
| c0dd58a94e | |||
| 91e116969a | |||
| 1f37e32f91 | |||
| 221061ea30 | |||
| 1e8e45431d | |||
| 381a81e4bb | |||
| be28b9899e | |||
| 37ca139195 | |||
| 6b02779e0f | |||
| ff6d95dc4d | |||
| e611d7a8fd | |||
| 67f6cd3c56 |
@@ -11,7 +11,6 @@ test/
|
|||||||
/client/.nuxt/
|
/client/.nuxt/
|
||||||
/client/dist/
|
/client/dist/
|
||||||
/dist/
|
/dist/
|
||||||
library/
|
|
||||||
|
|
||||||
sw.*
|
sw.*
|
||||||
.DS_STORE
|
.DS_STORE
|
||||||
|
|||||||
Vendored
+20
@@ -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
|
RUN npm run generate
|
||||||
|
|
||||||
### STAGE 1: Build server ###
|
### STAGE 1: Build server ###
|
||||||
|
FROM sandreas/tone:v0.1.1 AS tone
|
||||||
FROM node:16-alpine
|
FROM node:16-alpine
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
RUN apk update && \
|
RUN apk update && \
|
||||||
apk add --no-cache --update \
|
apk add --no-cache --update \
|
||||||
@@ -14,6 +16,7 @@ RUN apk update && \
|
|||||||
tzdata \
|
tzdata \
|
||||||
ffmpeg
|
ffmpeg
|
||||||
|
|
||||||
|
COPY --from=tone /usr/local/bin/tone /usr/local/bin/
|
||||||
COPY --from=build /client/dist /client/dist
|
COPY --from=build /client/dist /client/dist
|
||||||
COPY index.js package* /
|
COPY index.js package* /
|
||||||
COPY server server
|
COPY server server
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ set -o pipefail
|
|||||||
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg"
|
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg"
|
||||||
DEFAULT_DATA_DIR="/usr/share/audiobookshelf"
|
DEFAULT_DATA_DIR="/usr/share/audiobookshelf"
|
||||||
CONFIG_PATH="/etc/default/audiobookshelf"
|
CONFIG_PATH="/etc/default/audiobookshelf"
|
||||||
DEFAULT_PORT=7331
|
DEFAULT_PORT=13378
|
||||||
DEFAULT_HOST="0.0.0.0"
|
DEFAULT_HOST="0.0.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
add_user() {
|
add_user() {
|
||||||
: "${1:?'User was not defined'}"
|
: "${1:?'User was not defined'}"
|
||||||
declare -r user="$1"
|
declare -r user="$1"
|
||||||
@@ -52,6 +50,7 @@ install_ffmpeg() {
|
|||||||
echo "Starting FFMPEG Install"
|
echo "Starting FFMPEG Install"
|
||||||
|
|
||||||
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz"
|
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz"
|
||||||
|
WGET_TONE="wget https://github.com/sandreas/tone/releases/download/v0.1.1/tone-0.1.1-linux-x64.tar.gz"
|
||||||
|
|
||||||
if ! cd "$FFMPEG_INSTALL_DIR"; then
|
if ! cd "$FFMPEG_INSTALL_DIR"; then
|
||||||
echo "Creating ffmpeg install dir at $FFMPEG_INSTALL_DIR"
|
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
|
tar xvf ffmpeg-git-amd64-static.tar.xz --strip-components=1
|
||||||
rm ffmpeg-git-amd64-static.tar.xz
|
rm ffmpeg-git-amd64-static.tar.xz
|
||||||
|
|
||||||
echo "Good to go on Ffmpeg... hopefully"
|
# Temp downloading tone library to the ffmpeg dir
|
||||||
|
echo "Getting tone.."
|
||||||
|
$WGET_TONE
|
||||||
|
tar xvf tone-0.1.1-linux-x64.tar.gz --strip-components=1
|
||||||
|
rm tone-0.1.1-linux-x64.tar.gz
|
||||||
|
|
||||||
|
echo "Good to go on Ffmpeg (& tone)... hopefully"
|
||||||
}
|
}
|
||||||
|
|
||||||
setup_config() {
|
setup_config() {
|
||||||
if [ -f "$CONFIG_PATH" ]; then
|
if [ -f "$CONFIG_PATH" ]; then
|
||||||
echo "Existing config found."
|
echo "Existing config found."
|
||||||
cat $CONFIG_PATH
|
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
|
else
|
||||||
|
|
||||||
if [ ! -d "$DEFAULT_DATA_DIR" ]; then
|
if [ ! -d "$DEFAULT_DATA_DIR" ]; then
|
||||||
@@ -83,11 +95,12 @@ setup_config() {
|
|||||||
echo "Creating default config."
|
echo "Creating default config."
|
||||||
|
|
||||||
config_text="METADATA_PATH=$DEFAULT_DATA_DIR/metadata
|
config_text="METADATA_PATH=$DEFAULT_DATA_DIR/metadata
|
||||||
CONFIG_PATH=$DEFAULT_DATA_DIR/config
|
CONFIG_PATH=$DEFAULT_DATA_DIR/config
|
||||||
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
|
FFMPEG_PATH=$FFMPEG_INSTALL_DIR/ffmpeg
|
||||||
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
|
FFPROBE_PATH=$FFMPEG_INSTALL_DIR/ffprobe
|
||||||
PORT=$DEFAULT_PORT
|
TONE_PATH=$FFMPEG_INSTALL_DIR/tone
|
||||||
HOST=$DEFAULT_HOST"
|
PORT=$DEFAULT_PORT
|
||||||
|
HOST=$DEFAULT_HOST"
|
||||||
|
|
||||||
echo "$config_text"
|
echo "$config_text"
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ ExecStart=/usr/share/audiobookshelf/audiobookshelf
|
|||||||
ExecReload=/bin/kill -HUP $MAINPID
|
ExecReload=/bin/kill -HUP $MAINPID
|
||||||
Restart=always
|
Restart=always
|
||||||
User=audiobookshelf
|
User=audiobookshelf
|
||||||
|
Group=audiobookshelf
|
||||||
PermissionsStartOnly=true
|
PermissionsStartOnly=true
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
@@ -22,6 +22,10 @@
|
|||||||
#bookshelf {
|
#bookshelf {
|
||||||
height: calc(100% - 40px);
|
height: calc(100% - 40px);
|
||||||
background-image: linear-gradient(to right bottom, #2e2e2e, #303030, #313131, #333333, #353535, #343434, #323232, #313131, #2c2c2c, #282828, #232323, #1f1f1f);
|
background-image: linear-gradient(to right bottom, #2e2e2e, #303030, #313131, #333333, #353535, #343434, #323232, #313131, #2c2c2c, #282828, #232323, #1f1f1f);
|
||||||
|
|
||||||
|
/* For Firefox */
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #855620 rgba(0, 0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bookshelf-row {
|
.bookshelf-row {
|
||||||
|
|||||||
+31
-31
@@ -2,14 +2,14 @@
|
|||||||
font-family: 'Material Icons';
|
font-family: 'Material Icons';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
src: url(/fonts/MaterialIcons.woff2) format('woff2');
|
src: url(~static/fonts/MaterialIcons.woff2) format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Material Icons Outlined';
|
font-family: 'Material Icons Outlined';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
src: url(/fonts/MaterialIconsOutlined.woff2) format('woff2');
|
src: url(~static/fonts/MaterialIconsOutlined.woff2) format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
.material-icons {
|
.material-icons {
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
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;
|
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-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
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;
|
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-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
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;
|
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-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
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;
|
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
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;
|
unicode-range: U+1F00-1FFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
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;
|
unicode-range: U+0370-03FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
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;
|
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-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
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;
|
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-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
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;
|
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-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
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;
|
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-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
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;
|
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
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;
|
unicode-range: U+1F00-1FFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
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;
|
unicode-range: U+0370-03FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +184,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
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;
|
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-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
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;
|
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-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
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;
|
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-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-display: swap;
|
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;
|
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-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-display: swap;
|
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;
|
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +234,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-display: swap;
|
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;
|
unicode-range: U+1F00-1FFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +244,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-display: swap;
|
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;
|
unicode-range: U+0370-03FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +254,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-display: swap;
|
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;
|
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-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-display: swap;
|
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;
|
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-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-display: swap;
|
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;
|
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-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
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;
|
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-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
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;
|
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +304,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
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;
|
unicode-range: U+1F00-1FFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,7 +314,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
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;
|
unicode-range: U+0370-03FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +324,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
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;
|
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-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
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;
|
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 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">
|
<div class="flex h-full items-center">
|
||||||
<nuxt-link to="/">
|
<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>
|
||||||
|
|
||||||
<nuxt-link to="/">
|
<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>
|
</nuxt-link>
|
||||||
|
|
||||||
<ui-libraries-dropdown class="mr-2" />
|
<ui-libraries-dropdown class="mr-2" />
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<controls-global-search v-if="currentLibrary" class="mr-1 sm:mr-0" />
|
<controls-global-search v-if="currentLibrary" class="mr-1 sm:mr-0" />
|
||||||
<div class="flex-grow" />
|
<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">
|
<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>
|
<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">
|
<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>
|
<h1 class="text-2xl px-4">{{ numLibraryItemsSelected }} Selected</h1>
|
||||||
<div class="flex-grow" />
|
<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-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-read-icon-btn :disabled="processingBatch" :is-read="selectedIsFinished" @click="toggleBatchRead" class="mx-1.5" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
@@ -158,7 +161,7 @@ export default {
|
|||||||
var newIsFinished = !this.selectedIsFinished
|
var newIsFinished = !this.selectedIsFinished
|
||||||
var updateProgressPayloads = this.selectedLibraryItems.map((lid) => {
|
var updateProgressPayloads = this.selectedLibraryItems.map((lid) => {
|
||||||
return {
|
return {
|
||||||
id: lid,
|
libraryItemId: lid,
|
||||||
isFinished: newIsFinished
|
isFinished: newIsFinished
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -210,6 +213,9 @@ export default {
|
|||||||
},
|
},
|
||||||
setBookshelfTotalEntities(totalEntities) {
|
setBookshelfTotalEntities(totalEntities) {
|
||||||
this.totalEntities = totalEntities
|
this.totalEntities = totalEntities
|
||||||
|
},
|
||||||
|
batchAutoMatchClick() {
|
||||||
|
this.$store.commit('globals/setShowBatchQuickMatchModal', true)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
@@ -16,10 +16,10 @@
|
|||||||
<!-- Alternate plain view -->
|
<!-- Alternate plain view -->
|
||||||
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24">
|
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24">
|
||||||
<template v-for="(shelf, index) in shelves">
|
<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>
|
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ shelf.label }}</p>
|
||||||
</widgets-item-slider>
|
</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>
|
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ shelf.label }}</p>
|
||||||
</widgets-episode-slider>
|
</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">
|
<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 -->
|
<!-- Regular bookshelf view -->
|
||||||
<div v-else class="w-full">
|
<div v-else class="w-full">
|
||||||
<template v-for="(shelf, index) in shelves">
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,7 +175,6 @@ export default {
|
|||||||
}
|
}
|
||||||
this.shelves = shelves
|
this.shelves = shelves
|
||||||
},
|
},
|
||||||
settingsUpdated(settings) {},
|
|
||||||
scan() {
|
scan() {
|
||||||
this.$store
|
this.$store
|
||||||
.dispatch('libraries/requestLibraryScan', { libraryId: this.$store.state.libraries.currentLibraryId })
|
.dispatch('libraries/requestLibraryScan', { libraryId: this.$store.state.libraries.currentLibraryId })
|
||||||
@@ -187,6 +186,15 @@ export default {
|
|||||||
this.$toast.error('Failed to start scan')
|
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) {
|
libraryItemAdded(libraryItem) {
|
||||||
console.log('libraryItem added', libraryItem)
|
console.log('libraryItem added', libraryItem)
|
||||||
// TODO: Check if libraryItem would be on this shelf
|
// TODO: Check if libraryItem would be on this shelf
|
||||||
@@ -244,6 +252,45 @@ export default {
|
|||||||
this.libraryItemUpdated(li)
|
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) {
|
authorUpdated(author) {
|
||||||
this.shelves.forEach((shelf) => {
|
this.shelves.forEach((shelf) => {
|
||||||
if (shelf.type == 'authors') {
|
if (shelf.type == 'authors') {
|
||||||
@@ -267,9 +314,8 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
initListeners() {
|
initListeners() {
|
||||||
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
|
|
||||||
|
|
||||||
if (this.$root.socket) {
|
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_updated', this.authorUpdated)
|
||||||
this.$root.socket.on('author_removed', this.authorRemoved)
|
this.$root.socket.on('author_removed', this.authorRemoved)
|
||||||
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
||||||
@@ -285,6 +331,7 @@ export default {
|
|||||||
this.$store.commit('user/removeSettingsListener', 'bookshelf')
|
this.$store.commit('user/removeSettingsListener', 'bookshelf')
|
||||||
|
|
||||||
if (this.$root.socket) {
|
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_updated', this.authorUpdated)
|
||||||
this.$root.socket.off('author_removed', this.authorRemoved)
|
this.$root.socket.off('author_removed', this.authorRemoved)
|
||||||
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
||||||
|
|||||||
@@ -4,12 +4,26 @@
|
|||||||
<div class="w-full h-full pt-6">
|
<div class="w-full h-full pt-6">
|
||||||
<div v-if="shelf.type === 'book' || shelf.type === 'podcast'" class="flex items-center">
|
<div v-if="shelf.type === 'book' || shelf.type === 'podcast'" class="flex items-center">
|
||||||
<template v-for="(entity, index) in shelf.entities">
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="shelf.type === 'episode'" class="flex items-center">
|
<div v-if="shelf.type === 'episode'" class="flex items-center">
|
||||||
<template v-for="(entity, index) in shelf.entities">
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="shelf.type === 'series'" class="flex items-center">
|
<div v-if="shelf.type === 'series'" class="flex items-center">
|
||||||
@@ -17,6 +31,11 @@
|
|||||||
<cards-lazy-series-card :key="entity.name" :series-mount="entity" :height="bookCoverHeight" :width="bookCoverWidth * 2" :book-cover-aspect-ratio="bookCoverAspectRatio" class="relative mx-2" @hook:updated="updatedBookCard" />
|
<cards-lazy-series-card :key="entity.name" :series-mount="entity" :height="bookCoverHeight" :width="bookCoverWidth * 2" :book-cover-aspect-ratio="bookCoverAspectRatio" class="relative mx-2" @hook:updated="updatedBookCard" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="shelf.type === 'tags'" class="flex items-center">
|
||||||
|
<template v-for="entity in shelf.entities">
|
||||||
|
<cards-group-card :key="entity.name" :group="entity" :height="bookCoverHeight" :width="bookCoverWidth * 2" :book-cover-aspect-ratio="bookCoverAspectRatio" class="relative mx-2" @hook:updated="updatedBookCard" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
<div v-if="shelf.type === 'authors'" class="flex items-center">
|
<div v-if="shelf.type === 'authors'" class="flex items-center">
|
||||||
<template v-for="entity in shelf.entities">
|
<template v-for="entity in shelf.entities">
|
||||||
<cards-author-card :key="entity.id" :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" />
|
<cards-author-card :key="entity.id" :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" />
|
||||||
@@ -52,7 +71,8 @@ export default {
|
|||||||
},
|
},
|
||||||
sizeMultiplier: Number,
|
sizeMultiplier: Number,
|
||||||
bookCoverWidth: Number,
|
bookCoverWidth: Number,
|
||||||
bookCoverAspectRatio: Number
|
bookCoverAspectRatio: Number,
|
||||||
|
continueListeningShelf: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -18,13 +18,10 @@
|
|||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</div>
|
</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">
|
<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>
|
<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 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">
|
<p class="pl-2 font-book text-lg">
|
||||||
<span class="material-icons text-2xl text-white">west</span>
|
|
||||||
</div>
|
|
||||||
<p class="pl-4 font-book text-lg">
|
|
||||||
{{ seriesName }}
|
{{ seriesName }}
|
||||||
</p>
|
</p>
|
||||||
<div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</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>
|
||||||
<div class="flex-grow hidden sm:inline-block" />
|
<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>
|
<ui-btn v-if="isIssuesFilter && userCanDelete" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">Remove All {{ numShowing }} {{ entityName }}</ui-btn>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="page === 'search'">
|
<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" />
|
<div class="flex-grow" />
|
||||||
<p>Search results for "{{ searchQuery }}"</p>
|
<p>Search results for "{{ searchQuery }}"</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
@@ -215,6 +209,7 @@ export default {
|
|||||||
this.$toast.success('Removed library items with issues')
|
this.$toast.success('Removed library items with issues')
|
||||||
this.$router.push(`/library/${this.currentLibraryId}/bookshelf`)
|
this.$router.push(`/library/${this.currentLibraryId}/bookshelf`)
|
||||||
this.processingIssues = false
|
this.processingIssues = false
|
||||||
|
this.$store.dispatch('libraries/fetch', this.currentLibraryId)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to remove library items with issues', error)
|
console.error('Failed to remove library items with issues', error)
|
||||||
@@ -228,7 +223,7 @@ export default {
|
|||||||
this.processingSeries = true
|
this.processingSeries = true
|
||||||
var updateProgressPayloads = this.seriesLibraryItemIds.map((lid) => {
|
var updateProgressPayloads = this.seriesLibraryItemIds.map((lid) => {
|
||||||
return {
|
return {
|
||||||
id: lid,
|
libraryItemId: lid,
|
||||||
isFinished: newIsFinished
|
isFinished: newIsFinished
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -246,12 +241,6 @@ export default {
|
|||||||
this.processingSeries = false
|
this.processingSeries = false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
searchBackArrow() {
|
|
||||||
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`)
|
|
||||||
},
|
|
||||||
seriesBackArrow() {
|
|
||||||
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf/series`)
|
|
||||||
},
|
|
||||||
updateOrder() {
|
updateOrder() {
|
||||||
this.saveSettings()
|
this.saveSettings()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -82,6 +82,11 @@ export default {
|
|||||||
id: 'config-log',
|
id: 'config-log',
|
||||||
title: 'Logs',
|
title: 'Logs',
|
||||||
path: '/config/log'
|
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" />
|
<div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</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'">
|
<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">
|
<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" />
|
<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>
|
<p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -123,6 +131,9 @@ export default {
|
|||||||
isPodcastSearchPage() {
|
isPodcastSearchPage() {
|
||||||
return this.$route.name === 'library-library-podcast-search'
|
return this.$route.name === 'library-library-podcast-search'
|
||||||
},
|
},
|
||||||
|
isPodcastLatestPage() {
|
||||||
|
return this.$route.name === 'library-library-podcast-latest'
|
||||||
|
},
|
||||||
homePage() {
|
homePage() {
|
||||||
return this.$route.name === 'library-library'
|
return this.$route.name === 'library-library'
|
||||||
},
|
},
|
||||||
@@ -165,7 +176,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
clickChangelog(){
|
clickChangelog() {
|
||||||
this.showChangelogModal = true
|
this.showChangelogModal = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -44,11 +44,14 @@
|
|||||||
@close="closePlayer"
|
@close="closePlayer"
|
||||||
@showBookmarks="showBookmarks"
|
@showBookmarks="showBookmarks"
|
||||||
@showSleepTimer="showSleepTimerModal = true"
|
@showSleepTimer="showSleepTimerModal = true"
|
||||||
|
@showPlayerQueueItems="showPlayerQueueItemsModal = true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
|
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
|
||||||
|
|
||||||
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-time="sleepTimerTime" :remaining="sleepTimerRemaining" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-time="sleepTimerTime" :remaining="sleepTimerRemaining" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
||||||
|
|
||||||
|
<modals-player-queue-items-modal v-model="showPlayerQueueItemsModal" :library-item-id="libraryItemId" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -66,6 +69,7 @@ export default {
|
|||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
showSleepTimerModal: false,
|
showSleepTimerModal: false,
|
||||||
|
showPlayerQueueItemsModal: false,
|
||||||
sleepTimerSet: false,
|
sleepTimerSet: false,
|
||||||
sleepTimerTime: 0,
|
sleepTimerTime: 0,
|
||||||
sleepTimerRemaining: 0,
|
sleepTimerRemaining: 0,
|
||||||
@@ -138,9 +142,39 @@ export default {
|
|||||||
podcastAuthor() {
|
podcastAuthor() {
|
||||||
if (!this.isPodcast) return null
|
if (!this.isPodcast) return null
|
||||||
return this.mediaMetadata.author || 'Unknown'
|
return this.mediaMetadata.author || 'Unknown'
|
||||||
|
},
|
||||||
|
playerQueueItems() {
|
||||||
|
return this.$store.state.playerQueueItems || []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
mediaFinished(libraryItemId, episodeId) {
|
||||||
|
// Play next item in queue
|
||||||
|
if (!this.playerQueueItems.length || !this.$store.state.playerQueueAutoPlay) {
|
||||||
|
// TODO: Set media finished flag so play button will play next queue item
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var currentQueueIndex = this.playerQueueItems.findIndex((i) => {
|
||||||
|
if (episodeId) return i.libraryItemId === libraryItemId && i.episodeId === episodeId
|
||||||
|
return i.libraryItemId === libraryItemId
|
||||||
|
})
|
||||||
|
if (currentQueueIndex < 0) {
|
||||||
|
console.error('Media finished not found in queue', this.playerQueueItems)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (currentQueueIndex === this.playerQueueItems.length - 1) {
|
||||||
|
console.log('Finished last item in queue')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const nextItemInQueue = this.playerQueueItems[currentQueueIndex + 1]
|
||||||
|
if (nextItemInQueue) {
|
||||||
|
this.playLibraryItem({
|
||||||
|
libraryItemId: nextItemInQueue.libraryItemId,
|
||||||
|
episodeId: nextItemInQueue.episodeId || null,
|
||||||
|
queueItems: this.playerQueueItems
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
setPlaying(isPlaying) {
|
setPlaying(isPlaying) {
|
||||||
this.isPlaying = isPlaying
|
this.isPlaying = isPlaying
|
||||||
this.$store.commit('setIsPlaying', isPlaying)
|
this.$store.commit('setIsPlaying', isPlaying)
|
||||||
@@ -313,6 +347,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
sessionOpen(session) {
|
sessionOpen(session) {
|
||||||
|
// For opening session on init (temporarily unused)
|
||||||
this.$store.commit('setMediaPlaying', {
|
this.$store.commit('setMediaPlaying', {
|
||||||
libraryItem: session.libraryItem,
|
libraryItem: session.libraryItem,
|
||||||
episodeId: session.episodeId
|
episodeId: session.episodeId
|
||||||
@@ -376,7 +411,8 @@ export default {
|
|||||||
if (!libraryItem) return
|
if (!libraryItem) return
|
||||||
this.$store.commit('setMediaPlaying', {
|
this.$store.commit('setMediaPlaying', {
|
||||||
libraryItem,
|
libraryItem,
|
||||||
episodeId
|
episodeId,
|
||||||
|
queueItems: payload.queueItems || []
|
||||||
})
|
})
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
|
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
|
||||||
|
|||||||
@@ -93,12 +93,12 @@ export default {
|
|||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
if (!response) {
|
if (!response) {
|
||||||
this.$toast.error('Author not found')
|
this.$toast.error(`Author ${this.name} not found`)
|
||||||
} else if (response.updated) {
|
} else if (response.updated) {
|
||||||
if (response.author.imagePath) this.$toast.success('Author was updated')
|
if (response.author.imagePath) this.$toast.success(`Author ${response.author.name} was updated`)
|
||||||
else this.$toast.success('Author was updated (no image found)')
|
else this.$toast.success(`Author ${response.author.name} was updated (no image found)`)
|
||||||
} else {
|
} 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
|
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="min-w-12 max-w-12 md:min-w-20 md:max-w-20">
|
||||||
<div class="w-full bg-primary">
|
<div class="w-full bg-primary">
|
||||||
<img v-if="selectedCover" :src="selectedCover" class="h-full w-full object-contain" />
|
<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>
|
</div>
|
||||||
<div v-if="!isPodcast" class="px-2 md:px-4 flex-grow">
|
<div v-if="!isPodcast" class="px-2 md:px-4 flex-grow">
|
||||||
@@ -12,13 +13,13 @@
|
|||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<p class="text-sm md:text-base">{{ book.publishedYear }}</p>
|
<p class="text-sm md:text-base">{{ book.publishedYear }}</p>
|
||||||
</div>
|
</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.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>
|
<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-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">
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,26 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="rounded-sm h-full relative" :style="{ padding: `0px ${paddingX}px` }" @mouseover="mouseoverCard" @mouseleave="mouseleaveCard" @click="clickCard">
|
<div class="rounded-sm h-full relative" :style="{ width: width + 'px', height: height + 'px' }" @mouseover="mouseoverCard" @mouseleave="mouseleaveCard" @click="clickCard">
|
||||||
<nuxt-link :to="groupTo" class="cursor-pointer">
|
<nuxt-link :to="groupTo" class="cursor-pointer">
|
||||||
<div class="w-full h-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'" :style="{ height: coverHeight + 'px', width: coverWidth + 'px' }">
|
<div class="w-full h-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'">
|
||||||
<covers-group-cover ref="groupcover" :id="seriesId" :name="groupName" :group-to="groupTo" :type="groupType" :book-items="bookItems" :width="coverWidth" :height="coverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-group-cover ref="groupcover" :id="groupEncode" :name="groupName" :type="groupType" :book-items="bookItems" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
|
|
||||||
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity z-30" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
|
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity z-30" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
|
||||||
<p class="font-book" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ groupName }}</p>
|
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ groupName }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute top-2 right-2 w-7 h-7 rounded-lg bg-black bg-opacity-90 text-gray-300 box-shadow-book flex items-center justify-center border border-white border-opacity-25 pointer-events-none z-40">
|
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ bookItems.length }}</div>
|
||||||
<p class="font-book text-xl">{{ bookItems.length }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!isCategorized" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto bottom-0 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, coverWidth) + 'px' }">
|
|
||||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${1 * sizeMultiplier}rem` }">
|
|
||||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ groupName }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -31,11 +23,8 @@ export default {
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => null
|
default: () => null
|
||||||
},
|
},
|
||||||
width: {
|
width: Number,
|
||||||
type: Number,
|
height: Number,
|
||||||
default: 120
|
|
||||||
},
|
|
||||||
isCategorized: Boolean,
|
|
||||||
bookCoverAspectRatio: Number
|
bookCoverAspectRatio: Number
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -43,23 +32,7 @@ export default {
|
|||||||
isHovering: false
|
isHovering: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
|
||||||
width(newVal) {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
if (this.$refs.groupcover) {
|
|
||||||
this.$refs.groupcover.init()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
computed: {
|
||||||
seriesId() {
|
|
||||||
return this.groupEncode
|
|
||||||
},
|
|
||||||
labelFontSize() {
|
|
||||||
if (this.coverWidth < 160) return 0.75
|
|
||||||
return 0.875
|
|
||||||
},
|
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
@@ -70,29 +43,11 @@ export default {
|
|||||||
return this._group.type
|
return this._group.type
|
||||||
},
|
},
|
||||||
groupTo() {
|
groupTo() {
|
||||||
if (this.groupType === 'series') {
|
return `/library/${this.currentLibraryId}/bookshelf?filter=${this.filter}`
|
||||||
return `/library/${this.currentLibraryId}/series/${this._group.id}`
|
|
||||||
} else if (this.groupType === 'collection') {
|
|
||||||
return `/collection/${this._group.id}`
|
|
||||||
} else {
|
|
||||||
return `/library/${this.currentLibraryId}/bookshelf?filter=tags.${this.groupEncode}`
|
|
||||||
}
|
|
||||||
},
|
|
||||||
squareAspectRatio() {
|
|
||||||
return this.bookCoverAspectRatio === 1
|
|
||||||
},
|
|
||||||
coverWidth() {
|
|
||||||
return this.width * 2
|
|
||||||
},
|
|
||||||
coverHeight() {
|
|
||||||
return this.width * this.bookCoverAspectRatio
|
|
||||||
},
|
},
|
||||||
sizeMultiplier() {
|
sizeMultiplier() {
|
||||||
var baseSize = this.squareAspectRatio ? 192 : 120
|
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
||||||
return this.width / baseSize
|
return this.width / 240
|
||||||
},
|
|
||||||
paddingX() {
|
|
||||||
return 16 * this.sizeMultiplier
|
|
||||||
},
|
},
|
||||||
bookItems() {
|
bookItems() {
|
||||||
return this._group.books || []
|
return this._group.books || []
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
<div v-if="!booksInSeries && (!isPodcast || episodeProgress)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
<div v-if="!booksInSeries && (!isPodcast || episodeProgress)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||||
|
|
||||||
<!-- Overlay is not shown if collapsing series in library -->
|
<!-- Overlay is not shown if collapsing series in library -->
|
||||||
<div v-show="!booksInSeries && libraryItem && (isHovering || isSelectionMode || isMoreMenuOpen)" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded hidden md:block" :class="overlayWrapperClasslist">
|
<div v-show="!booksInSeries && libraryItem && (isHovering || isSelectionMode || isMoreMenuOpen) && !processing" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded hidden md:block" :class="overlayWrapperClasslist">
|
||||||
<div v-show="showPlayButton" class="h-full flex items-center justify-center pointer-events-none">
|
<div v-show="showPlayButton" class="h-full flex items-center justify-center pointer-events-none">
|
||||||
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="play">
|
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="play">
|
||||||
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
|
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
|
||||||
@@ -66,6 +66,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Processing/loading spinner overlay -->
|
||||||
|
<div v-if="processing" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 rounded flex items-center justify-center">
|
||||||
|
<widgets-loading-spinner size="la-lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Series name overlay -->
|
<!-- Series name overlay -->
|
||||||
<div v-if="booksInSeries && libraryItem && isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-60 rounded flex items-center justify-center" :style="{ padding: sizeMultiplier + 'rem' }">
|
<div v-if="booksInSeries && libraryItem && isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-60 rounded flex items-center justify-center" :style="{ padding: sizeMultiplier + 'rem' }">
|
||||||
<p class="text-gray-200 text-center" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">{{ series }}</p>
|
<p class="text-gray-200 text-center" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">{{ series }}</p>
|
||||||
@@ -88,7 +93,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Podcast Episode # -->
|
<!-- Podcast Episode # -->
|
||||||
<div v-if="recentEpisodeNumber && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
<div v-if="recentEpisodeNumber && !isHovering && !isSelectionMode && !processing" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">Episode #{{ recentEpisodeNumber }}</p>
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">Episode #{{ recentEpisodeNumber }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -123,16 +128,16 @@ export default {
|
|||||||
},
|
},
|
||||||
orderBy: String,
|
orderBy: String,
|
||||||
filterBy: String,
|
filterBy: String,
|
||||||
sortingIgnorePrefix: Boolean
|
sortingIgnorePrefix: Boolean,
|
||||||
|
continueListeningShelf: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isHovering: false,
|
isHovering: false,
|
||||||
isMoreMenuOpen: false,
|
isMoreMenuOpen: false,
|
||||||
isProcessingReadUpdate: false,
|
processing: false,
|
||||||
libraryItem: null,
|
libraryItem: null,
|
||||||
imageReady: false,
|
imageReady: false,
|
||||||
rescanning: false,
|
|
||||||
selected: false,
|
selected: false,
|
||||||
isSelectionMode: false,
|
isSelectionMode: false,
|
||||||
showCoverBg: false
|
showCoverBg: false
|
||||||
@@ -177,7 +182,8 @@ export default {
|
|||||||
return this.mediaType === 'podcast'
|
return this.mediaType === 'podcast'
|
||||||
},
|
},
|
||||||
placeholderUrl() {
|
placeholderUrl() {
|
||||||
return '/book_placeholder.jpg'
|
const config = this.$config || this.$nuxt.$config
|
||||||
|
return `${config.routerBasePath}/book_placeholder.jpg`
|
||||||
},
|
},
|
||||||
bookCoverSrc() {
|
bookCoverSrc() {
|
||||||
return this.store.getters['globals/getLibraryItemCoverSrc'](this._libraryItem, this.placeholderUrl)
|
return this.store.getters['globals/getLibraryItemCoverSrc'](this._libraryItem, this.placeholderUrl)
|
||||||
@@ -364,7 +370,7 @@ export default {
|
|||||||
},
|
},
|
||||||
moreMenuItems() {
|
moreMenuItems() {
|
||||||
if (this.recentEpisode) {
|
if (this.recentEpisode) {
|
||||||
return [
|
const items = [
|
||||||
{
|
{
|
||||||
func: 'editPodcast',
|
func: 'editPodcast',
|
||||||
text: 'Edit Podcast'
|
text: 'Edit Podcast'
|
||||||
@@ -374,6 +380,13 @@ export default {
|
|||||||
text: `Mark as ${this.itemIsFinished ? 'Not Finished' : 'Finished'}`
|
text: `Mark as ${this.itemIsFinished ? 'Not Finished' : 'Finished'}`
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
if (this.continueListeningShelf) {
|
||||||
|
items.push({
|
||||||
|
func: 'removeFromContinueListening',
|
||||||
|
text: 'Remove from Continue Listening'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
var items = []
|
var items = []
|
||||||
@@ -382,12 +395,14 @@ export default {
|
|||||||
{
|
{
|
||||||
func: 'toggleFinished',
|
func: 'toggleFinished',
|
||||||
text: `Mark as ${this.itemIsFinished ? 'Not Finished' : 'Finished'}`
|
text: `Mark as ${this.itemIsFinished ? 'Not Finished' : 'Finished'}`
|
||||||
},
|
|
||||||
{
|
|
||||||
func: 'openCollections',
|
|
||||||
text: 'Add to Collection'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
if (this.userCanUpdate) {
|
||||||
|
items.push({
|
||||||
|
func: 'openCollections',
|
||||||
|
text: 'Add to Collection'
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (this.userCanUpdate) {
|
if (this.userCanUpdate) {
|
||||||
items.push({
|
items.push({
|
||||||
@@ -405,6 +420,18 @@ export default {
|
|||||||
text: 'Re-Scan'
|
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
|
return items
|
||||||
},
|
},
|
||||||
_socket() {
|
_socket() {
|
||||||
@@ -490,6 +517,7 @@ export default {
|
|||||||
this.libraryItem = libraryItem
|
this.libraryItem = libraryItem
|
||||||
},
|
},
|
||||||
clickCard(e) {
|
clickCard(e) {
|
||||||
|
if (this.processing) return
|
||||||
if (this.isSelectionMode) {
|
if (this.isSelectionMode) {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -526,7 +554,7 @@ export default {
|
|||||||
var updatePayload = {
|
var updatePayload = {
|
||||||
isFinished: !this.itemIsFinished
|
isFinished: !this.itemIsFinished
|
||||||
}
|
}
|
||||||
this.isProcessingReadUpdate = true
|
this.processing = true
|
||||||
|
|
||||||
var apiEndpoint = `/api/me/progress/${this.libraryItemId}`
|
var apiEndpoint = `/api/me/progress/${this.libraryItemId}`
|
||||||
if (this.recentEpisode) apiEndpoint += `/${this.recentEpisode.id}`
|
if (this.recentEpisode) apiEndpoint += `/${this.recentEpisode.id}`
|
||||||
@@ -536,12 +564,12 @@ export default {
|
|||||||
axios
|
axios
|
||||||
.$patch(apiEndpoint, updatePayload)
|
.$patch(apiEndpoint, updatePayload)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.isProcessingReadUpdate = false
|
this.processing = false
|
||||||
toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
this.isProcessingReadUpdate = false
|
this.processing = false
|
||||||
toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -549,11 +577,12 @@ export default {
|
|||||||
this.$emit('editPodcast', this.libraryItem)
|
this.$emit('editPodcast', this.libraryItem)
|
||||||
},
|
},
|
||||||
rescan() {
|
rescan() {
|
||||||
this.rescanning = true
|
if (this.processing) return
|
||||||
this.$axios
|
const axios = this.$axios || this.$nuxt.$axios
|
||||||
|
this.processing = true
|
||||||
|
axios
|
||||||
.$get(`/api/items/${this.libraryItemId}/scan`)
|
.$get(`/api/items/${this.libraryItemId}/scan`)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
this.rescanning = false
|
|
||||||
var result = data.result
|
var result = data.result
|
||||||
if (!result) {
|
if (!result) {
|
||||||
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
|
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
|
||||||
@@ -568,7 +597,9 @@ export default {
|
|||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to scan library item', error)
|
console.error('Failed to scan library item', error)
|
||||||
this.$toast.error('Failed to scan library item')
|
this.$toast.error('Failed to scan library item')
|
||||||
this.rescanning = false
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.processing = false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
showEditModalFiles() {
|
showEditModalFiles() {
|
||||||
@@ -579,6 +610,40 @@ export default {
|
|||||||
// More menu func
|
// More menu func
|
||||||
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' })
|
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() {
|
openCollections() {
|
||||||
this.store.commit('setSelectedLibraryItem', this.libraryItem)
|
this.store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||||
this.store.commit('globals/setShowUserCollectionsModal', true)
|
this.store.commit('globals/setShowUserCollectionsModal', true)
|
||||||
@@ -647,11 +712,50 @@ export default {
|
|||||||
this.selected = !this.selected
|
this.selected = !this.selected
|
||||||
this.$emit('select', this.libraryItem)
|
this.$emit('select', this.libraryItem)
|
||||||
},
|
},
|
||||||
play() {
|
async play() {
|
||||||
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||||
|
|
||||||
|
const queueItems = []
|
||||||
|
// Podcast episode load queue items
|
||||||
|
if (this.recentEpisode) {
|
||||||
|
const axios = this.$axios || this.$nuxt.$axios
|
||||||
|
this.processing = true
|
||||||
|
const fullLibraryItem = await axios.$get(`/api/items/${this.libraryItemId}`).catch((err) => {
|
||||||
|
console.error('Failed to fetch library item', err)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
this.processing = false
|
||||||
|
|
||||||
|
if (fullLibraryItem && fullLibraryItem.media.episodes) {
|
||||||
|
const episodes = fullLibraryItem.media.episodes || []
|
||||||
|
// Sort from least recent to most recent
|
||||||
|
episodes.sort((a, b) => String(a.publishedAt).localeCompare(String(b.publishedAt), undefined, { numeric: true, sensitivity: 'base' }))
|
||||||
|
|
||||||
|
const episodeIndex = episodes.findIndex((ep) => ep.id === this.recentEpisode.id)
|
||||||
|
if (episodeIndex >= 0) {
|
||||||
|
for (let i = episodeIndex; i < episodes.length; i++) {
|
||||||
|
const episode = episodes[i]
|
||||||
|
const podcastProgress = this.store.getters['user/getUserMediaProgress'](this.libraryItemId, episode.id)
|
||||||
|
if (!podcastProgress || !podcastProgress.isFinished) {
|
||||||
|
queueItems.push({
|
||||||
|
libraryItemId: this.libraryItemId,
|
||||||
|
episodeId: episode.id,
|
||||||
|
title: episode.title,
|
||||||
|
subtitle: this.mediaMetadata.title,
|
||||||
|
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
|
||||||
|
duration: episode.audioFile.duration || null,
|
||||||
|
coverPath: this.media.coverPath || null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
eventBus.$emit('play-item', {
|
eventBus.$emit('play-item', {
|
||||||
libraryItemId: this.libraryItemId,
|
libraryItemId: this.libraryItemId,
|
||||||
episodeId: this.recentEpisode ? this.recentEpisode.id : null
|
episodeId: this.recentEpisode ? this.recentEpisode.id : null,
|
||||||
|
queueItems
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
mouseover() {
|
mouseover() {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||||
<covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</div>
|
</div>
|
||||||
<div v-show="isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
|
<div v-show="isHovering && userCanUpdate" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
|
||||||
<div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickEdit">
|
<div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickEdit">
|
||||||
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
|
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -30,7 +30,12 @@ export default {
|
|||||||
bookshelfView: {
|
bookshelfView: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 0
|
default: 0
|
||||||
}
|
},
|
||||||
|
collectionMount: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
},
|
||||||
|
isTag: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -64,6 +69,9 @@ export default {
|
|||||||
isAlternativeBookshelfView() {
|
isAlternativeBookshelfView() {
|
||||||
const constants = this.$constants || this.$nuxt.$constants
|
const constants = this.$constants || this.$nuxt.$constants
|
||||||
return this.bookshelfView == constants.BookshelfView.TITLES
|
return this.bookshelfView == constants.BookshelfView.TITLES
|
||||||
|
},
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.store.getters['user/getUserCanUpdate']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -99,6 +107,10 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {
|
||||||
|
if (this.collectionMount) {
|
||||||
|
this.setEntity(this.collectionMount)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<div ref="card" :id="`series-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
<div ref="card" :id="`series-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
|
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
|
||||||
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="displayTitle" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" :group-to="seriesBooksRoute" />
|
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="displayTitle" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ books.length }}</div>
|
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ books.length }}</div>
|
||||||
|
|||||||
@@ -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
|
return this.value
|
||||||
},
|
},
|
||||||
set(val) {
|
set(val) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem("volume", val);
|
||||||
|
} catch(error) {
|
||||||
|
console.error('Failed to store volume', err)
|
||||||
|
}
|
||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -141,6 +146,10 @@ export default {
|
|||||||
if (this.value === 0) {
|
if (this.value === 0) {
|
||||||
this.isMute = true
|
this.isMute = true
|
||||||
}
|
}
|
||||||
|
const storageVolume = localStorage.getItem("volume")
|
||||||
|
if (storageVolume) {
|
||||||
|
this.volume = parseFloat(storageVolume)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
window.removeEventListener('mousewheel', this.scroll)
|
window.removeEventListener('mousewheel', this.scroll)
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export default {
|
|||||||
if (!this.imagePath) return null
|
if (!this.imagePath) return null
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
// Testing
|
// 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}`
|
return `/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export default {
|
|||||||
return this.author
|
return this.author
|
||||||
},
|
},
|
||||||
placeholderUrl() {
|
placeholderUrl() {
|
||||||
return '/book_placeholder.jpg'
|
return `${this.$config.routerBasePath}/book_placeholder.jpg`
|
||||||
},
|
},
|
||||||
fullCoverUrl() {
|
fullCoverUrl() {
|
||||||
if (!this.libraryItem) return null
|
if (!this.libraryItem) return null
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export default {
|
|||||||
},
|
},
|
||||||
width: Number,
|
width: Number,
|
||||||
height: Number,
|
height: Number,
|
||||||
groupTo: String,
|
|
||||||
bookCoverAspectRatio: Number
|
bookCoverAspectRatio: Number
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div ref="container" @mouseover="mouseover" @mouseleave="mouseleave" class="relative">
|
|
||||||
<covers-book-cover :width="24" :audiobook="audiobook" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
audiobook: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
isHovering: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
placeholderUrl() {
|
|
||||||
return '/book_placeholder.jpg'
|
|
||||||
},
|
|
||||||
fullCoverUrl() {
|
|
||||||
return this.$store.getters['globals/getLibraryItemCoverSrc'](this.audiobook, this.placeholderUrl)
|
|
||||||
},
|
|
||||||
hasCover() {
|
|
||||||
return !!this.audiobook.book.cover
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
mouseover() {
|
|
||||||
this.isHovering = true
|
|
||||||
},
|
|
||||||
mouseleave() {
|
|
||||||
this.isHovering = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="!imageFailed" class="absolute -bottom-5 left-0 right-0 mx-auto text-xs text-gray-300 text-center">{{ resolution }}</p>
|
<p v-if="!imageFailed && showResolution" class="absolute -bottom-5 left-0 right-0 mx-auto text-xs text-gray-300 text-center">{{ resolution }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -31,7 +31,11 @@ export default {
|
|||||||
default: 120
|
default: 120
|
||||||
},
|
},
|
||||||
showOpenNewTab: Boolean,
|
showOpenNewTab: Boolean,
|
||||||
bookCoverAspectRatio: Number
|
bookCoverAspectRatio: Number,
|
||||||
|
showResolution: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="batchQuickMatch" :processing="processing" :width="500" :height="'unset'">
|
||||||
|
<template #outer>
|
||||||
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
|
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||||
|
<div v-if="show" class="w-full h-full">
|
||||||
|
<div class="py-4 px-4">
|
||||||
|
<h1 class="text-2xl">Quick Match {{ selectedBookIds.length }} Books</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full overflow-y-auto overflow-x-hidden max-h-96">
|
||||||
|
<div class="flex px-8 items-center py-2">
|
||||||
|
<p class="pr-4">Provider</p>
|
||||||
|
<ui-dropdown v-model="options.provider" :items="providers" small />
|
||||||
|
</div>
|
||||||
|
<p class="text-base px-8 py-2">Quick Match will attempt to add missing covers and metadata for the selected books. Enable the options below to allow Quick Match to overwrite existing covers and/or metadata.</p>
|
||||||
|
<div class="flex px-8 items-end py-2">
|
||||||
|
<ui-toggle-switch v-model="options.overrideCover"/>
|
||||||
|
<ui-tooltip :text="tooltips.updateCovers">
|
||||||
|
<p class="pl-4">
|
||||||
|
Update Covers
|
||||||
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
|
</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
<div class="flex px-8 items-end py-2">
|
||||||
|
<ui-toggle-switch v-model="options.overrideDetails"/>
|
||||||
|
<ui-tooltip :text="tooltips.updateDetails">
|
||||||
|
<p class="pl-4">
|
||||||
|
Update Details
|
||||||
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
|
</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 py-4 border-b border-white border-opacity-10 text-white text-opacity-80 border-t border-white border-opacity-5">
|
||||||
|
<div class="flex items-center px-4">
|
||||||
|
<ui-btn type="button" @click="show = false">Cancel</ui-btn>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn color="success" @click="doBatchQuickMatch">Continue</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false,
|
||||||
|
lastUsedLibrary: undefined,
|
||||||
|
options: {
|
||||||
|
provider: undefined,
|
||||||
|
overrideDetails: true,
|
||||||
|
overrideCover: true,
|
||||||
|
overrideDefaults: true
|
||||||
|
},
|
||||||
|
tooltips: {
|
||||||
|
updateCovers: 'Allow overwriting of existing covers for the selected books when a match is located.',
|
||||||
|
updateDetails: 'Allow overwriting of existing details for the selected books when a match is located.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show: {
|
||||||
|
handler(newVal) {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.$store.state.globals.showBatchQuickMatchModal
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$store.commit('globals/setShowBatchQuickMatchModal', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return `${this.selectedBookIds.length} Items Selected`
|
||||||
|
},
|
||||||
|
showBatchQuickMatchModal() {
|
||||||
|
return this.$store.state.globals.showBatchQuickMatchModal
|
||||||
|
},
|
||||||
|
selectedBookIds() {
|
||||||
|
return this.$store.state.selectedLibraryItems || []
|
||||||
|
},
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.$store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
|
providers() {
|
||||||
|
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||||
|
return this.$store.state.scanners.providers
|
||||||
|
},
|
||||||
|
libraryProvider() {
|
||||||
|
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
init() {
|
||||||
|
// If we don't have a set provider (first open of dialog) or we've switched library, set
|
||||||
|
// the selected provider to the current library default provider
|
||||||
|
if (!this.options.provider || (this.options.lastUsedLibrary != this.currentLibraryId)) {
|
||||||
|
this.options.lastUsedLibrary = this.currentLibraryId
|
||||||
|
this.options.provider = this.libraryProvider
|
||||||
|
}
|
||||||
|
},
|
||||||
|
doBatchQuickMatch() {
|
||||||
|
if (!this.selectedBookIds.length) return
|
||||||
|
if (this.processing) return
|
||||||
|
|
||||||
|
this.processing = true
|
||||||
|
this.$store.commit('setProcessingBatch', true)
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/items/batch/quickmatch`, {
|
||||||
|
options: this.options,
|
||||||
|
libraryItemIds: this.selectedBookIds
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.info('Batch quick match of ' + this.selectedBookIds.length + ' books started!')
|
||||||
|
}).catch((error) => {
|
||||||
|
this.$toast.error('Batch quick match failed')
|
||||||
|
console.error('Failed to batch quick match', error)
|
||||||
|
}).finally(() => {
|
||||||
|
this.processing = false
|
||||||
|
this.$store.commit('setProcessingBatch', false)
|
||||||
|
this.show = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute bottom-0 left-0 right-0 w-full py-2 px-4 flex">
|
<div class="absolute bottom-0 left-0 right-0 w-full py-2 px-4 flex">
|
||||||
<ui-btn small color="error" type="button" @click.stop="removeClick">Remove</ui-btn>
|
<ui-btn v-if="userCanDelete" small color="error" type="button" @click.stop="removeClick">Remove</ui-btn>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn color="success" type="submit">Save</ui-btn>
|
<ui-btn color="success" type="submit">Save</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,6 +85,9 @@ export default {
|
|||||||
},
|
},
|
||||||
books() {
|
books() {
|
||||||
return this.collection.books || []
|
return this.collection.books || []
|
||||||
|
},
|
||||||
|
userCanDelete() {
|
||||||
|
return this.$store.getters['user/getUserCanDelete']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -66,9 +66,9 @@ export default {
|
|||||||
component: 'modals-item-tabs-match'
|
component: 'modals-item-tabs-match'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'manage',
|
id: 'tools',
|
||||||
title: 'Manage',
|
title: 'Tools',
|
||||||
component: 'modals-item-tabs-manage',
|
component: 'modals-item-tabs-tools',
|
||||||
mediaType: 'book',
|
mediaType: 'book',
|
||||||
admin: true
|
admin: true
|
||||||
},
|
},
|
||||||
@@ -141,10 +141,10 @@ export default {
|
|||||||
if (tab.mediaType && this.mediaType !== tab.mediaType) return false
|
if (tab.mediaType && this.mediaType !== tab.mediaType) return false
|
||||||
if (tab.admin && !this.userIsAdminOrUp) 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 === 'tools' || 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.userCanUpdate) return true
|
||||||
if (tab.id === 'match' && this.userCanUpdate) return true
|
if (tab.id === 'match' && this.userCanUpdate) return true
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
@@ -231,8 +231,10 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
selectTab(tab) {
|
selectTab(tab) {
|
||||||
|
if (this.selectedTab === tab) return
|
||||||
if (this.availableTabs.find((t) => t.id === tab)) {
|
if (this.availableTabs.find((t) => t.id === tab)) {
|
||||||
this.selectedTab = tab
|
this.selectedTab = tab
|
||||||
|
this.processing = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
libraryItemUpdated(expandedLibraryItem) {
|
libraryItemUpdated(expandedLibraryItem) {
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export default {
|
|||||||
return this.$store.state.scanners.providers
|
return this.$store.state.scanners.providers
|
||||||
},
|
},
|
||||||
searchTitleLabel() {
|
searchTitleLabel() {
|
||||||
if (this.provider == 'audible') return 'Search Title or ASIN'
|
if (this.provider.startsWith('audible')) return 'Search Title or ASIN'
|
||||||
else if (this.provider == 'itunes') return 'Search Term'
|
else if (this.provider == 'itunes') return 'Search Term'
|
||||||
return 'Search Title'
|
return 'Search Title'
|
||||||
},
|
},
|
||||||
@@ -164,7 +164,7 @@ export default {
|
|||||||
.filter((f) => f.fileType === 'image')
|
.filter((f) => f.fileType === 'image')
|
||||||
.map((file) => {
|
.map((file) => {
|
||||||
var _file = { ...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
|
return _file
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,11 @@
|
|||||||
|
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
|
||||||
<ui-tooltip v-if="mediaType == 'book'" :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-2 md:mr-4">
|
<ui-tooltip :disabled="!!quickMatching" :text="`Populate empty ${mediaType} details & cover with first ${mediaType} result from '${libraryProvider}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.`" direction="bottom" class="mr-2 md:mr-4">
|
||||||
<ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
|
<ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-2 md:mr-4">
|
<ui-tooltip :disabled="!!libraryScan" text="Rescan library item including metadata" direction="bottom" class="mr-2 md:mr-4">
|
||||||
<ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
|
<ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,15 @@
|
|||||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||||
<div class="w-full mb-4">
|
<div class="w-full mb-4">
|
||||||
<div v-if="userIsAdminOrUp" class="flex items-end justify-end mb-4">
|
<div v-if="userIsAdminOrUp" class="flex items-end justify-end mb-4">
|
||||||
<!-- <p v-if="autoDownloadEpisodes">Last new episode check {{ $formatDate(lastEpisodeCheck) }}</p> -->
|
|
||||||
<ui-text-input-with-label ref="lastCheckInput" v-model="lastEpisodeCheckInput" :disabled="checkingNewEpisodes" type="datetime-local" label="Look for new episodes after this date" class="max-w-xs mr-2" />
|
<ui-text-input-with-label ref="lastCheckInput" v-model="lastEpisodeCheckInput" :disabled="checkingNewEpisodes" type="datetime-local" label="Look for new episodes after this date" class="max-w-xs mr-2" />
|
||||||
|
<ui-text-input-with-label ref="maxEpisodesInput" v-model="maxEpisodesToDownload" :disabled="checkingNewEpisodes" type="number" label="Max episodes" class="w-16 mr-2" input-class="h-10">
|
||||||
|
<div class="flex -mb-0.5">
|
||||||
|
<p class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': checkingNewEpisodes }">Limit</p>
|
||||||
|
<ui-tooltip direction="top" text="Max # of episodes to download. Use 0 for unlimited.">
|
||||||
|
<span class="material-icons text-base">info_outlined</span>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
</ui-text-input-with-label>
|
||||||
<ui-btn :loading="checkingNewEpisodes" @click="checkForNewEpisodes">Check & Download New Episodes</ui-btn>
|
<ui-btn :loading="checkingNewEpisodes" @click="checkForNewEpisodes">Check & Download New Episodes</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -52,7 +59,8 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
checkingNewEpisodes: false,
|
checkingNewEpisodes: false,
|
||||||
lastEpisodeCheckInput: null
|
lastEpisodeCheckInput: null,
|
||||||
|
maxEpisodesToDownload: 3
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -89,6 +97,16 @@ export default {
|
|||||||
if (this.$refs.lastCheckInput) {
|
if (this.$refs.lastCheckInput) {
|
||||||
this.$refs.lastCheckInput.blur()
|
this.$refs.lastCheckInput.blur()
|
||||||
}
|
}
|
||||||
|
if (this.$refs.maxEpisodesInput) {
|
||||||
|
this.$refs.maxEpisodesInput.blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.maxEpisodesToDownload < 0) {
|
||||||
|
this.maxEpisodesToDownload = 3
|
||||||
|
this.$toast.error('Invalid max episodes to download')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
this.checkingNewEpisodes = true
|
this.checkingNewEpisodes = true
|
||||||
const lastEpisodeCheck = new Date(this.lastEpisodeCheckInput).valueOf()
|
const lastEpisodeCheck = new Date(this.lastEpisodeCheckInput).valueOf()
|
||||||
|
|
||||||
@@ -102,7 +120,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.$axios
|
this.$axios
|
||||||
.$get(`/api/podcasts/${this.libraryItemId}/checknew`)
|
.$get(`/api/podcasts/${this.libraryItemId}/checknew?limit=${this.maxEpisodesToDownload}`)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.episodes && response.episodes.length) {
|
if (response.episodes && response.episodes.length) {
|
||||||
console.log('New episodes', response.episodes.length)
|
console.log('New episodes', response.episodes.length)
|
||||||
|
|||||||
@@ -1,267 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
|
||||||
<!-- Merge to m4b -->
|
|
||||||
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8">
|
|
||||||
<div class="flex flex-wrap items-center">
|
|
||||||
<div>
|
|
||||||
<p class="text-lg">Make M4B Audiobook File <span class="text-error">*</span></p>
|
|
||||||
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded metadata, cover image, and chapters. <br /><span class="text-warning">*</span> Does not delete existing audio files.</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<div class="mt-2 md:mt-0">
|
|
||||||
<p v-if="abmergeStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
|
|
||||||
<p v-if="abmergeStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
|
|
||||||
<p v-if="abmergeStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
|
||||||
|
|
||||||
<ui-btn v-if="abmergeStatus !== $constants.DownloadStatus.READY" :loading="abmergeStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startAudiobookMerge">Start Merge</ui-btn>
|
|
||||||
<div v-else>
|
|
||||||
<div class="flex">
|
|
||||||
<ui-btn @click="downloadWithProgress(abmergeDownload)">Download</ui-btn>
|
|
||||||
<ui-icon-btn small icon="delete" bg-color="error" class="ml-2" @click="removeDownload" />
|
|
||||||
</div>
|
|
||||||
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(abmergeDownload.size) }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Split to mp3 -->
|
|
||||||
<div v-if="showMp3Split && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div>
|
|
||||||
<p class="text-lg">Split M4B to MP3's</p>
|
|
||||||
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate multiple MP3's split by chapters with embedded metadata, cover image, and chapters. <br /><span class="text-warning">*</span> Does not delete existing audio files.</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<div>
|
|
||||||
<p v-if="abmergeStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
|
|
||||||
<p v-if="abmergeStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
|
|
||||||
<p v-if="abmergeStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
|
||||||
|
|
||||||
<ui-btn v-if="abmergeStatus !== $constants.DownloadStatus.READY" :loading="abmergeStatus === $constants.DownloadStatus.PENDING" :disabled="true" @click="startAudiobookMerge">Not yet implemented</ui-btn>
|
|
||||||
<div v-else>
|
|
||||||
<div class="flex">
|
|
||||||
<ui-btn @click="downloadWithProgress(abmergeDownload)">Download</ui-btn>
|
|
||||||
<ui-icon-btn small icon="delete" bg-color="error" class="ml-2" @click="removeDownload" />
|
|
||||||
</div>
|
|
||||||
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(abmergeDownload.size) }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Embed Metadata -->
|
|
||||||
<div v-if="mediaTracks.length && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div>
|
|
||||||
<p class="text-lg">Embed Metadata</p>
|
|
||||||
<p class="max-w-sm text-sm pt-2 text-gray-300">Embed metadata into audio files including cover image and chapters. <br /><span class="text-warning">*</span> Modifies audio files.</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<div>
|
|
||||||
<ui-btn :to="`/item/${libraryItemId}/manage`" class="flex items-center"
|
|
||||||
>Open Manager
|
|
||||||
<span class="material-icons text-lg ml-2">launch</span>
|
|
||||||
</ui-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="showM4bDownload" class="text-left text-base mb-4 py-4">
|
|
||||||
<span class="text-error">* <strong>Experimental</strong></span
|
|
||||||
> - M4b merge can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted. Download will timeout after 30 minutes.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- <p v-if="isSingleM4b" class="text-lg text-center my-8">Audiobook is already a single m4b!</p> -->
|
|
||||||
<p v-if="!mediaTracks.length" class="text-lg text-center my-8">No audio tracks to merge</p>
|
|
||||||
|
|
||||||
<div v-if="isDownloading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
|
||||||
<div class="w-80 border border-black-400 bg-bg rounded-xl h-20">
|
|
||||||
<div class="w-full h-full flex items-center justify-center">
|
|
||||||
<p class="text-lg">Download.... {{ downloadPercent }}%</p>
|
|
||||||
<p class="w-24 font-mono pl-8 text-right">
|
|
||||||
{{ downloadAmount }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
processing: Boolean,
|
|
||||||
libraryItem: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
tempDisable: false,
|
|
||||||
isDownloading: false,
|
|
||||||
downloadPercent: '0',
|
|
||||||
downloadAmount: '0 KB'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
abmergeStatus(newVal) {
|
|
||||||
if (newVal) {
|
|
||||||
this.tempDisable = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.$store.state.showExperimentalFeatures
|
|
||||||
},
|
|
||||||
libraryItemId() {
|
|
||||||
return this.libraryItem ? this.libraryItem.id : null
|
|
||||||
},
|
|
||||||
media() {
|
|
||||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
|
||||||
},
|
|
||||||
downloads() {
|
|
||||||
return this.$store.getters['downloads/getDownloads'](this.libraryItemId)
|
|
||||||
},
|
|
||||||
abmergeDownload() {
|
|
||||||
return this.downloads.find((d) => d.type === 'abmerge')
|
|
||||||
},
|
|
||||||
abmergeStatus() {
|
|
||||||
return this.abmergeDownload ? this.abmergeDownload.status : false
|
|
||||||
},
|
|
||||||
libraryFiles() {
|
|
||||||
return this.libraryItem.libraryFiles
|
|
||||||
},
|
|
||||||
totalFiles() {
|
|
||||||
return this.libraryFiles.length
|
|
||||||
},
|
|
||||||
mediaTracks() {
|
|
||||||
return this.media.tracks || []
|
|
||||||
},
|
|
||||||
isSingleM4b() {
|
|
||||||
return this.mediaTracks.length === 1 && this.mediaTracks[0].metadata.ext.toLowerCase() === '.m4b'
|
|
||||||
},
|
|
||||||
chapters() {
|
|
||||||
return this.media.chapters || []
|
|
||||||
},
|
|
||||||
showM4bDownload() {
|
|
||||||
if (!this.mediaTracks.length) return false
|
|
||||||
return !this.isSingleM4b
|
|
||||||
},
|
|
||||||
showMp3Split() {
|
|
||||||
if (!this.mediaTracks.length) return false
|
|
||||||
return this.isSingleM4b && this.chapters.length
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
removeDownload() {
|
|
||||||
if (!this.abmergeDownload) return
|
|
||||||
if (!confirm(`Are you sure you want to remove this merge download?`)) return
|
|
||||||
|
|
||||||
var downloadId = this.abmergeDownload.id
|
|
||||||
|
|
||||||
this.tempDisable = true
|
|
||||||
this.$axios
|
|
||||||
.$delete(`/api/download/${downloadId}`)
|
|
||||||
.then(() => {
|
|
||||||
this.tempDisable = false
|
|
||||||
this.$toast.success('Merge download deleted')
|
|
||||||
this.$store.commit('downloads/removeDownload', { id: downloadId })
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
|
||||||
this.$toast.error(errorMsg)
|
|
||||||
this.tempDisable = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
startAudiobookMerge() {
|
|
||||||
this.tempDisable = true
|
|
||||||
|
|
||||||
this.$axios
|
|
||||||
.$get(`/api/audiobook-merge/${this.libraryItemId}`)
|
|
||||||
.then(() => {
|
|
||||||
this.tempDisable = false
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
|
||||||
this.$toast.error(errorMsg)
|
|
||||||
this.tempDisable = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
downloadWithProgress(download) {
|
|
||||||
var downloadId = download.id
|
|
||||||
var downloadUrl = `${process.env.serverUrl}/api/download/${downloadId}`
|
|
||||||
var filename = download.filename
|
|
||||||
|
|
||||||
this.isDownloading = true
|
|
||||||
|
|
||||||
var request = new XMLHttpRequest()
|
|
||||||
request.responseType = 'blob'
|
|
||||||
request.open('get', downloadUrl, true)
|
|
||||||
request.setRequestHeader('Authorization', `Bearer ${this.$store.getters['user/getToken']}`)
|
|
||||||
request.send()
|
|
||||||
|
|
||||||
request.onreadystatechange = () => {
|
|
||||||
if (request.readyState === 4) {
|
|
||||||
this.isDownloading = false
|
|
||||||
}
|
|
||||||
if (request.readyState == 4 && request.status == 200) {
|
|
||||||
const url = window.URL.createObjectURL(request.response)
|
|
||||||
|
|
||||||
const anchor = document.createElement('a')
|
|
||||||
anchor.href = url
|
|
||||||
anchor.download = filename
|
|
||||||
document.body.appendChild(anchor)
|
|
||||||
anchor.click()
|
|
||||||
setTimeout(() => {
|
|
||||||
if (anchor) anchor.remove()
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
request.onerror = (err) => {
|
|
||||||
console.error('Download error', err)
|
|
||||||
this.isDownloading = false
|
|
||||||
}
|
|
||||||
|
|
||||||
request.onprogress = (e) => {
|
|
||||||
const percent_complete = Math.floor((e.loaded / e.total) * 100)
|
|
||||||
this.downloadAmount = this.$bytesPretty(e.loaded)
|
|
||||||
this.downloadPercent = percent_complete
|
|
||||||
|
|
||||||
// const duration = (new Date().getTime() - startTime) / 1000
|
|
||||||
// const bps = e.loaded / duration
|
|
||||||
// const kbps = Math.floor(bps / 1024)
|
|
||||||
// const time = (e.total - e.loaded) / bps
|
|
||||||
// const seconds = Math.floor(time % 60)
|
|
||||||
// const minutes = Math.floor(time / 60)
|
|
||||||
// console.log(`${percent_complete}% - ${kbps} Kbps - ${minutes} min ${seconds} sec remaining`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
loadDownloads() {
|
|
||||||
this.$axios
|
|
||||||
.$get(`/api/downloads`)
|
|
||||||
.then((data) => {
|
|
||||||
var pendingDownloads = data.pendingDownloads.map((pd) => {
|
|
||||||
pd.download.status = this.$constants.DownloadStatus.PENDING
|
|
||||||
return pd.download
|
|
||||||
})
|
|
||||||
var downloads = data.downloads.map((d) => {
|
|
||||||
d.status = this.$constants.DownloadStatus.READY
|
|
||||||
return d
|
|
||||||
})
|
|
||||||
var allDownloads = downloads.concat(pendingDownloads)
|
|
||||||
this.$store.commit('downloads/setDownloads', allDownloads)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to load downloads', error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.loadDownloads()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -25,16 +25,16 @@
|
|||||||
<cards-book-match-card :key="index" :book="res" :is-podcast="isPodcast" :book-cover-aspect-ratio="bookCoverAspectRatio" @select="selectMatch" />
|
<cards-book-match-card :key="index" :book="res" :is-podcast="isPodcast" :book-cover-aspect-ratio="bookCoverAspectRatio" @select="selectMatch" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</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="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>
|
<span class="material-icons text-3xl">arrow_back</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xl pl-3">Update Book Details</p>
|
<p class="text-xl pl-3">Update Book Details</p>
|
||||||
</div>
|
</div>
|
||||||
<ui-checkbox v-model="selectAll" checkbox-bg="bg" @input="selectAllToggled" />
|
<ui-checkbox v-model="selectAll" checkbox-bg="bg" @input="selectAllToggled" />
|
||||||
<form @submit.prevent="submitMatchUpdate">
|
<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-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" />
|
<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">
|
<div class="min-w-12 max-w-12 md:min-w-16 md:max-w-16">
|
||||||
@@ -43,46 +43,49 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<ui-checkbox v-model="selectedMatchUsage.title" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="flex-grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-text-input-with-label v-model="selectedMatch.title" :disabled="!selectedMatchUsage.title" label="Title" />
|
<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>
|
<p v-if="mediaMetadata.title" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.title || '' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<ui-checkbox v-model="selectedMatchUsage.subtitle" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="flex-grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-text-input-with-label v-model="selectedMatch.subtitle" :disabled="!selectedMatchUsage.subtitle" label="Subtitle" />
|
<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>
|
<p v-if="mediaMetadata.subtitle" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.subtitle || '' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<ui-checkbox v-model="selectedMatchUsage.author" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="flex-grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-text-input-with-label v-model="selectedMatch.author" :disabled="!selectedMatchUsage.author" label="Author" />
|
<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>
|
<p v-if="mediaMetadata.authorName" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.authorName || '' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<ui-checkbox v-model="selectedMatchUsage.narrator" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="flex-grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-text-input-with-label v-model="selectedMatch.narrator" :disabled="!selectedMatchUsage.narrator" label="Narrator" />
|
<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>
|
<p v-if="mediaMetadata.narratorName" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.narratorName || '' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</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-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>
|
||||||
<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" />
|
<ui-checkbox v-model="selectedMatchUsage.publisher" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="flex-grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-text-input-with-label v-model="selectedMatch.publisher" :disabled="!selectedMatchUsage.publisher" label="Publisher" />
|
<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>
|
<p v-if="mediaMetadata.publisher" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.publisher || '' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<ui-checkbox v-model="selectedMatchUsage.publishedYear" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="flex-grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-text-input-with-label v-model="selectedMatch.publishedYear" :disabled="!selectedMatchUsage.publishedYear" label="Published Year" />
|
<ui-text-input-with-label v-model="selectedMatch.publishedYear" :disabled="!selectedMatchUsage.publishedYear" label="Published Year" />
|
||||||
@@ -90,46 +93,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</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" />
|
<ui-checkbox v-model="selectedMatchUsage.series" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="flex-grow ml-4">
|
<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>
|
<p v-if="mediaMetadata.seriesName" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.seriesName || '' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedMatch.volumeNumber" 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.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">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.genres" checkbox-bg="bg" @input="checkboxToggled" />
|
<ui-checkbox v-model="selectedMatchUsage.genres" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="flex-grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-text-input-with-label v-model="selectedMatch.genres" :disabled="!selectedMatchUsage.genres" label="Genres" />
|
<ui-multi-select v-model="selectedMatch.genres" :items="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>
|
<p v-if="mediaMetadata.genres" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.genres.join(', ') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<ui-checkbox v-model="selectedMatchUsage.tags" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="flex-grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-text-input-with-label v-model="selectedMatch.tags" :disabled="!selectedMatchUsage.tags" label="Tags" />
|
<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>
|
</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" />
|
<ui-checkbox v-model="selectedMatchUsage.language" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="flex-grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-text-input-with-label v-model="selectedMatch.language" :disabled="!selectedMatchUsage.language" label="Language" />
|
<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>
|
<p v-if="mediaMetadata.language" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.language || '' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<ui-checkbox v-model="selectedMatchUsage.isbn" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="flex-grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-text-input-with-label v-model="selectedMatch.isbn" :disabled="!selectedMatchUsage.isbn" label="ISBN" />
|
<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>
|
<p v-if="mediaMetadata.isbn" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.isbn || '' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<ui-checkbox v-model="selectedMatchUsage.asin" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="flex-grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-text-input-with-label v-model="selectedMatch.asin" :disabled="!selectedMatchUsage.asin" label="ASIN" />
|
<ui-text-input-with-label v-model="selectedMatch.asin" :disabled="!selectedMatchUsage.asin" label="ASIN" />
|
||||||
@@ -137,28 +136,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</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" />
|
<ui-checkbox v-model="selectedMatchUsage.itunesId" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="flex-grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-text-input-with-label v-model="selectedMatch.itunesId" type="number" :disabled="!selectedMatchUsage.itunesId" label="iTunes ID" />
|
<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>
|
<p v-if="mediaMetadata.itunesId" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.itunesId || '' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<ui-checkbox v-model="selectedMatchUsage.feedUrl" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="flex-grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-text-input-with-label v-model="selectedMatch.feedUrl" :disabled="!selectedMatchUsage.feedUrl" label="RSS Feed URL" />
|
<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>
|
<p v-if="mediaMetadata.feedUrl" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.feedUrl || '' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<ui-checkbox v-model="selectedMatchUsage.itunesPageUrl" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="flex-grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-text-input-with-label v-model="selectedMatch.itunesPageUrl" :disabled="!selectedMatchUsage.itunesPageUrl" label="iTunes Page URL" />
|
<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>
|
<p v-if="mediaMetadata.itunesPageUrl" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.itunesPageUrl || '' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<ui-checkbox v-model="selectedMatchUsage.releaseDate" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="flex-grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-text-input-with-label v-model="selectedMatch.releaseDate" :disabled="!selectedMatchUsage.releaseDate" label="Release Date" />
|
<ui-text-input-with-label v-model="selectedMatch.releaseDate" :disabled="!selectedMatchUsage.releaseDate" label="Release Date" />
|
||||||
@@ -193,6 +192,7 @@ export default {
|
|||||||
searchResults: [],
|
searchResults: [],
|
||||||
hasSearched: false,
|
hasSearched: false,
|
||||||
selectedMatch: null,
|
selectedMatch: null,
|
||||||
|
selectedMatchOrig: null,
|
||||||
selectedMatchUsage: {
|
selectedMatchUsage: {
|
||||||
title: true,
|
title: true,
|
||||||
subtitle: true,
|
subtitle: true,
|
||||||
@@ -203,7 +203,6 @@ export default {
|
|||||||
publisher: true,
|
publisher: true,
|
||||||
publishedYear: true,
|
publishedYear: true,
|
||||||
series: true,
|
series: true,
|
||||||
volumeNumber: true,
|
|
||||||
genres: true,
|
genres: true,
|
||||||
tags: true,
|
tags: true,
|
||||||
language: true,
|
language: true,
|
||||||
@@ -241,14 +240,13 @@ export default {
|
|||||||
return this.selectedMatch.series.map((se) => {
|
return this.selectedMatch.series.map((se) => {
|
||||||
return {
|
return {
|
||||||
id: `new-${Math.floor(Math.random() * 10000)}`,
|
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,
|
name: se.series,
|
||||||
sequence: se.volumeNumber || ''
|
sequence: se.sequence || ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
set(val) {
|
set(val) {
|
||||||
console.log('set series items', val)
|
|
||||||
this.selectedMatch.series = val
|
this.selectedMatch.series = val
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -260,7 +258,7 @@ export default {
|
|||||||
return this.$store.state.scanners.providers
|
return this.$store.state.scanners.providers
|
||||||
},
|
},
|
||||||
searchTitleLabel() {
|
searchTitleLabel() {
|
||||||
if (this.provider == 'audible') return 'Search Title or ASIN'
|
if (this.provider.startsWith('audible')) return 'Search Title or ASIN'
|
||||||
else if (this.provider == 'itunes') return 'Search Term'
|
else if (this.provider == 'itunes') return 'Search Term'
|
||||||
return 'Search Title'
|
return 'Search Title'
|
||||||
},
|
},
|
||||||
@@ -314,7 +312,7 @@ export default {
|
|||||||
this.isProcessing = true
|
this.isProcessing = true
|
||||||
this.lastSearch = searchQuery
|
this.lastSearch = searchQuery
|
||||||
var searchEntity = this.isPodcast ? 'podcast' : 'books'
|
var searchEntity = this.isPodcast ? 'podcast' : 'books'
|
||||||
var results = await this.$axios.$get(`/api/search/${searchEntity}?${searchQuery}`, { timeout: 10000 }).catch((error) => {
|
var results = await this.$axios.$get(`/api/search/${searchEntity}?${searchQuery}`, { timeout: 20000 }).catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
@@ -338,7 +336,7 @@ export default {
|
|||||||
this.hasSearched = true
|
this.hasSearched = true
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.selectedMatch = null
|
this.clearSelectedMatch()
|
||||||
this.selectedMatchUsage = {
|
this.selectedMatchUsage = {
|
||||||
title: true,
|
title: true,
|
||||||
subtitle: true,
|
subtitle: true,
|
||||||
@@ -349,7 +347,6 @@ export default {
|
|||||||
publisher: true,
|
publisher: true,
|
||||||
publishedYear: true,
|
publishedYear: true,
|
||||||
series: true,
|
series: true,
|
||||||
volumeNumber: true,
|
|
||||||
genres: true,
|
genres: true,
|
||||||
tags: true,
|
tags: true,
|
||||||
language: true,
|
language: true,
|
||||||
@@ -392,37 +389,34 @@ export default {
|
|||||||
match.series = match.series.map((se) => {
|
match.series = match.series.map((se) => {
|
||||||
return {
|
return {
|
||||||
id: `new-${Math.floor(Math.random() * 10000)}`,
|
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,
|
name: se.series,
|
||||||
sequence: se.volumeNumber || ''
|
sequence: se.sequence || ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (match.genres && Array.isArray(match.genres)) {
|
if (match.genres && !Array.isArray(match.genres)) {
|
||||||
match.genres = match.genres.join(',')
|
// match.genres = match.genres.join(',')
|
||||||
|
match.genres = match.genres.split(',').map((g) => g.trim())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Select Match', match)
|
console.log('Select Match', match)
|
||||||
this.selectedMatch = match
|
this.selectedMatch = match
|
||||||
|
this.selectedMatchOrig = JSON.parse(JSON.stringify(match))
|
||||||
},
|
},
|
||||||
buildMatchUpdatePayload() {
|
buildMatchUpdatePayload() {
|
||||||
var updatePayload = {}
|
var updatePayload = {}
|
||||||
updatePayload.metadata = {}
|
updatePayload.metadata = {}
|
||||||
|
|
||||||
var volumeNumber = this.selectedMatchUsage.volumeNumber ? this.selectedMatch.volumeNumber || null : null
|
|
||||||
for (const key in this.selectedMatchUsage) {
|
for (const key in this.selectedMatchUsage) {
|
||||||
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
|
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
|
||||||
if (key === 'series') {
|
if (key === 'series') {
|
||||||
var seriesPayload = []
|
|
||||||
if (!Array.isArray(this.selectedMatch[key])) {
|
if (!Array.isArray(this.selectedMatch[key])) {
|
||||||
seriesPayload.push({
|
console.error('Invalid series in selectedMatch', this.selectedMatch[key])
|
||||||
id: `new-${Math.floor(Math.random() * 10000)}`,
|
|
||||||
name: this.selectedMatch[key],
|
|
||||||
sequence: volumeNumber
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
|
var seriesPayload = []
|
||||||
this.selectedMatch[key].forEach((seriesItem) =>
|
this.selectedMatch[key].forEach((seriesItem) =>
|
||||||
seriesPayload.push({
|
seriesPayload.push({
|
||||||
id: seriesItem.id,
|
id: seriesItem.id,
|
||||||
@@ -430,9 +424,8 @@ export default {
|
|||||||
sequence: seriesItem.sequence
|
sequence: seriesItem.sequence
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
updatePayload.metadata.series = seriesPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
updatePayload.metadata.series = seriesPayload
|
|
||||||
} else if (key === 'author' && !this.isPodcast) {
|
} else if (key === 'author' && !this.isPodcast) {
|
||||||
var authors = this.selectedMatch[key]
|
var authors = this.selectedMatch[key]
|
||||||
if (!Array.isArray(authors)) {
|
if (!Array.isArray(authors)) {
|
||||||
@@ -449,7 +442,8 @@ export default {
|
|||||||
} else if (key === 'narrator') {
|
} else if (key === 'narrator') {
|
||||||
updatePayload.metadata.narrators = this.selectedMatch[key].split(',').map((v) => v.trim())
|
updatePayload.metadata.narrators = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||||
} else if (key === 'genres') {
|
} 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') {
|
} else if (key === 'tags') {
|
||||||
updatePayload.tags = this.selectedMatch[key].split(',').map((v) => v.trim())
|
updatePayload.tags = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||||
} else if (key === 'itunesId') {
|
} else if (key === 'itunesId') {
|
||||||
@@ -500,15 +494,19 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
this.$toast.info('No detail updates were necessary')
|
this.$toast.info('No detail updates were necessary')
|
||||||
}
|
}
|
||||||
this.selectedMatch = null
|
this.clearSelectedMatch()
|
||||||
this.$emit('selectTab', 'details')
|
this.$emit('selectTab', 'details')
|
||||||
} else {
|
} else {
|
||||||
this.$toast.error('Item Details Failed to Update')
|
this.$toast.error('Item Details Failed to Update')
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.selectedMatch = null
|
this.clearSelectedMatch()
|
||||||
}
|
}
|
||||||
this.isProcessing = false
|
this.isProcessing = false
|
||||||
|
},
|
||||||
|
clearSelectedMatch() {
|
||||||
|
this.selectedMatch = null
|
||||||
|
this.selectedMatchOrig = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="item" class="w-full flex items-center px-4 py-2" :class="wrapperClass" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||||
|
<covers-preview-cover :src="coverUrl" :width="48" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" />
|
||||||
|
<div class="flex-grow px-2 py-1 queue-item-row-content truncate">
|
||||||
|
<p class="text-gray-200 text-sm truncate">{{ title }}</p>
|
||||||
|
<p class="text-gray-300 text-sm">{{ subtitle }}</p>
|
||||||
|
<p v-if="caption" class="text-gray-400 text-xs">{{ caption }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-28">
|
||||||
|
<p v-if="isOpenInPlayer" class="text-sm text-right text-gray-400">Streaming</p>
|
||||||
|
<div v-else-if="isHovering" class="flex items-center justify-end -mx-1">
|
||||||
|
<button class="outline-none mx-1 flex items-center" @click.stop="playClick">
|
||||||
|
<span class="material-icons text-success">play_arrow</span>
|
||||||
|
</button>
|
||||||
|
<button class="outline-none mx-1 flex items-center" @click.stop="removeClick">
|
||||||
|
<span class="material-icons text-error">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-gray-400 text-sm text-right">{{ durationPretty }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
item: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
index: Number
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isHovering: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
title() {
|
||||||
|
return this.item.title || ''
|
||||||
|
},
|
||||||
|
subtitle() {
|
||||||
|
return this.item.subtitle || ''
|
||||||
|
},
|
||||||
|
caption() {
|
||||||
|
return this.item.caption
|
||||||
|
},
|
||||||
|
libraryItemId() {
|
||||||
|
return this.item.libraryItemId
|
||||||
|
},
|
||||||
|
episodeId() {
|
||||||
|
return this.item.episodeId
|
||||||
|
},
|
||||||
|
coverPath() {
|
||||||
|
return this.item.coverPath
|
||||||
|
},
|
||||||
|
coverUrl() {
|
||||||
|
if (!this.coverPath) return `${this.$config.routerBasePath}/book_placeholder.jpg`
|
||||||
|
return this.$store.getters['globals/getLibraryItemCoverSrcById'](this.libraryItemId)
|
||||||
|
},
|
||||||
|
bookCoverAspectRatio() {
|
||||||
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
|
},
|
||||||
|
duration() {
|
||||||
|
return this.item.duration
|
||||||
|
},
|
||||||
|
durationPretty() {
|
||||||
|
if (!this.duration) return 'N/A'
|
||||||
|
return this.$elapsedPretty(this.duration)
|
||||||
|
},
|
||||||
|
isOpenInPlayer() {
|
||||||
|
return this.$store.getters['getIsMediaStreaming'](this.libraryItemId, this.episodeId)
|
||||||
|
},
|
||||||
|
wrapperClass() {
|
||||||
|
if (this.isOpenInPlayer) return 'bg-yellow-400 bg-opacity-10'
|
||||||
|
if (this.index % 2 === 0) return 'bg-gray-300 bg-opacity-5 hover:bg-opacity-10'
|
||||||
|
return 'bg-bg hover:bg-gray-300 hover:bg-opacity-10'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
mouseover() {
|
||||||
|
this.isHovering = true
|
||||||
|
},
|
||||||
|
mouseleave() {
|
||||||
|
this.isHovering = false
|
||||||
|
},
|
||||||
|
playClick() {
|
||||||
|
this.$emit('play', this.item)
|
||||||
|
},
|
||||||
|
removeClick() {
|
||||||
|
this.$emit('remove', this.item)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.queue-item-row-content {
|
||||||
|
max-width: calc(100% - 48px - 128px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="queue-items" :width="800" :height="'unset'">
|
||||||
|
<template #outer>
|
||||||
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
|
<p class="font-book text-3xl text-white truncate">Player Queue</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden py-4" style="max-height: 80vh">
|
||||||
|
<div v-if="show" class="w-full h-full">
|
||||||
|
<div class="pb-4 px-4 flex items-center">
|
||||||
|
<p class="text-base text-gray-200">Player Queue</p>
|
||||||
|
<p class="text-base text-gray-400 px-4">{{ playerQueueItems.length }} Items</p>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-checkbox v-model="playerQueueAutoPlay" label="Auto Play" medium checkbox-bg="primary" border-color="gray-600" label-class="pl-2 mb-px" />
|
||||||
|
</div>
|
||||||
|
<modals-player-queue-item-row v-for="(item, index) in playerQueueItems" :key="index" :item="item" :index="index" @play="playItem" @remove="removeItem" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
libraryItemId: String
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
playerQueueAutoPlay: {
|
||||||
|
get() {
|
||||||
|
return this.$store.state.playerQueueAutoPlay
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$store.commit('setPlayerQueueAutoPlay', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
playerQueueItems() {
|
||||||
|
return this.$store.state.playerQueueItems || []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
playItem(item) {
|
||||||
|
this.$eventBus.$emit('play-item', {
|
||||||
|
libraryItemId: item.libraryItemId,
|
||||||
|
episodeId: item.episodeId || null,
|
||||||
|
queueItems: this.playerQueueItems
|
||||||
|
})
|
||||||
|
this.show = false
|
||||||
|
},
|
||||||
|
removeItem(item) {
|
||||||
|
const updatedQueue = this.playerQueueItems.filter((i) => {
|
||||||
|
if (!i.episodeId) return i.libraryItemId !== item.libraryItemId
|
||||||
|
return i.libraryItemId !== item.libraryItemId || i.episodeId !== item.episodeId
|
||||||
|
})
|
||||||
|
this.$store.commit('setPlayerQueueItems', updatedQueue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -11,11 +11,11 @@
|
|||||||
v-for="(episode, index) in episodes"
|
v-for="(episode, index) in episodes"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="relative"
|
class="relative"
|
||||||
:class="episode.enclosure && itemEpisodeMap[episode.enclosure.url] ? 'bg-primary bg-opacity-40' : selectedEpisodes[String(index)] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
|
:class="itemEpisodeMap[episode.enclosure.url] ? 'bg-primary bg-opacity-40' : selectedEpisodes[String(index)] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
|
||||||
@click="toggleSelectEpisode(index)"
|
@click="toggleSelectEpisode(index, episode)"
|
||||||
>
|
>
|
||||||
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
||||||
<span v-if="episode.enclosure && itemEpisodeMap[episode.enclosure.url]" class="material-icons text-success text-xl">download_done</span>
|
<span v-if="itemEpisodeMap[episode.enclosure.url]" class="material-icons text-success text-xl">download_done</span>
|
||||||
<ui-checkbox v-else v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" />
|
<ui-checkbox v-else v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" />
|
||||||
</div>
|
</div>
|
||||||
<div class="px-8 py-2">
|
<div class="px-8 py-2">
|
||||||
@@ -23,20 +23,13 @@
|
|||||||
<p class="break-words mb-1">{{ episode.title }}</p>
|
<p class="break-words mb-1">{{ episode.title }}</p>
|
||||||
<p v-if="episode.subtitle" class="break-words mb-1 text-sm text-gray-300 episode-subtitle">{{ episode.subtitle }}</p>
|
<p v-if="episode.subtitle" class="break-words mb-1 text-sm text-gray-300 episode-subtitle">{{ episode.subtitle }}</p>
|
||||||
<p class="text-xs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
<p class="text-xs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
||||||
<!-- <span class="material-icons cursor-pointer text-lg hover:text-success" @click="saveEpisode(episode)">save</span> -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end pt-4">
|
<div class="flex justify-end pt-4">
|
||||||
<div class="relative">
|
<ui-checkbox v-if="!allDownloaded" v-model="selectAll" @input="toggleSelectAll" label="Select all episodes" small checkbox-bg="primary" border-color="gray-600" class="mx-8" />
|
||||||
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
<ui-btn v-if="!allDownloaded" :disabled="!episodesSelected.length" @click="submit">{{ buttonText }}</ui-btn>
|
||||||
<ui-checkbox v-model="selectAll" small checkbox-bg="primary" border-color="gray-600" :disabled="allDownloaded" />
|
<p v-else class="text-success text-base px-2 py-4">All episodes are downloaded</p>
|
||||||
</div>
|
|
||||||
<div class="px-8 py-2">
|
|
||||||
<p :class="!allDownloaded ? 'font-semibold text-gray-200' : 'text-gray-400'">Select all episodes</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ui-btn :disabled="!episodesSelected.length" @click="submit">{{ buttonText }}</ui-btn>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
@@ -58,7 +51,8 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
processing: false,
|
processing: false,
|
||||||
selectedEpisodes: {}
|
selectedEpisodes: {},
|
||||||
|
selectAll: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -78,22 +72,12 @@ export default {
|
|||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
selectAll: {
|
|
||||||
get() {
|
|
||||||
return this.episodesSelected.length == this.episodes.filter((_, index) => !(this.episodes[index].enclosure && this.itemEpisodeMap[this.episodes[index].enclosure.url])).length
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
for (const key in this.selectedEpisodes) {
|
|
||||||
this.selectedEpisodes[key] = val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
title() {
|
title() {
|
||||||
if (!this.libraryItem) return ''
|
if (!this.libraryItem) return ''
|
||||||
return this.libraryItem.media.metadata.title || 'Unknown'
|
return this.libraryItem.media.metadata.title || 'Unknown'
|
||||||
},
|
},
|
||||||
allDownloaded() {
|
allDownloaded() {
|
||||||
return Object.values(this.episodes).filter((episode) => !(episode.enclosure && this.itemEpisodeMap[episode.enclosure.url])).length === 0
|
return !this.episodes.some((episode) => !this.itemEpisodeMap[episode.enclosure.url])
|
||||||
},
|
},
|
||||||
episodesSelected() {
|
episodesSelected() {
|
||||||
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
|
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
|
||||||
@@ -115,8 +99,27 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
toggleSelectEpisode(index) {
|
toggleSelectAll(val) {
|
||||||
|
for (let i = 0; i < this.episodes.length; i++) {
|
||||||
|
const episode = this.episodes[i]
|
||||||
|
if (this.itemEpisodeMap[episode.enclosure.url]) this.selectedEpisodes[String(i)] = false
|
||||||
|
else this.$set(this.selectedEpisodes, String(i), val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
checkSetIsSelectedAll() {
|
||||||
|
for (let i = 0; i < this.episodes.length; i++) {
|
||||||
|
const episode = this.episodes[i]
|
||||||
|
if (!this.itemEpisodeMap[episode.enclosure.url] && !this.selectedEpisodes[String(i)]) {
|
||||||
|
this.selectAll = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.selectAll = true
|
||||||
|
},
|
||||||
|
toggleSelectEpisode(index, episode) {
|
||||||
|
if (this.itemEpisodeMap[episode.enclosure.url]) return
|
||||||
this.$set(this.selectedEpisodes, String(index), !this.selectedEpisodes[String(index)])
|
this.$set(this.selectedEpisodes, String(index), !this.selectedEpisodes[String(index)])
|
||||||
|
this.checkSetIsSelectedAll()
|
||||||
},
|
},
|
||||||
submit() {
|
submit() {
|
||||||
var episodesToDownload = []
|
var episodesToDownload = []
|
||||||
@@ -145,17 +148,15 @@ export default {
|
|||||||
console.error('Failed to download episodes', error)
|
console.error('Failed to download episodes', error)
|
||||||
this.processing = false
|
this.processing = false
|
||||||
this.$toast.error(errorMsg)
|
this.$toast.error(errorMsg)
|
||||||
|
|
||||||
|
this.selectedEpisodes = {}
|
||||||
|
this.selectAll = false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.episodes.sort((a, b) => (a.publishedAt < b.publishedAt) ? 1 : -1)
|
this.episodes.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
|
||||||
for (let i = 0; i < this.episodes.length; i++) {
|
this.selectAll = false
|
||||||
var episode = this.episodes[i]
|
this.selectedEpisodes = {}
|
||||||
if (episode.enclosure && !this.itemEpisodeMap[episode.enclosure.url]) {
|
|
||||||
// Do not include episodes already downloaded
|
|
||||||
this.$set(this.selectedEpisodes, String(i), false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" name="new-podcast-modal" :width="1000" :height="'unset'" :processing="processing">
|
<modals-modal v-model="show" name="new-podcast-modal" :width="1000" :height="'unset'" :processing="processing">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
<div class="absolute top-0 left-0 p-5 w-3/4 overflow-hidden">
|
||||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
<p class="font-book text-xl md:text-3xl text-white truncate">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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 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-4">
|
<div class="w-full">
|
||||||
<p class="text-lg font-semibold mb-2">Details</p>
|
<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" />
|
<img :src="podcast.imageUrl" class="h-16 w-16 object-contain" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex">
|
<div class="flex flex-wrap">
|
||||||
<div class="w-full md:w-1/2 p-2">
|
<div class="w-full md:w-1/2 p-2">
|
||||||
<ui-text-input-with-label v-model="podcast.title" label="Title" @input="titleUpdated" />
|
<ui-text-input-with-label v-model="podcast.title" label="Title" @input="titleUpdated" />
|
||||||
</div>
|
</div>
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<ui-text-input-with-label v-model="podcast.author" label="Author" />
|
<ui-text-input-with-label v-model="podcast.author" label="Author" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex">
|
<div class="flex flex-wrap">
|
||||||
<div class="w-full md:w-1/2 p-2">
|
<div class="w-full md:w-1/2 p-2">
|
||||||
<ui-text-input-with-label v-model="podcast.feedUrl" label="Feed URL" readonly />
|
<ui-text-input-with-label v-model="podcast.feedUrl" label="Feed URL" readonly />
|
||||||
</div>
|
</div>
|
||||||
@@ -31,19 +31,19 @@
|
|||||||
<div class="p-2 w-full">
|
<div class="p-2 w-full">
|
||||||
<ui-textarea-with-label v-model="podcast.description" label="Description" :rows="3" />
|
<ui-textarea-with-label v-model="podcast.description" label="Description" :rows="3" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex">
|
<div class="flex flex-wrap">
|
||||||
<div class="w-full md:w-1/2 p-2">
|
<div class="w-full md:w-1/2 p-2">
|
||||||
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="processing" label="Folder" @input="folderUpdated" />
|
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="processing" label="Folder" @input="folderUpdated" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full md:w-1/2 p-2">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center py-4">
|
<div class="flex items-center py-4 px-2">
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<div class="px-4">
|
<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>
|
</div>
|
||||||
<ui-btn color="success" @click="submit">Add Podcast</ui-btn>
|
<ui-btn color="success" @click="submit">Add Podcast</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export default {
|
|||||||
serverAddress: window.origin,
|
serverAddress: window.origin,
|
||||||
slug: this.newFeedSlug
|
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)
|
console.log('Payload', payload)
|
||||||
this.$axios
|
this.$axios
|
||||||
|
|||||||
@@ -27,6 +27,10 @@
|
|||||||
<span class="material-icons text-2xl sm:text-3xl transform transition-transform" :class="useChapterTrack ? 'rotate-180' : ''">timelapse</span>
|
<span class="material-icons text-2xl sm:text-3xl transform transition-transform" :class="useChapterTrack ? 'rotate-180' : ''">timelapse</span>
|
||||||
</div>
|
</div>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
|
<button v-if="playerQueueItems.length" class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showPlayerQueueItems')">
|
||||||
|
<span class="material-icons text-2xl sm:text-3xl">playlist_play</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate.sync="playbackRate" :paused="paused" :has-next-chapter="hasNextChapter" @prevChapter="prevChapter" @nextChapter="nextChapter" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
|
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate.sync="playbackRate" :paused="paused" :has-next-chapter="hasNextChapter" @prevChapter="prevChapter" @nextChapter="nextChapter" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
|
||||||
@@ -138,6 +142,9 @@ export default {
|
|||||||
hasNextChapter() {
|
hasNextChapter() {
|
||||||
if (!this.chapters.length) return false
|
if (!this.chapters.length) return false
|
||||||
return this.currentChapterIndex < this.chapters.length - 1
|
return this.currentChapterIndex < this.chapters.length - 1
|
||||||
|
},
|
||||||
|
playerQueueItems() {
|
||||||
|
return this.$store.state.playerQueueItems || []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
<div 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 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">
|
<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">
|
<div class="flex px-1 items-center">
|
||||||
<ui-btn v-if="isYesNo" color="primary" @click="nevermind">Cancel</ui-btn>
|
<ui-btn v-if="isYesNo" color="primary" @click="nevermind">Cancel</ui-btn>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
|||||||
@@ -96,7 +96,9 @@ export default {
|
|||||||
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
|
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
|
||||||
var relPath = this.ebookFile.metadata.relPath
|
var relPath = this.ebookFile.metadata.relPath
|
||||||
if (relPath.startsWith('/')) relPath = relPath.slice(1)
|
if (relPath.startsWith('/')) relPath = relPath.slice(1)
|
||||||
return `/ebook/${this.libraryId}/${this.folderId}/${itemRelPath}/${relPath}`
|
|
||||||
|
const relRelPath = this.$encodeUriPath(`${itemRelPath}/${relPath}`)
|
||||||
|
return `/ebook/${this.libraryId}/${this.folderId}/${relRelPath}`
|
||||||
},
|
},
|
||||||
userToken() {
|
userToken() {
|
||||||
return this.$store.getters['user/getToken']
|
return this.$store.getters['user/getToken']
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<td class="hidden sm:table-cell font-mono md:text-sm text-xs">{{ $bytesPretty(backup.fileSize) }}</td>
|
<td class="hidden sm:table-cell font-mono md:text-sm text-xs">{{ $bytesPretty(backup.fileSize) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="w-full flex flex-row items-center justify-center">
|
<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>
|
<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">
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td v-if="userCanDownload && !isMissing" class="text-center">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -24,6 +24,14 @@
|
|||||||
<th class="text-left w-20">Size</th>
|
<th class="text-left w-20">Size</th>
|
||||||
<th class="text-left w-20">Duration</th>
|
<th class="text-left w-20">Duration</th>
|
||||||
<th v-if="userCanDownload" class="text-center w-20">Download</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>
|
</tr>
|
||||||
<template v-for="track in tracks">
|
<template v-for="track in tracks">
|
||||||
<tr :key="track.index">
|
<tr :key="track.index">
|
||||||
@@ -38,7 +46,10 @@
|
|||||||
{{ $secondsToTimestamp(track.duration) }}
|
{{ $secondsToTimestamp(track.duration) }}
|
||||||
</td>
|
</td>
|
||||||
<td v-if="userCanDownload" class="text-center">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
@@ -65,7 +76,8 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
showTracks: false,
|
showTracks: false,
|
||||||
showFullPath: false
|
showFullPath: false,
|
||||||
|
toneProbing: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -77,11 +89,35 @@ export default {
|
|||||||
},
|
},
|
||||||
userCanUpdate() {
|
userCanUpdate() {
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
|
},
|
||||||
|
showExperimentalFeatures() {
|
||||||
|
return this.$store.state.showExperimentalFeatures
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
clickBar() {
|
clickBar() {
|
||||||
this.showTracks = !this.showTracks
|
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() {}
|
mounted() {}
|
||||||
|
|||||||
@@ -27,15 +27,15 @@
|
|||||||
<span class="material-icons text-lg text-white text-opacity-70 hover:text-opacity-100 cursor-pointer">radio_button_unchecked</span>
|
<span class="material-icons text-lg text-white text-opacity-70 hover:text-opacity-100 cursor-pointer">radio_button_unchecked</span>
|
||||||
</div> -->
|
</div> -->
|
||||||
</div>
|
</div>
|
||||||
<div class="w-40 absolute top-0 -right-24 h-full transform transition-transform" :class="!isHovering ? 'translate-x-0' : '-translate-x-24'">
|
<div class="w-40 absolute top-0 -right-24 h-full transform transition-transform" :class="!isHovering ? 'translate-x-0' : translateDistance">
|
||||||
<div class="flex h-full items-center">
|
<div class="flex h-full items-center">
|
||||||
<ui-tooltip :text="userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished'" direction="top">
|
<ui-tooltip :text="userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished'" direction="top">
|
||||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
<div class="mx-1" :class="isHovering ? '' : 'ml-6'">
|
<div v-if="userCanUpdate" class="mx-1" :class="isHovering ? '' : 'ml-6'">
|
||||||
<ui-icon-btn icon="edit" borderless @click="clickEdit" />
|
<ui-icon-btn icon="edit" borderless @click="clickEdit" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mx-1">
|
<div v-if="userCanDelete" class="mx-1">
|
||||||
<ui-icon-btn icon="close" borderless @click="removeClick" />
|
<ui-icon-btn icon="close" borderless @click="removeClick" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,6 +71,11 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
translateDistance() {
|
||||||
|
if (!this.userCanUpdate && !this.userCanDelete) return 'translate-x-0'
|
||||||
|
else if (!this.userCanUpdate || !this.userCanDelete) return '-translate-x-12'
|
||||||
|
return '-translate-x-24'
|
||||||
|
},
|
||||||
media() {
|
media() {
|
||||||
return this.book.media || {}
|
return this.book.media || {}
|
||||||
},
|
},
|
||||||
@@ -113,6 +118,12 @@ export default {
|
|||||||
coverWidth() {
|
coverWidth() {
|
||||||
if (this.bookCoverAspectRatio === 1) return this.coverSize * 1.6
|
if (this.bookCoverAspectRatio === 1) return this.coverSize * 1.6
|
||||||
return this.coverSize
|
return this.coverSize
|
||||||
|
},
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
|
},
|
||||||
|
userCanDelete() {
|
||||||
|
return this.$store.getters['user/getUserCanDelete']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -133,10 +133,7 @@ export default {
|
|||||||
if (this.streamIsPlaying) {
|
if (this.streamIsPlaying) {
|
||||||
this.$eventBus.$emit('pause-item')
|
this.$eventBus.$emit('pause-item')
|
||||||
} else {
|
} else {
|
||||||
this.$eventBus.$emit('play-item', {
|
this.$emit('play', this.episode)
|
||||||
libraryItemId: this.libraryItemId,
|
|
||||||
episodeId: this.episode.id
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
toggleFinished(confirmed = false) {
|
toggleFinished(confirmed = false) {
|
||||||
|
|||||||
@@ -4,14 +4,17 @@
|
|||||||
<p class="text-lg mb-0 font-semibold">Episodes</p>
|
<p class="text-lg mb-0 font-semibold">Episodes</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<template v-if="isSelectionMode">
|
<template v-if="isSelectionMode">
|
||||||
<ui-btn color="error" small @click="removeSelectedEpisodes">Remove {{ selectedEpisodes.length }} episode{{ selectedEpisodes.length > 1 ? 's' : '' }}</ui-btn>
|
<ui-tooltip :text="`Mark as ${selectedIsFinished ? 'Not Finished' : 'Finished'}`" direction="bottom">
|
||||||
<ui-btn small class="ml-2" @click="clearSelected">Cancel</ui-btn>
|
<ui-read-icon-btn :disabled="processing" :is-read="selectedIsFinished" @click="toggleBatchFinished" class="mx-1.5" />
|
||||||
|
</ui-tooltip>
|
||||||
|
<ui-btn color="error" :disabled="processing" small class="h-9" @click="removeSelectedEpisodes">Remove {{ selectedEpisodes.length }} episode{{ selectedEpisodes.length > 1 ? 's' : '' }}</ui-btn>
|
||||||
|
<ui-btn :disabled="processing" small class="ml-2 h-9" @click="clearSelected">Cancel</ui-btn>
|
||||||
</template>
|
</template>
|
||||||
<controls-episode-sort-select v-else v-model="sortKey" :descending.sync="sortDesc" class="w-36 sm:w-44 md:w-48 h-9 ml-1 sm:ml-4" />
|
<controls-episode-sort-select v-else v-model="sortKey" :descending.sync="sortDesc" class="w-36 sm:w-44 md:w-48 h-9 ml-1 sm:ml-4" />
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!episodes.length" class="py-4 text-center text-lg">No Episodes</p>
|
<p v-if="!episodes.length" class="py-4 text-center text-lg">No Episodes</p>
|
||||||
<template v-for="episode in episodesSorted">
|
<template v-for="episode in episodesSorted">
|
||||||
<tables-podcast-episode-table-row ref="episodeRow" :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" :selection-mode="isSelectionMode" class="item" @remove="removeEpisode" @edit="editEpisode" @view="viewEpisode" @selected="episodeSelected" />
|
<tables-podcast-episode-table-row ref="episodeRow" :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" :selection-mode="isSelectionMode" class="item" @play="playEpisode" @remove="removeEpisode" @edit="editEpisode" @view="viewEpisode" @selected="episodeSelected" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<modals-podcast-remove-episode v-model="showPodcastRemoveModal" @input="removeEpisodeModalToggled" :library-item="libraryItem" :episodes="episodesToRemove" @clearSelected="clearSelected" />
|
<modals-podcast-remove-episode v-model="showPodcastRemoveModal" @input="removeEpisodeModalToggled" :library-item="libraryItem" :episodes="episodesToRemove" @clearSelected="clearSelected" />
|
||||||
@@ -34,7 +37,8 @@ export default {
|
|||||||
selectedEpisode: null,
|
selectedEpisode: null,
|
||||||
showPodcastRemoveModal: false,
|
showPodcastRemoveModal: false,
|
||||||
selectedEpisodes: [],
|
selectedEpisodes: [],
|
||||||
episodesToRemove: []
|
episodesToRemove: [],
|
||||||
|
processing: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -65,9 +69,40 @@ export default {
|
|||||||
}
|
}
|
||||||
return String(a[this.sortKey]).localeCompare(String(b[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
|
return String(a[this.sortKey]).localeCompare(String(b[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
selectedIsFinished() {
|
||||||
|
// Find an item that is not finished, if none then all items finished
|
||||||
|
return !this.selectedEpisodes.find((episode) => {
|
||||||
|
var itemProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id)
|
||||||
|
return !itemProgress || !itemProgress.isFinished
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
toggleBatchFinished() {
|
||||||
|
this.processing = true
|
||||||
|
var newIsFinished = !this.selectedIsFinished
|
||||||
|
var updateProgressPayloads = this.selectedEpisodes.map((episode) => {
|
||||||
|
return {
|
||||||
|
libraryItemId: this.libraryItem.id,
|
||||||
|
episodeId: episode.id,
|
||||||
|
isFinished: newIsFinished
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.$axios
|
||||||
|
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('Batch update success!')
|
||||||
|
this.processing = false
|
||||||
|
this.clearSelected()
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.$toast.error('Batch update failed')
|
||||||
|
console.error('Failed to batch update read/not read', error)
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
|
},
|
||||||
removeEpisodeModalToggled(val) {
|
removeEpisodeModalToggled(val) {
|
||||||
if (!val) this.episodesToRemove = []
|
if (!val) this.episodesToRemove = []
|
||||||
},
|
},
|
||||||
@@ -91,6 +126,33 @@ export default {
|
|||||||
this.selectedEpisodes = this.selectedEpisodes.filter((ep) => ep.id !== episode.id)
|
this.selectedEpisodes = this.selectedEpisodes.filter((ep) => ep.id !== episode.id)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
playEpisode(episode) {
|
||||||
|
const queueItems = []
|
||||||
|
|
||||||
|
const episodesInListeningOrder = this.episodesCopy.map((ep) => ({ ...ep })).sort((a, b) => String(a.publishedAt).localeCompare(String(b.publishedAt), undefined, { numeric: true, sensitivity: 'base' }))
|
||||||
|
const episodeIndex = episodesInListeningOrder.findIndex((e) => e.id === episode.id)
|
||||||
|
for (let i = episodeIndex; i < episodesInListeningOrder.length; i++) {
|
||||||
|
const episode = episodesInListeningOrder[i]
|
||||||
|
const podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id)
|
||||||
|
if (!podcastProgress || !podcastProgress.isFinished) {
|
||||||
|
queueItems.push({
|
||||||
|
libraryItemId: this.libraryItem.id,
|
||||||
|
episodeId: episode.id,
|
||||||
|
title: episode.title,
|
||||||
|
subtitle: this.mediaMetadata.title,
|
||||||
|
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
|
||||||
|
duration: episode.audioFile.duration || null,
|
||||||
|
coverPath: this.media.coverPath || null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$eventBus.$emit('play-item', {
|
||||||
|
libraryItemId: this.libraryItem.id,
|
||||||
|
episodeId: episode.id,
|
||||||
|
queueItems
|
||||||
|
})
|
||||||
|
},
|
||||||
removeEpisode(episode) {
|
removeEpisode(episode) {
|
||||||
this.episodesToRemove = [episode]
|
this.episodesToRemove = [episode]
|
||||||
this.showPodcastRemoveModal = true
|
this.showPodcastRemoveModal = true
|
||||||
|
|||||||
@@ -55,9 +55,10 @@ export default {
|
|||||||
},
|
},
|
||||||
labelClassname() {
|
labelClassname() {
|
||||||
if (this.labelClass) return this.labelClass
|
if (this.labelClass) return this.labelClass
|
||||||
var classes = ['pl-1']
|
var classes = []
|
||||||
if (this.small) classes.push('text-xs md:text-sm')
|
if (this.small) classes.push('text-xs md:text-sm pl-1')
|
||||||
else if (this.medium) classes.push('text-base md:text-lg')
|
else if (this.medium) classes.push('text-base md:text-lg pl-2')
|
||||||
|
else classes.push('pl-2')
|
||||||
return classes.join(' ')
|
return classes.join(' ')
|
||||||
},
|
},
|
||||||
svgClass() {
|
svgClass() {
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
<p class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
<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">
|
<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="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>
|
||||||
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
<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>
|
<span class="material-icons">expand_more</span>
|
||||||
@@ -15,7 +17,9 @@
|
|||||||
<template v-for="item in items">
|
<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)">
|
<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">
|
<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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
@@ -64,6 +68,9 @@ export default {
|
|||||||
selectedText() {
|
selectedText() {
|
||||||
return this.selectedItem ? this.selectedItem.text : ''
|
return this.selectedItem ? this.selectedItem.text : ''
|
||||||
},
|
},
|
||||||
|
selectedSubtext() {
|
||||||
|
return this.selectedItem ? this.selectedItem.subtext : ''
|
||||||
|
},
|
||||||
buttonClass() {
|
buttonClass() {
|
||||||
var classes = []
|
var classes = []
|
||||||
if (this.small) classes.push('h-9')
|
if (this.small) classes.push('h-9')
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
this.$emit('click')
|
this.$emit('click', e)
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<form @submit.prevent="submitForm">
|
<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 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 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 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>
|
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,13 +5,13 @@
|
|||||||
<form @submit.prevent="submitForm">
|
<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 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 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 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>
|
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item.id)">close</span>
|
||||||
</div>
|
</div>
|
||||||
{{ item[textKey] }}
|
{{ item[textKey] }}
|
||||||
</div>
|
</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>
|
<span class="material-icons text-white hover:text-success pt-px pr-px" style="font-size: 1.1rem" @click.stop="addItem">add</span>
|
||||||
</div>
|
</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" />
|
<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" />
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
||||||
</p>
|
</p>
|
||||||
</slot>
|
</slot>
|
||||||
<ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" @blur="inputBlurred" />
|
<ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" :class="inputClass" @blur="inputBlurred" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -20,7 +20,8 @@ export default {
|
|||||||
default: 'text'
|
default: 'text'
|
||||||
},
|
},
|
||||||
readonly: Boolean,
|
readonly: Boolean,
|
||||||
disabled: Boolean
|
disabled: Boolean,
|
||||||
|
inputClass: String
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -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 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' }">
|
<div class="flex" :style="{ height: height + 'px' }">
|
||||||
<template v-for="(item, index) in items">
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -34,7 +49,8 @@ export default {
|
|||||||
bookshelfView: {
|
bookshelfView: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 1
|
default: 1
|
||||||
}
|
},
|
||||||
|
continueListeningShelf: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
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 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' }">
|
<div class="flex" :style="{ height: height + 'px' }">
|
||||||
<template v-for="(item, index) in items">
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -34,7 +34,8 @@ export default {
|
|||||||
bookshelfView: {
|
bookshelfView: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 1
|
default: 1
|
||||||
}
|
},
|
||||||
|
continueListeningShelf: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -147,6 +147,16 @@ export default {
|
|||||||
margin-top: -2px;
|
margin-top: -2px;
|
||||||
margin-left: -2px;
|
margin-left: -2px;
|
||||||
}
|
}
|
||||||
|
.la-ball-spin-clockwise.la-lg {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
.la-ball-spin-clockwise.la-lg > div {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
margin-top: -4px;
|
||||||
|
margin-left: -4px;
|
||||||
|
}
|
||||||
.la-ball-spin-clockwise.la-2x {
|
.la-ball-spin-clockwise.la-2x {
|
||||||
width: 64px;
|
width: 64px;
|
||||||
height: 64px;
|
height: 64px;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<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">
|
<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">
|
<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>
|
<p>{{ item.text }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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>
|
<template>
|
||||||
<div>
|
<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" />
|
<modals-edit-series-input-inner-modal v-model="showSeriesForm" :selected-series="selectedSeries" :existing-series-names="existingSeriesNames" @submit="submitSeriesForm" />
|
||||||
</div>
|
</div>
|
||||||
@@ -12,7 +12,8 @@ export default {
|
|||||||
value: {
|
value: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
}
|
},
|
||||||
|
disabled: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
+54
-72
@@ -15,6 +15,7 @@
|
|||||||
<modals-podcast-edit-episode />
|
<modals-podcast-edit-episode />
|
||||||
<modals-podcast-view-episode />
|
<modals-podcast-view-episode />
|
||||||
<modals-authors-edit-modal />
|
<modals-authors-edit-modal />
|
||||||
|
<modals-batch-quick-match-model />
|
||||||
<prompt-confirm />
|
<prompt-confirm />
|
||||||
<readers-reader />
|
<readers-reader />
|
||||||
</div>
|
</div>
|
||||||
@@ -111,21 +112,8 @@ export default {
|
|||||||
reconnectFailed() {
|
reconnectFailed() {
|
||||||
console.error('[SOCKET] reconnect failed')
|
console.error('[SOCKET] reconnect failed')
|
||||||
},
|
},
|
||||||
init(payload, count = 0) {
|
init(payload) {
|
||||||
if (!this.$refs.streamContainer) {
|
|
||||||
if (count > 20) {
|
|
||||||
console.error('Stream container never mounted')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setTimeout(() => {
|
|
||||||
this.init(payload, ++count)
|
|
||||||
}, 100)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
console.log('Init Payload', payload)
|
console.log('Init Payload', payload)
|
||||||
if (payload.session) {
|
|
||||||
this.$refs.streamContainer.sessionOpen(payload.session)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start scans currently running
|
// Start scans currently running
|
||||||
if (payload.librariesScanning) {
|
if (payload.librariesScanning) {
|
||||||
@@ -282,6 +270,14 @@ export default {
|
|||||||
|
|
||||||
this.$store.commit('scanners/addUpdate', data)
|
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) {
|
userUpdated(user) {
|
||||||
if (this.$store.state.user.user.id === user.id) {
|
if (this.$store.state.user.user.id === user.id) {
|
||||||
this.$store.commit('user/setUser', user)
|
this.$store.commit('user/setUser', user)
|
||||||
@@ -314,53 +310,6 @@ export default {
|
|||||||
}
|
}
|
||||||
this.$store.commit('user/removeCollection', collection)
|
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) {
|
rssFeedOpen(data) {
|
||||||
this.$store.commit('feeds/addFeed', data)
|
this.$store.commit('feeds/addFeed', data)
|
||||||
},
|
},
|
||||||
@@ -371,6 +320,18 @@ export default {
|
|||||||
// Force refresh
|
// Force refresh
|
||||||
location.reload()
|
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() {
|
initializeSocket() {
|
||||||
this.socket = this.$nuxtSocket({
|
this.socket = this.$nuxtSocket({
|
||||||
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
|
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
|
||||||
@@ -430,18 +391,17 @@ export default {
|
|||||||
this.socket.on('scan_complete', this.scanComplete)
|
this.socket.on('scan_complete', this.scanComplete)
|
||||||
this.socket.on('scan_progress', this.scanProgress)
|
this.socket.on('scan_progress', this.scanProgress)
|
||||||
|
|
||||||
// Download Listeners
|
// Task Listeners
|
||||||
this.socket.on('abmerge_started', this.abmergeStarted)
|
this.socket.on('task_started', this.taskStarted)
|
||||||
this.socket.on('abmerge_ready', this.abmergeReady)
|
this.socket.on('task_finished', this.taskFinished)
|
||||||
this.socket.on('abmerge_failed', this.abmergeFailed)
|
|
||||||
this.socket.on('abmerge_killed', this.abmergeKilled)
|
|
||||||
this.socket.on('abmerge_expired', this.abmergeExpired)
|
|
||||||
|
|
||||||
// Feed Listeners
|
// Feed Listeners
|
||||||
this.socket.on('rss_feed_open', this.rssFeedOpen)
|
this.socket.on('rss_feed_open', this.rssFeedOpen)
|
||||||
this.socket.on('rss_feed_closed', this.rssFeedClosed)
|
this.socket.on('rss_feed_closed', this.rssFeedClosed)
|
||||||
|
|
||||||
this.socket.on('backup_applied', this.backupApplied)
|
this.socket.on('backup_applied', this.backupApplied)
|
||||||
|
|
||||||
|
this.socket.on('batch_quickmatch_complete', this.batchQuickMatchComplete)
|
||||||
},
|
},
|
||||||
showUpdateToast(versionData) {
|
showUpdateToast(versionData) {
|
||||||
var ignoreVersion = localStorage.getItem('ignoreVersion')
|
var ignoreVersion = localStorage.getItem('ignoreVersion')
|
||||||
@@ -535,6 +495,30 @@ export default {
|
|||||||
if (res && res.hasUpdate) this.showUpdateToast(res)
|
if (res && res.hasUpdate) this.showUpdateToast(res)
|
||||||
})
|
})
|
||||||
.catch((err) => console.error(err))
|
.catch((err) => console.error(err))
|
||||||
|
},
|
||||||
|
initLocalStorage() {
|
||||||
|
// If experimental features set in local storage
|
||||||
|
var experimentalFeaturesSaved = localStorage.getItem('experimental')
|
||||||
|
if (experimentalFeaturesSaved === '1') {
|
||||||
|
this.$store.commit('setExperimentalFeatures', true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue auto play
|
||||||
|
var playerQueueAutoPlay = localStorage.getItem('playerQueueAutoPlay')
|
||||||
|
this.$store.commit('setPlayerQueueAutoPlay', playerQueueAutoPlay !== '0')
|
||||||
|
},
|
||||||
|
loadTasks() {
|
||||||
|
this.$axios
|
||||||
|
.$get('/api/tasks')
|
||||||
|
.then((payload) => {
|
||||||
|
console.log('Fetched tasks', payload)
|
||||||
|
if (payload.tasks) {
|
||||||
|
this.$store.commit('tasks/setTasks', payload.tasks)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to load tasks', error)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
beforeMount() {
|
beforeMount() {
|
||||||
@@ -548,14 +532,12 @@ export default {
|
|||||||
|
|
||||||
this.$store.dispatch('libraries/load')
|
this.$store.dispatch('libraries/load')
|
||||||
|
|
||||||
// If experimental features set in local storage
|
this.initLocalStorage()
|
||||||
var experimentalFeaturesSaved = localStorage.getItem('experimental')
|
|
||||||
if (experimentalFeaturesSaved === '1') {
|
|
||||||
this.$store.commit('setExperimentalFeatures', true)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.checkVersionUpdate()
|
this.checkVersionUpdate()
|
||||||
|
|
||||||
|
this.loadTasks()
|
||||||
|
|
||||||
if (this.$route.query.error) {
|
if (this.$route.query.error) {
|
||||||
this.$toast.error(this.$route.query.error)
|
this.$toast.error(this.$route.query.error)
|
||||||
this.$router.replace(this.$route.path)
|
this.$router.replace(this.$route.path)
|
||||||
|
|||||||
+13
-11
@@ -6,13 +6,14 @@ module.exports = {
|
|||||||
target: 'static',
|
target: 'static',
|
||||||
dev: process.env.NODE_ENV !== 'production',
|
dev: process.env.NODE_ENV !== 'production',
|
||||||
env: {
|
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'
|
chromecastReceiver: 'FD1F76C5'
|
||||||
},
|
},
|
||||||
telemetry: false,
|
telemetry: false,
|
||||||
|
|
||||||
publicRuntimeConfig: {
|
publicRuntimeConfig: {
|
||||||
version: pkg.version
|
version: pkg.version,
|
||||||
|
routerBasePath: process.env.ROUTER_BASE_PATH || ''
|
||||||
},
|
},
|
||||||
|
|
||||||
// Global page headers: https://go.nuxtjs.dev/config-head
|
// Global page headers: https://go.nuxtjs.dev/config-head
|
||||||
@@ -28,15 +29,17 @@ module.exports = {
|
|||||||
],
|
],
|
||||||
script: [
|
script: [
|
||||||
{
|
{
|
||||||
src: '/libs/sortable.js'
|
src: (process.env.ROUTER_BASE_PATH || '') + '/libs/sortable.js'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
link: [
|
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
|
// Global CSS: https://go.nuxtjs.dev/config-css
|
||||||
css: [
|
css: [
|
||||||
@@ -73,8 +76,7 @@ module.exports = {
|
|||||||
proxy: {
|
proxy: {
|
||||||
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } },
|
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } },
|
||||||
'/ebook/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
|
'/ebook/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
|
||||||
'/s/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
|
'/s/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' + process.env : '/' },
|
||||||
'/metadata/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' }
|
|
||||||
},
|
},
|
||||||
|
|
||||||
io: {
|
io: {
|
||||||
@@ -89,7 +91,7 @@ module.exports = {
|
|||||||
|
|
||||||
// Axios module configuration: https://go.nuxtjs.dev/config-axios
|
// Axios module configuration: https://go.nuxtjs.dev/config-axios
|
||||||
axios: {
|
axios: {
|
||||||
baseURL: process.env.serverUrl || ''
|
baseURL: process.env.ROUTER_BASE_PATH || ''
|
||||||
},
|
},
|
||||||
|
|
||||||
// nuxt/pwa https://pwa.nuxtjs.org
|
// nuxt/pwa https://pwa.nuxtjs.org
|
||||||
@@ -109,15 +111,15 @@ module.exports = {
|
|||||||
background_color: '#373838',
|
background_color: '#373838',
|
||||||
icons: [
|
icons: [
|
||||||
{
|
{
|
||||||
src: '/icon.svg',
|
src: (process.env.ROUTER_BASE_PATH || '') + '/icon.svg',
|
||||||
sizes: "64x64"
|
sizes: "64x64"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: '/icon.svg',
|
src: (process.env.ROUTER_BASE_PATH || '') + '/icon.svg',
|
||||||
sizes: "192x192"
|
sizes: "192x192"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: '/icon.svg',
|
src: (process.env.ROUTER_BASE_PATH || '') + '/icon.svg',
|
||||||
sizes: "512x512"
|
sizes: "512x512"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.1.4",
|
"version": "2.2.1",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.1.2",
|
"version": "2.2.1",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/axios": "^5.13.6",
|
"@nuxtjs/axios": "^5.13.6",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.1.4",
|
"version": "2.2.1",
|
||||||
"description": "Self-hosted audiobook and podcast client",
|
"description": "Self-hosted audiobook and podcast client",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<p class="text-base">Duration:</p>
|
<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>
|
||||||
|
|
||||||
<div class="flex flex-wrap-reverse justify-center py-4">
|
<div class="flex flex-wrap-reverse justify-center py-4">
|
||||||
@@ -18,11 +18,35 @@
|
|||||||
<p class="text-lg mb-4 font-semibold">Audiobook Chapters</p>
|
<p class="text-lg mb-4 font-semibold">Audiobook Chapters</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-checkbox v-model="showSecondInputs" checkbox-bg="primary" small label-class="text-sm text-gray-200 pl-1" label="Show seconds" class="mx-2" />
|
<ui-checkbox v-model="showSecondInputs" checkbox-bg="primary" small label-class="text-sm text-gray-200 pl-1" label="Show seconds" class="mx-2" />
|
||||||
|
<div class="w-40" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center mb-3 py-1">
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn v-if="newChapters.length > 1" :color="showShiftTimes ? 'bg' : 'primary'" small @click="showShiftTimes = !showShiftTimes">Shift Times</ui-btn>
|
||||||
<ui-btn color="primary" small class="mx-2" @click="showFindChaptersModal = true">Lookup</ui-btn>
|
<ui-btn color="primary" small class="mx-2" @click="showFindChaptersModal = true">Lookup</ui-btn>
|
||||||
<ui-btn color="success" small @click="saveChapters">Save</ui-btn>
|
<ui-btn color="success" small @click="saveChapters">Save</ui-btn>
|
||||||
<div class="w-40" />
|
<div class="w-40" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-hidden">
|
||||||
|
<transition name="slide">
|
||||||
|
<div v-if="showShiftTimes" class="flex mb-4">
|
||||||
|
<div class="w-12"></div>
|
||||||
|
<div class="flex-grow">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<p class="text-sm mb-1 font-semibold pr-2">Time to shift in seconds</p>
|
||||||
|
<ui-text-input v-model="shiftAmount" type="number" class="max-w-20" style="height: 30px" />
|
||||||
|
<ui-btn color="primary" class="mx-1" small @click="shiftChapterTimes">Add</ui-btn>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<span class="material-icons text-gray-200 hover:text-white cursor-pointer" @click="showShiftTimes = false">close</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs py-1.5 text-gray-300 max-w-md">Note: First chapter start time must remain at 0:00 and the last chapter start time cannot exceed this audiobooks duration.</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-40"></div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
|
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
|
||||||
<div class="w-12"></div>
|
<div class="w-12"></div>
|
||||||
<div class="w-32 px-2">Start</div>
|
<div class="w-32 px-2">Start</div>
|
||||||
@@ -81,7 +105,7 @@
|
|||||||
<p class="text-xs truncate max-w-sm">{{ track.metadata.filename }}</p>
|
<p class="text-xs truncate max-w-sm">{{ track.metadata.filename }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-20" style="min-width: 80px">
|
<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>
|
||||||
<div class="w-20 flex justify-center" style="min-width: 80px">
|
<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>
|
<span v-if="(track.chapters || []).length" class="material-icons text-success text-sm">check</span>
|
||||||
@@ -107,10 +131,16 @@
|
|||||||
<ui-btn small color="primary" class="mt-5 ml-2" @click="findChapters">Find</ui-btn>
|
<ui-btn small color="primary" class="mt-5 ml-2" @click="findChapters">Find</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="w-full p-4">
|
<div v-else class="w-full p-4">
|
||||||
<p class="mb-4">Duration found: {{ chapterData.runtimeLengthSec }}</p>
|
<div class="flex justify-between mb-4">
|
||||||
<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>
|
||||||
<p>Chapter data invalid duration<br />Your media duration is shorter than duration found</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>
|
</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="flex py-0.5 text-xs font-semibold uppercase text-gray-300 mb-1">
|
||||||
<div class="w-24 px-2">Start</div>
|
<div class="w-24 px-2">Start</div>
|
||||||
@@ -126,7 +156,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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" />
|
<div class="flex-grow" />
|
||||||
<ui-btn small color="success" @click="applyChapterData">Apply Chapters</ui-btn>
|
<ui-btn small color="success" @click="applyChapterData">Apply Chapters</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
@@ -166,6 +210,8 @@ export default {
|
|||||||
return {
|
return {
|
||||||
newChapters: [],
|
newChapters: [],
|
||||||
selectedChapter: null,
|
selectedChapter: null,
|
||||||
|
showShiftTimes: false,
|
||||||
|
shiftAmount: 0,
|
||||||
audioEl: null,
|
audioEl: null,
|
||||||
isPlayingChapter: false,
|
isPlayingChapter: false,
|
||||||
isLoadingChapter: false,
|
isLoadingChapter: false,
|
||||||
@@ -197,6 +243,9 @@ export default {
|
|||||||
mediaDuration() {
|
mediaDuration() {
|
||||||
return this.media.duration
|
return this.media.duration
|
||||||
},
|
},
|
||||||
|
mediaDurationRounded() {
|
||||||
|
return Math.round(this.mediaDuration)
|
||||||
|
},
|
||||||
chapters() {
|
chapters() {
|
||||||
return this.media.chapters || []
|
return this.media.chapters || []
|
||||||
},
|
},
|
||||||
@@ -214,6 +263,32 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
shiftChapterTimes() {
|
||||||
|
if (!this.shiftAmount || isNaN(this.shiftAmount) || this.newChapters.length <= 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = Number(this.shiftAmount)
|
||||||
|
|
||||||
|
const lastChapter = this.newChapters[this.newChapters.length - 1]
|
||||||
|
if (lastChapter.start + amount > this.mediaDurationRounded) {
|
||||||
|
this.$toast.error('Invalid shift amount. Last chapter start time would extend beyond the duration of this audiobook.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.newChapters[0].end + amount <= 0) {
|
||||||
|
this.$toast.error('Invalid shift amount. First chapter would have zero or negative length.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < this.newChapters.length; i++) {
|
||||||
|
const chap = this.newChapters[i]
|
||||||
|
chap.end = Math.min(chap.end + amount, this.mediaDuration)
|
||||||
|
if (i > 0) {
|
||||||
|
chap.start = Math.max(0, chap.start + amount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
editItem() {
|
editItem() {
|
||||||
this.$store.commit('showEditModal', this.libraryItem)
|
this.$store.commit('showEditModal', this.libraryItem)
|
||||||
},
|
},
|
||||||
@@ -256,7 +331,6 @@ export default {
|
|||||||
console.log('Chapter already playing', this.isLoadingChapter, this.isPlayingChapter)
|
console.log('Chapter already playing', this.isLoadingChapter, this.isPlayingChapter)
|
||||||
if (this.isLoadingChapter) return
|
if (this.isLoadingChapter) return
|
||||||
if (this.isPlayingChapter) {
|
if (this.isPlayingChapter) {
|
||||||
console.log('Destroying chapter')
|
|
||||||
this.destroyAudioEl()
|
this.destroyAudioEl()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -282,7 +356,7 @@ export default {
|
|||||||
const audioEl = this.audioEl || document.createElement('audio')
|
const audioEl = this.audioEl || document.createElement('audio')
|
||||||
var src = audioTrack.contentUrl + `?token=${this.userToken}`
|
var src = audioTrack.contentUrl + `?token=${this.userToken}`
|
||||||
if (this.$isDev) {
|
if (this.$isDev) {
|
||||||
src = `http://localhost:3333${src}`
|
src = `http://localhost:3333${this.$config.routerBasePath}${src}`
|
||||||
}
|
}
|
||||||
console.log('src', src)
|
console.log('src', src)
|
||||||
|
|
||||||
@@ -370,6 +444,16 @@ export default {
|
|||||||
this.$toast.error('Failed to update chapters')
|
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() {
|
applyChapterData() {
|
||||||
var index = 0
|
var index = 0
|
||||||
this.newChapters = this.chapterData.chapters
|
this.newChapters = this.chapterData.chapters
|
||||||
@@ -427,6 +511,9 @@ export default {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.destroyAudioEl()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
<template>
|
||||||
|
<div id="page-wrapper" class="bg-bg page p-8 overflow-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
|
<div class="flex items-center justify-center mb-6">
|
||||||
|
<div class="w-full max-w-2xl">
|
||||||
|
<p class="text-2xl mb-2">Audiobook File Management Tools</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-full max-w-2xl">
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<ui-dropdown v-model="selectedTool" :items="availableTools" :disabled="processing" class="max-w-sm" @input="selectedToolUpdated" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<div class="w-full max-w-2xl">
|
||||||
|
<p class="text-xl mb-1">Metadata to embed</p>
|
||||||
|
<p class="mb-2 text-base text-gray-300">audiobookshelf uses <a href="https://github.com/sandreas/tone" target="_blank" class="hover:underline text-blue-400 hover:text-blue-300">tone</a> to write metadata.</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-full max-w-2xl"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center flex-wrap">
|
||||||
|
<div class="w-full max-w-2xl border border-white border-opacity-10 bg-bg mx-2">
|
||||||
|
<div class="flex py-2 px-4">
|
||||||
|
<div class="w-1/3 text-xs font-semibold uppercase text-gray-200">Meta Tag</div>
|
||||||
|
<div class="w-2/3 text-xs font-semibold uppercase text-gray-200">Value</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full max-h-72 overflow-auto">
|
||||||
|
<template v-for="(value, key, index) in toneObject">
|
||||||
|
<div :key="key" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
|
||||||
|
<div class="w-1/3 font-semibold">{{ key }}</div>
|
||||||
|
<div class="w-2/3">
|
||||||
|
{{ value }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full max-w-2xl border border-white border-opacity-10 bg-bg mx-2">
|
||||||
|
<div class="flex py-2 px-4 bg-primary bg-opacity-25">
|
||||||
|
<div class="flex-grow text-xs font-semibold uppercase text-gray-200">Chapter Title</div>
|
||||||
|
<div class="w-24 text-xs font-semibold uppercase text-gray-200">Start</div>
|
||||||
|
<div class="w-24 text-xs font-semibold uppercase text-gray-200">End</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full max-h-72 overflow-auto">
|
||||||
|
<p v-if="!metadataChapters.length" class="py-5 text-center text-gray-200">No chapters</p>
|
||||||
|
<template v-for="(chapter, index) in metadataChapters">
|
||||||
|
<div :key="index" class="flex py-1 px-4 text-sm" :class="index % 2 === 1 ? 'bg-primary bg-opacity-25' : ''">
|
||||||
|
<div class="flex-grow font-semibold">{{ chapter.title }}</div>
|
||||||
|
<div class="w-24">
|
||||||
|
{{ $secondsToTimestamp(chapter.start) }}
|
||||||
|
</div>
|
||||||
|
<div class="w-24">
|
||||||
|
{{ $secondsToTimestamp(chapter.end) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full h-px bg-white bg-opacity-10 my-8" />
|
||||||
|
|
||||||
|
<div class="w-full max-w-4xl mx-auto">
|
||||||
|
<div v-if="selectedTool === 'embed'" class="w-full flex justify-end items-center mb-4">
|
||||||
|
<ui-btn v-if="!isFinished" color="primary" :loading="processing" @click.stop="embedClick">Start Metadata Embed</ui-btn>
|
||||||
|
<p v-else class="text-success text-lg font-semibold">Embed Finished!</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="w-full flex justify-end items-center mb-4">
|
||||||
|
<ui-btn v-if="!isTaskFinished && processing" color="error" :loading="isCancelingEncode" class="mr-2" @click.stop="cancelEncodeClick">Cancel Encode</ui-btn>
|
||||||
|
<ui-btn v-if="!isTaskFinished" color="primary" :loading="processing" @click.stop="encodeM4bClick">Start M4B Encode</ui-btn>
|
||||||
|
<p v-else-if="taskFailed" class="text-error text-lg font-semibold">M4B Failed! {{ taskError }}</p>
|
||||||
|
<p v-else class="text-success text-lg font-semibold">M4B Finished!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<div v-if="selectedTool === 'embed'" class="flex items-start mb-2">
|
||||||
|
<span class="material-icons text-base text-warning pt-1">star</span>
|
||||||
|
<p class="text-gray-200 ml-2">Metadata will be embedded on the audio tracks inside your audiobook folder.</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex items-start mb-2">
|
||||||
|
<span class="material-icons text-base text-warning pt-1">star</span>
|
||||||
|
<p class="text-gray-200 ml-2">
|
||||||
|
Finished M4B will be put into your audiobook folder at <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">.../{{ libraryItemRelPath }}/</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start mb-2">
|
||||||
|
<span class="material-icons text-base text-warning pt-1">star</span>
|
||||||
|
<p class="text-gray-200 ml-2">
|
||||||
|
A backup of your original audio files will be stored in <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">/metadata/cache/items/{{ libraryItemId }}/</span>. Make sure to periodically purge items cache.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedTool === 'm4b'" class="flex items-start mb-2">
|
||||||
|
<span class="material-icons text-base text-warning pt-1">star</span>
|
||||||
|
<p class="text-gray-200 ml-2">Encoding can take up to 30 minutes.</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedTool === 'm4b'" class="flex items-start mb-2">
|
||||||
|
<span class="material-icons text-base text-warning pt-1">star</span>
|
||||||
|
<p class="text-gray-200 ml-2">If you have the watcher disabled you will need to re-scan this audiobook afterwards.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start mb-2">
|
||||||
|
<span class="material-icons text-base text-warning pt-1">star</span>
|
||||||
|
<p class="text-gray-200 ml-2">Once the task is started you can navigate away from this page.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full max-w-4xl mx-auto">
|
||||||
|
<p class="mb-2 font-semibold">Audio Tracks</p>
|
||||||
|
<div class="w-full mx-auto border border-white border-opacity-10 bg-bg">
|
||||||
|
<div class="flex py-2 px-4 bg-primary bg-opacity-25">
|
||||||
|
<div class="w-10 text-xs font-semibold text-gray-200">#</div>
|
||||||
|
<div class="flex-grow text-xs font-semibold uppercase text-gray-200">Filename</div>
|
||||||
|
<div class="w-16 text-xs font-semibold uppercase text-gray-200">Size</div>
|
||||||
|
<div class="w-24"></div>
|
||||||
|
</div>
|
||||||
|
<template v-for="file in audioFiles">
|
||||||
|
<div :key="file.index" class="flex py-2 px-4 text-sm" :class="file.index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
|
||||||
|
<div class="w-10">{{ file.index }}</div>
|
||||||
|
<div class="flex-grow">
|
||||||
|
{{ file.metadata.filename }}
|
||||||
|
</div>
|
||||||
|
<div class="w-16 font-mono text-gray-200">
|
||||||
|
{{ $bytesPretty(file.metadata.size) }}
|
||||||
|
</div>
|
||||||
|
<div class="w-24">
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<span v-if="audiofilesFinished[file.ino]" class="material-icons text-xl text-success leading-none">check_circle</span>
|
||||||
|
<div v-else-if="audiofilesEncoding[file.ino]">
|
||||||
|
<widgets-loading-spinner />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
async asyncData({ store, params, app, redirect, route }) {
|
||||||
|
if (!store.state.user.user) {
|
||||||
|
return redirect(`/login?redirect=${route.path}`)
|
||||||
|
}
|
||||||
|
if (!store.getters['user/getIsAdminOrUp']) {
|
||||||
|
return redirect('/?error=unauthorized')
|
||||||
|
}
|
||||||
|
var libraryItem = await app.$axios.$get(`/api/items/${params.id}?expanded=1`).catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if (!libraryItem) {
|
||||||
|
console.error('Not found...', params.id)
|
||||||
|
return redirect('/?error=not found')
|
||||||
|
}
|
||||||
|
if (libraryItem.mediaType !== 'book') {
|
||||||
|
console.error('Invalid media type')
|
||||||
|
return redirect('/?error=invalid media type')
|
||||||
|
}
|
||||||
|
if (!libraryItem.media.audioFiles.length) {
|
||||||
|
cnosole.error('No audio files')
|
||||||
|
return redirect('/?error=no audio files')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
libraryItem
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false,
|
||||||
|
audiofilesEncoding: {},
|
||||||
|
audiofilesFinished: {},
|
||||||
|
isFinished: false,
|
||||||
|
toneObject: null,
|
||||||
|
selectedTool: 'embed',
|
||||||
|
isCancelingEncode: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
task: {
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.taskUpdated(newVal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
libraryItemId() {
|
||||||
|
return this.libraryItem.id
|
||||||
|
},
|
||||||
|
libraryItemRelPath() {
|
||||||
|
return this.libraryItem.relPath
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem.media || {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
audioFiles() {
|
||||||
|
return (this.media.audioFiles || []).filter((af) => !af.exclude && !af.invalid)
|
||||||
|
},
|
||||||
|
isSingleM4b() {
|
||||||
|
return this.audioFiles.length === 1 && this.audioFiles[0].metadata.ext.toLowerCase() === '.m4b'
|
||||||
|
},
|
||||||
|
streamLibraryItem() {
|
||||||
|
return this.$store.state.streamLibraryItem
|
||||||
|
},
|
||||||
|
metadataChapters() {
|
||||||
|
return this.media.chapters || []
|
||||||
|
},
|
||||||
|
availableTools() {
|
||||||
|
if (this.isSingleM4b) {
|
||||||
|
return [{ value: 'embed', text: 'Embed Metadata' }]
|
||||||
|
} else {
|
||||||
|
return [
|
||||||
|
{ value: 'embed', text: 'Embed Metadata' },
|
||||||
|
{ value: 'm4b', text: 'M4B Encoder' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
taskFailed() {
|
||||||
|
return this.isTaskFinished && this.task.isFailed
|
||||||
|
},
|
||||||
|
taskError() {
|
||||||
|
return this.taskFailed ? this.task.error || 'Unknown Error' : null
|
||||||
|
},
|
||||||
|
isTaskFinished() {
|
||||||
|
return this.task && this.task.isFinished
|
||||||
|
},
|
||||||
|
task() {
|
||||||
|
return this.$store.getters['tasks/getTaskByLibraryItemId'](this.libraryItemId)
|
||||||
|
},
|
||||||
|
taskRunning() {
|
||||||
|
return this.task && !this.task.isFinished
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
cancelEncodeClick() {
|
||||||
|
this.isCancelingEncode = true
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/encode-m4b/${this.libraryItemId}/cancel`)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('Encode canceled')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to cancel encode', error)
|
||||||
|
this.$toast.error('Failed to cancel encode')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.isCancelingEncode = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
encodeM4bClick() {
|
||||||
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$get(`/api/encode-m4b/${this.libraryItemId}`)
|
||||||
|
.then(() => {
|
||||||
|
console.log('Ab m4b merge started')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
|
this.processing = true
|
||||||
|
})
|
||||||
|
},
|
||||||
|
embedClick() {
|
||||||
|
const payload = {
|
||||||
|
message: `Are you sure you want to embed metadata in ${this.audioFiles.length} audio files?`,
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.updateAudioFileMetadata()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
|
},
|
||||||
|
updateAudioFileMetadata() {
|
||||||
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$get(`/api/items/${this.libraryItemId}/audio-metadata?tone=1`)
|
||||||
|
.then(() => {
|
||||||
|
console.log('Audio metadata encode started')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Audio metadata encode failed', error)
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
audioMetadataStarted(data) {
|
||||||
|
console.log('audio metadata started', data)
|
||||||
|
if (data.libraryItemId !== this.libraryItemId) return
|
||||||
|
this.audiofilesFinished = {}
|
||||||
|
},
|
||||||
|
audioMetadataFinished(data) {
|
||||||
|
console.log('audio metadata finished', data)
|
||||||
|
if (data.libraryItemId !== this.libraryItemId) return
|
||||||
|
this.processing = false
|
||||||
|
this.isFinished = true
|
||||||
|
this.audiofilesEncoding = {}
|
||||||
|
this.$toast.success('Audio file metadata updated')
|
||||||
|
},
|
||||||
|
audiofileMetadataStarted(data) {
|
||||||
|
if (data.libraryItemId !== this.libraryItemId) return
|
||||||
|
this.$set(this.audiofilesEncoding, data.ino, true)
|
||||||
|
},
|
||||||
|
audiofileMetadataFinished(data) {
|
||||||
|
if (data.libraryItemId !== this.libraryItemId) return
|
||||||
|
this.$set(this.audiofilesEncoding, data.ino, false)
|
||||||
|
this.$set(this.audiofilesFinished, data.ino, true)
|
||||||
|
},
|
||||||
|
selectedToolUpdated() {
|
||||||
|
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + `?tool=${this.selectedTool}`
|
||||||
|
window.history.replaceState({ path: newurl }, '', newurl)
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.fetchToneObject()
|
||||||
|
if (this.$route.query.tool === 'm4b') {
|
||||||
|
if (this.availableTools.some((t) => t.value === 'm4b')) {
|
||||||
|
this.selectedTool = 'm4b'
|
||||||
|
} else {
|
||||||
|
this.selectedToolUpdated()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.task) this.taskUpdated(this.task)
|
||||||
|
},
|
||||||
|
fetchToneObject() {
|
||||||
|
this.$axios
|
||||||
|
.$get(`/api/items/${this.libraryItemId}/tone-object`)
|
||||||
|
.then((toneObject) => {
|
||||||
|
delete toneObject.CoverFile
|
||||||
|
this.toneObject = toneObject
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to fetch tone object', error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
taskUpdated(task) {
|
||||||
|
this.processing = !task.isFinished
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.init()
|
||||||
|
this.$root.socket.on('audio_metadata_started', this.audioMetadataStarted)
|
||||||
|
this.$root.socket.on('audio_metadata_finished', this.audioMetadataFinished)
|
||||||
|
this.$root.socket.on('audiofile_metadata_started', this.audiofileMetadataStarted)
|
||||||
|
this.$root.socket.on('audiofile_metadata_finished', this.audiofileMetadataFinished)
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.$root.socket.off('audio_metadata_started', this.audioMetadataStarted)
|
||||||
|
this.$root.socket.off('audio_metadata_finished', this.audioMetadataFinished)
|
||||||
|
this.$root.socket.off('audiofile_metadata_started', this.audiofileMetadataStarted)
|
||||||
|
this.$root.socket.off('audiofile_metadata_finished', this.audiofileMetadataFinished)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
async asyncData({ store, app, params, redirect }) {
|
async asyncData({ store, app, params, redirect }) {
|
||||||
const author = await app.$axios.$get(`/api/authors/${params.id}?include=items,series`).catch((error) => {
|
const author = await app.$axios.$get(`/api/authors/${params.id}?library=${store.state.libraries.currentLibraryId}&include=items,series`).catch((error) => {
|
||||||
console.error('Failed to get author', error)
|
console.error('Failed to get author', error)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,9 +19,9 @@
|
|||||||
{{ streaming ? 'Streaming' : 'Play' }}
|
{{ streaming ? 'Streaming' : 'Play' }}
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
|
|
||||||
<ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" />
|
<ui-icon-btn v-if="userCanUpdate" icon="edit" class="mx-0.5" @click="editClick" />
|
||||||
|
|
||||||
<ui-icon-btn icon="delete" class="mx-0.5" @click="removeClick" />
|
<ui-icon-btn v-if="userCanDelete" icon="delete" class="mx-0.5" @click="removeClick" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="my-8 max-w-2xl">
|
<div class="my-8 max-w-2xl">
|
||||||
@@ -92,6 +92,12 @@ export default {
|
|||||||
},
|
},
|
||||||
showPlayButton() {
|
showPlayButton() {
|
||||||
return this.playableBooks.length
|
return this.playableBooks.length
|
||||||
|
},
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
|
},
|
||||||
|
userCanDelete() {
|
||||||
|
return this.$store.getters['user/getUserCanDelete']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -25,7 +25,9 @@
|
|||||||
<div class="flex items-center py-2">
|
<div class="flex items-center py-2">
|
||||||
<ui-text-input type="number" v-model="backupsToKeep" no-spinner :disabled="updatingServerSettings" :padding-x="1" text-center class="w-10" @change="updateBackupsSettings" />
|
<ui-text-input type="number" v-model="backupsToKeep" no-spinner :disabled="updatingServerSettings" :padding-x="1" text-center class="w-10" @change="updateBackupsSettings" />
|
||||||
|
|
||||||
<p class="pl-4 text-lg">Number of backups to keep</p>
|
<ui-tooltip :text="numBackupsToKeepTooltip">
|
||||||
|
<p class="pl-4 text-lg">Number of backups to keep <span class="material-icons icon-text">info_outlined</span></p>
|
||||||
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="flex items-center py-2">
|
||||||
@@ -53,7 +55,10 @@ export default {
|
|||||||
maxBackupSize: 1,
|
maxBackupSize: 1,
|
||||||
cronExpression: '',
|
cronExpression: '',
|
||||||
newServerSettings: {},
|
newServerSettings: {},
|
||||||
showCronBuilder: false
|
showCronBuilder: false,
|
||||||
|
backupsTooltip: 'Backups saved in /metadata/backups',
|
||||||
|
numBackupsToKeepTooltip: 'Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.',
|
||||||
|
maxBackupSizeTooltip: 'As a safeguard against misconfiguration, backups will fail if they exceed the configured size.'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -65,12 +70,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
backupsTooltip() {
|
|
||||||
return 'Backups saved in /metadata/backups'
|
|
||||||
},
|
|
||||||
maxBackupSizeTooltip() {
|
|
||||||
return 'As a safeguard against misconfiguration, backups will fail if they exceed the configured size.'
|
|
||||||
},
|
|
||||||
serverSettings() {
|
serverSettings() {
|
||||||
return this.$store.state.serverSettings
|
return this.$store.state.serverSettings
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -172,17 +172,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="flex items-center py-2">
|
||||||
<div class="flex items-center">
|
<ui-toggle-switch v-model="showExperimentalFeatures" />
|
||||||
<ui-toggle-switch v-model="showExperimentalFeatures" />
|
<ui-tooltip :text="tooltips.experimentalFeatures">
|
||||||
<ui-tooltip :text="tooltips.experimentalFeatures">
|
<p class="pl-4">
|
||||||
<p class="pl-4">
|
Experimental Features
|
||||||
Experimental Features
|
<a href="https://github.com/advplyr/audiobookshelf/discussions/75" target="_blank">
|
||||||
<a href="https://github.com/advplyr/audiobookshelf/discussions/75" target="_blank">
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
</a>
|
||||||
</a>
|
</p>
|
||||||
</p>
|
</ui-tooltip>
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="flex items-center py-2">
|
||||||
@@ -196,10 +194,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- <div class="flex items-center py-2">
|
<!-- <div class="flex items-center py-2">
|
||||||
<ui-toggle-switch v-model="newServerSettings.scannerUseSingleThreadedProber" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerUseSingleThreadedProber', val)" />
|
<ui-toggle-switch v-model="newServerSettings.scannerUseTone" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerUseTone', val)" />
|
||||||
<ui-tooltip :text="tooltips.scannerUseSingleThreadedProber">
|
<ui-tooltip text="Tone library for metadata">
|
||||||
<p class="pl-4">
|
<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>
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
</p>
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
@@ -211,8 +209,12 @@
|
|||||||
<div class="h-0.5 bg-primary bg-opacity-30 w-full" />
|
<div class="h-0.5 bg-primary bg-opacity-30 w-full" />
|
||||||
|
|
||||||
<div class="flex items-center py-4">
|
<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>
|
<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" />
|
<div class="flex-grow" />
|
||||||
<p class="pr-2 text-sm font-book text-yellow-400">
|
<p class="pr-2 text-sm font-book text-yellow-400">
|
||||||
Report bugs, request features, and contribute on
|
Report bugs, request features, and contribute on
|
||||||
@@ -423,7 +425,7 @@ export default {
|
|||||||
this.showConfirmPurgeCache = false
|
this.showConfirmPurgeCache = false
|
||||||
this.isPurgingCache = true
|
this.isPurgingCache = true
|
||||||
await this.$axios
|
await this.$axios
|
||||||
.$post('/api/purgecache')
|
.$post('/api/cache/purge')
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success('Cache Purged!')
|
this.$toast.success('Cache Purged!')
|
||||||
})
|
})
|
||||||
@@ -432,6 +434,31 @@ export default {
|
|||||||
this.$toast.error('Failed to purge cache')
|
this.$toast.error('Failed to purge cache')
|
||||||
})
|
})
|
||||||
this.isPurgingCache = false
|
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() {
|
mounted() {
|
||||||
|
|||||||
@@ -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="w-full h-px bg-white bg-opacity-10 my-2" />
|
||||||
<div class="py-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>
|
<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">
|
<tr class="bg-primary bg-opacity-40">
|
||||||
<th class="w-16 text-left">Item</th>
|
<th class="w-16 text-left">Item</th>
|
||||||
<th class="text-left"></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">Started At</th>
|
||||||
<th class="w-40 hidden sm:table-cell">Last Update</th>
|
<th class="w-40 hidden sm:table-cell">Last Update</th>
|
||||||
</tr>
|
</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>
|
<td>
|
||||||
<covers-book-cover :width="50" :library-item="item" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover :width="50" :library-item="item" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</td>
|
</td>
|
||||||
<td class="font-book">
|
<td class="font-book">
|
||||||
<p>{{ item.media && item.media.metadata ? item.media.metadata.title : 'Unknown' }}</p>
|
<template v-if="item.media && item.media.metadata && item.episode">
|
||||||
<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>
|
<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>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<p class="text-sm">{{ Math.floor(item.progress * 100) }}%</p>
|
<p class="text-sm">{{ Math.floor(item.progress * 100) }}%</p>
|
||||||
@@ -98,7 +111,8 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
listeningSessions: [],
|
listeningSessions: [],
|
||||||
listeningStats: {}
|
listeningStats: {},
|
||||||
|
purgingMediaProgress: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -117,6 +131,12 @@ export default {
|
|||||||
mediaProgress() {
|
mediaProgress() {
|
||||||
return this.user.mediaProgress.sort((a, b) => b.lastUpdate - a.lastUpdate)
|
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() {
|
totalListeningTime() {
|
||||||
return this.listeningStats.totalTime || 0
|
return this.listeningStats.totalTime || 0
|
||||||
},
|
},
|
||||||
@@ -150,6 +170,24 @@ export default {
|
|||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
console.log('Loaded user listening data', this.listeningSessions, this.listeningStats)
|
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() {
|
mounted() {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<!-- Item Cover Overlay -->
|
<!-- Item Cover Overlay -->
|
||||||
<div class="absolute top-0 left-0 w-full h-full z-10 bg-black bg-opacity-30 opacity-0 hover:opacity-100 transition-opacity" @mousedown.prevent @mouseup.prevent>
|
<div class="absolute top-0 left-0 w-full h-full z-10 bg-black bg-opacity-30 opacity-0 hover:opacity-100 transition-opacity" @mousedown.prevent @mouseup.prevent>
|
||||||
<div v-show="showPlayButton && !isStreaming" class="h-full flex items-center justify-center pointer-events-none">
|
<div v-show="showPlayButton && !isStreaming" class="h-full flex items-center justify-center pointer-events-none">
|
||||||
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" @click.stop.prevent="startStream">
|
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" @click.stop.prevent="playItem">
|
||||||
<span class="material-icons text-4xl">play_circle_filled</span>
|
<span class="material-icons text-4xl">play_circle_filled</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
|
|
||||||
<!-- Icon buttons -->
|
<!-- Icon buttons -->
|
||||||
<div class="flex items-center justify-center md:justify-start pt-4">
|
<div class="flex items-center justify-center md:justify-start pt-4">
|
||||||
<ui-btn v-if="showPlayButton" :disabled="isStreaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="startStream">
|
<ui-btn v-if="showPlayButton" :disabled="isStreaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="playItem">
|
||||||
<span v-show="!isStreaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
|
<span v-show="!isStreaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
|
||||||
{{ isStreaming ? 'Playing' : 'Play' }}
|
{{ isStreaming ? 'Playing' : 'Play' }}
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
@@ -150,7 +150,7 @@
|
|||||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="mx-0.5" @click="toggleFinished" />
|
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="mx-0.5" @click="toggleFinished" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
<ui-tooltip v-if="!isPodcast" text="Collections" direction="top">
|
<ui-tooltip v-if="!isPodcast && userCanUpdate" text="Collections" direction="top">
|
||||||
<ui-icon-btn icon="collections_bookmark" class="mx-0.5" outlined @click="collectionsClick" />
|
<ui-icon-btn icon="collections_bookmark" class="mx-0.5" outlined @click="collectionsClick" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
@@ -429,14 +429,14 @@ export default {
|
|||||||
message: `Start playback for "${this.title}" at ${this.$secondsToTimestamp(bookmark.time)}?`,
|
message: `Start playback for "${this.title}" at ${this.$secondsToTimestamp(bookmark.time)}?`,
|
||||||
callback: (confirmed) => {
|
callback: (confirmed) => {
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
this.startStream(bookmark.time)
|
this.playItem(bookmark.time)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
type: 'yesNo'
|
type: 'yesNo'
|
||||||
}
|
}
|
||||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
} else {
|
} else {
|
||||||
this.startStream(bookmark.time)
|
this.playItem(bookmark.time)
|
||||||
}
|
}
|
||||||
this.showBookmarksModal = false
|
this.showBookmarksModal = false
|
||||||
},
|
},
|
||||||
@@ -515,21 +515,43 @@ export default {
|
|||||||
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
startStream(startTime = null) {
|
playItem(startTime = null) {
|
||||||
var episodeId = null
|
var episodeId = null
|
||||||
|
const queueItems = []
|
||||||
if (this.isPodcast) {
|
if (this.isPodcast) {
|
||||||
var episode = this.podcastEpisodes.find((ep) => {
|
const episodesInListeningOrder = this.podcastEpisodes.map((ep) => ({ ...ep })).sort((a, b) => String(a.publishedAt).localeCompare(String(b.publishedAt), undefined, { numeric: true, sensitivity: 'base' }))
|
||||||
|
|
||||||
|
// Find most recent episode unplayed
|
||||||
|
var episodeIndex = episodesInListeningOrder.findLastIndex((ep) => {
|
||||||
var podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, ep.id)
|
var podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, ep.id)
|
||||||
return !podcastProgress || !podcastProgress.isFinished
|
return !podcastProgress || !podcastProgress.isFinished
|
||||||
})
|
})
|
||||||
if (!episode) episode = this.podcastEpisodes[0]
|
if (episodeIndex < 0) episodeIndex = 0
|
||||||
episodeId = episode.id
|
|
||||||
|
episodeId = episodesInListeningOrder[episodeIndex].id
|
||||||
|
|
||||||
|
for (let i = episodeIndex; i < episodesInListeningOrder.length; i++) {
|
||||||
|
const episode = episodesInListeningOrder[i]
|
||||||
|
const podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, episode.id)
|
||||||
|
if (!podcastProgress || !podcastProgress.isFinished) {
|
||||||
|
queueItems.push({
|
||||||
|
libraryItemId: this.libraryItemId,
|
||||||
|
episodeId: episode.id,
|
||||||
|
title: episode.title,
|
||||||
|
subtitle: this.title,
|
||||||
|
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
|
||||||
|
duration: episode.audioFile.duration || null,
|
||||||
|
coverPath: this.libraryItem.media.coverPath || null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$eventBus.$emit('play-item', {
|
this.$eventBus.$emit('play-item', {
|
||||||
libraryItemId: this.libraryItem.id,
|
libraryItemId: this.libraryItem.id,
|
||||||
episodeId,
|
episodeId,
|
||||||
startTime
|
startTime,
|
||||||
|
queueItems
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
editClick() {
|
editClick() {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
<app-book-shelf-toolbar :page="id || ''" :view-mode.sync="viewMode" />
|
<app-book-shelf-toolbar :page="id || ''" :view-mode.sync="viewMode"/>
|
||||||
<app-lazy-bookshelf :page="id || ''" :view-mode="viewMode" />
|
<app-lazy-bookshelf :page="id || ''" :view-mode="viewMode" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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' : ''">
|
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
<app-book-shelf-toolbar page="podcast-search" />
|
<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">
|
<div class="w-full max-w-4xl mx-auto flex">
|
||||||
<form @submit.prevent="submit" class="flex flex-grow">
|
<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" />
|
<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
|
return this.$store.state.streamLibraryItem
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {},
|
methods: {
|
||||||
beforeDestroy() {}
|
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>
|
</script>
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ export default class PlayerHandler {
|
|||||||
|
|
||||||
// TODO: Add listening time between last sync and now?
|
// TODO: Add listening time between last sync and now?
|
||||||
this.sendProgressSync(currentTime)
|
this.sendProgressSync(currentTime)
|
||||||
|
|
||||||
|
this.ctx.mediaFinished(this.libraryItemId, this.episodeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
playerStateChange(state) {
|
playerStateChange(state) {
|
||||||
@@ -294,7 +296,7 @@ export default class PlayerHandler {
|
|||||||
currentTime
|
currentTime
|
||||||
}
|
}
|
||||||
this.listeningTimeSinceSync = 0
|
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
|
this.failedProgressSyncs = 0
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error('Failed to update session progress', 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 => {
|
$axios.onRequest(config => {
|
||||||
if (!config.url) {
|
if (!config.url) {
|
||||||
console.error('Axios request invalid config', config)
|
console.error('Axios request invalid config', config)
|
||||||
|
|||||||
@@ -143,8 +143,10 @@ export {
|
|||||||
encode,
|
encode,
|
||||||
decode
|
decode
|
||||||
}
|
}
|
||||||
export default ({ app }, inject) => {
|
export default ({ app, store }, inject) => {
|
||||||
app.$decode = decode
|
app.$decode = decode
|
||||||
app.$encode = encode
|
app.$encode = encode
|
||||||
inject('isDev', process.env.NODE_ENV !== 'production')
|
inject('isDev', process.env.NODE_ENV !== 'production')
|
||||||
|
|
||||||
|
store.commit('setRouterBasePath', app.$config.routerBasePath)
|
||||||
}
|
}
|
||||||
+14
-5
@@ -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'}`
|
return `${hours} ${useFullNames ? `hour${hours === 1 ? '' : 's'}` : 'hr'} ${minutes} ${useFullNames ? `minute${minutes === 1 ? '' : 's'}` : 'min'}`
|
||||||
}
|
}
|
||||||
|
|
||||||
Vue.prototype.$secondsToTimestamp = (seconds) => {
|
Vue.prototype.$secondsToTimestamp = (seconds, includeMs = false, alwaysIncludeHours = false) => {
|
||||||
if (!seconds) return '0:00'
|
if (!seconds) {
|
||||||
|
return alwaysIncludeHours ? '00:00:00' : '0:00'
|
||||||
|
}
|
||||||
var _seconds = seconds
|
var _seconds = seconds
|
||||||
var _minutes = Math.floor(seconds / 60)
|
var _minutes = Math.floor(seconds / 60)
|
||||||
_seconds -= _minutes * 60
|
_seconds -= _minutes * 60
|
||||||
var _hours = Math.floor(_minutes / 60)
|
var _hours = Math.floor(_minutes / 60)
|
||||||
_minutes -= _hours * 60
|
_minutes -= _hours * 60
|
||||||
|
|
||||||
|
var ms = _seconds - Math.floor(seconds)
|
||||||
_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) => {
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+21
-5
@@ -14,6 +14,7 @@ export const state = () => ({
|
|||||||
selectedAuthor: null,
|
selectedAuthor: null,
|
||||||
isCasting: false, // Actively casting
|
isCasting: false, // Actively casting
|
||||||
isChromecastInitialized: false, // Script loaded
|
isChromecastInitialized: false, // Script loaded
|
||||||
|
showBatchQuickMatchModal: false,
|
||||||
dateFormats: [
|
dateFormats: [
|
||||||
{
|
{
|
||||||
text: 'MM/DD/YYYY',
|
text: 'MM/DD/YYYY',
|
||||||
@@ -31,7 +32,8 @@ export const state = () => ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const getters = {
|
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
|
if (!libraryItem) return placeholder
|
||||||
var media = libraryItem.media
|
var media = libraryItem.media
|
||||||
if (!media || !media.coverPath || media.coverPath === placeholder) return placeholder
|
if (!media || !media.coverPath || media.coverPath === placeholder) return placeholder
|
||||||
@@ -39,13 +41,24 @@ export const getters = {
|
|||||||
// Absolute URL covers (should no longer be used)
|
// Absolute URL covers (should no longer be used)
|
||||||
if (media.coverPath.startsWith('http:') || media.coverPath.startsWith('https:')) return media.coverPath
|
if (media.coverPath.startsWith('http:') || media.coverPath.startsWith('https:')) return media.coverPath
|
||||||
|
|
||||||
var userToken = rootGetters['user/getToken']
|
const userToken = rootGetters['user/getToken']
|
||||||
var lastUpdate = libraryItem.updatedAt || Date.now()
|
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
|
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 = 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${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}`
|
||||||
|
}
|
||||||
|
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,5 +113,8 @@ export const mutations = {
|
|||||||
},
|
},
|
||||||
setCasting(state, val) {
|
setCasting(state, val) {
|
||||||
state.isCasting = val
|
state.isCasting = val
|
||||||
|
},
|
||||||
|
setShowBatchQuickMatchModal(state, val) {
|
||||||
|
state.showBatchQuickMatchModal = val
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+16
-1
@@ -9,6 +9,8 @@ export const state = () => ({
|
|||||||
streamLibraryItem: null,
|
streamLibraryItem: null,
|
||||||
streamEpisodeId: null,
|
streamEpisodeId: null,
|
||||||
streamIsPlaying: false,
|
streamIsPlaying: false,
|
||||||
|
playerQueueItems: [],
|
||||||
|
playerQueueAutoPlay: true,
|
||||||
playerIsFullscreen: false,
|
playerIsFullscreen: false,
|
||||||
editModalTab: 'details',
|
editModalTab: 'details',
|
||||||
showEditModal: false,
|
showEditModal: false,
|
||||||
@@ -23,7 +25,8 @@ export const state = () => ({
|
|||||||
bookshelfBookIds: [],
|
bookshelfBookIds: [],
|
||||||
openModal: null,
|
openModal: null,
|
||||||
innerModalOpen: false,
|
innerModalOpen: false,
|
||||||
lastBookshelfScrollData: {}
|
lastBookshelfScrollData: {},
|
||||||
|
routerBasePath: '/'
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getters = {
|
export const getters = {
|
||||||
@@ -117,6 +120,9 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const mutations = {
|
export const mutations = {
|
||||||
|
setRouterBasePath(state, rbp) {
|
||||||
|
state.routerBasePath = rbp
|
||||||
|
},
|
||||||
setSource(state, source) {
|
setSource(state, source) {
|
||||||
state.Source = source
|
state.Source = source
|
||||||
},
|
},
|
||||||
@@ -144,14 +150,23 @@ export const mutations = {
|
|||||||
state.streamLibraryItem = null
|
state.streamLibraryItem = null
|
||||||
state.streamEpisodeId = null
|
state.streamEpisodeId = null
|
||||||
state.streamIsPlaying = false
|
state.streamIsPlaying = false
|
||||||
|
state.playerQueueItems = []
|
||||||
} else {
|
} else {
|
||||||
state.streamLibraryItem = payload.libraryItem
|
state.streamLibraryItem = payload.libraryItem
|
||||||
state.streamEpisodeId = payload.episodeId || null
|
state.streamEpisodeId = payload.episodeId || null
|
||||||
|
state.playerQueueItems = payload.queueItems || []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setIsPlaying(state, isPlaying) {
|
setIsPlaying(state, isPlaying) {
|
||||||
state.streamIsPlaying = isPlaying
|
state.streamIsPlaying = isPlaying
|
||||||
},
|
},
|
||||||
|
setPlayerQueueItems(state, items) {
|
||||||
|
state.playerQueueItems = items || []
|
||||||
|
},
|
||||||
|
setPlayerQueueAutoPlay(state, autoPlay) {
|
||||||
|
state.playerQueueAutoPlay = !!autoPlay
|
||||||
|
localStorage.setItem('playerQueueAutoPlay', !!autoPlay ? '1' : '0')
|
||||||
|
},
|
||||||
showEditModal(state, libraryItem) {
|
showEditModal(state, libraryItem) {
|
||||||
state.editModalTab = 'details'
|
state.editModalTab = 'details'
|
||||||
state.selectedLibraryItem = libraryItem
|
state.selectedLibraryItem = libraryItem
|
||||||
|
|||||||
@@ -10,12 +10,48 @@ export const state = () => ({
|
|||||||
value: 'openlibrary'
|
value: 'openlibrary'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Audible',
|
text: 'iTunes',
|
||||||
|
value: 'itunes'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Audible.com',
|
||||||
value: 'audible'
|
value: 'audible'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'iTunes',
|
text: 'Audible.ca',
|
||||||
value: 'itunes'
|
value: 'audible.ca'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Audible.co.uk',
|
||||||
|
value: 'audible.uk'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Audible.co.au',
|
||||||
|
value: 'audible.au'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Audible.fr',
|
||||||
|
value: 'audible.fr'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Audible.de',
|
||||||
|
value: 'audible.de'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Audible.co.jp',
|
||||||
|
value: 'audible.jp'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Audible.it',
|
||||||
|
value: 'audible.it'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Audible.co.in',
|
||||||
|
value: 'audible.in'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Audible.es',
|
||||||
|
value: 'audible.es'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
podcastProviders: [
|
podcastProviders: [
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ module.exports = {
|
|||||||
safelist: [
|
safelist: [
|
||||||
'bg-success',
|
'bg-success',
|
||||||
'bg-red-600',
|
'bg-red-600',
|
||||||
|
'bg-yellow-400',
|
||||||
'text-green-500',
|
'text-green-500',
|
||||||
'py-1.5',
|
'py-1.5',
|
||||||
'bg-info',
|
'bg-info',
|
||||||
@@ -18,7 +19,8 @@ module.exports = {
|
|||||||
'min-w-5',
|
'min-w-5',
|
||||||
'w-3.5',
|
'w-3.5',
|
||||||
'h-3.5',
|
'h-3.5',
|
||||||
'border-warning'
|
'border-warning',
|
||||||
|
'mb-px'
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
theme: {
|
theme: {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ if (isDev) {
|
|||||||
process.env.FFMPEG_PATH = devEnv.FFmpegPath
|
process.env.FFMPEG_PATH = devEnv.FFmpegPath
|
||||||
process.env.FFPROBE_PATH = devEnv.FFProbePath
|
process.env.FFPROBE_PATH = devEnv.FFProbePath
|
||||||
process.env.SOURCE = 'local'
|
process.env.SOURCE = 'local'
|
||||||
|
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const PORT = process.env.PORT || 80
|
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 UID = process.env.AUDIOBOOKSHELF_UID || 99
|
||||||
const GID = process.env.AUDIOBOOKSHELF_GID || 100
|
const GID = process.env.AUDIOBOOKSHELF_GID || 100
|
||||||
const SOURCE = process.env.SOURCE || 'docker'
|
const SOURCE = process.env.SOURCE || 'docker'
|
||||||
|
const ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH || ''
|
||||||
|
|
||||||
console.log('Config', CONFIG_PATH, METADATA_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()
|
Server.start()
|
||||||
|
|||||||
Generated
+13
-2
@@ -1,18 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.1.4",
|
"version": "2.2.1",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.1.2",
|
"version": "2.2.1",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^0.26.1",
|
"axios": "^0.26.1",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
"graceful-fs": "^4.2.10",
|
"graceful-fs": "^4.2.10",
|
||||||
"htmlparser2": "^8.0.1",
|
"htmlparser2": "^8.0.1",
|
||||||
|
"node-tone": "^1.0.1",
|
||||||
"socket.io": "^4.4.1",
|
"socket.io": "^4.4.1",
|
||||||
"xml2js": "^0.4.23"
|
"xml2js": "^0.4.23"
|
||||||
},
|
},
|
||||||
@@ -594,6 +595,11 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="
|
"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": {
|
"object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.1.4",
|
"version": "2.2.1",
|
||||||
"description": "Self-hosted audiobook and podcast server",
|
"description": "Self-hosted audiobook and podcast server",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
"graceful-fs": "^4.2.10",
|
"graceful-fs": "^4.2.10",
|
||||||
"htmlparser2": "^8.0.1",
|
"htmlparser2": "^8.0.1",
|
||||||
|
"node-tone": "^1.0.1",
|
||||||
"socket.io": "^4.4.1",
|
"socket.io": "^4.4.1",
|
||||||
"xml2js": "^0.4.23"
|
"xml2js": "^0.4.23"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ const commandLineArgs = require('./server/libs/commandLineArgs')
|
|||||||
const options = commandLineArgs(optionDefinitions)
|
const options = commandLineArgs(optionDefinitions)
|
||||||
|
|
||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
if (process.env.TOKEN_SECRET == null) process.env.TOKEN_SECRET = '09f26e402586e2faa8da4c98a35f1b20d6b033c6097befa8be3486a829587fe2f90a832bd3ff9d42710a4da095a2ce285b009f0c3730cd9b8e1af3eb84df6611'
|
|
||||||
process.env.NODE_ENV = 'production'
|
process.env.NODE_ENV = 'production'
|
||||||
|
|
||||||
const server = require('./server/Server')
|
const server = require('./server/Server')
|
||||||
@@ -27,8 +26,9 @@ const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve
|
|||||||
const UID = 99
|
const UID = 99
|
||||||
const GID = 100
|
const GID = 100
|
||||||
const SOURCE = options.source || 'debian'
|
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)
|
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()
|
Server.start()
|
||||||
|
|||||||
+11
-1
@@ -9,8 +9,8 @@ const Library = require('./objects/Library')
|
|||||||
const Author = require('./objects/entities/Author')
|
const Author = require('./objects/entities/Author')
|
||||||
const Series = require('./objects/entities/Series')
|
const Series = require('./objects/entities/Series')
|
||||||
const ServerSettings = require('./objects/settings/ServerSettings')
|
const ServerSettings = require('./objects/settings/ServerSettings')
|
||||||
|
const NotificationSettings = require('./objects/settings/NotificationSettings')
|
||||||
const PlaybackSession = require('./objects/PlaybackSession')
|
const PlaybackSession = require('./objects/PlaybackSession')
|
||||||
const Feed = require('./objects/Feed')
|
|
||||||
|
|
||||||
class Db {
|
class Db {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -43,6 +43,7 @@ class Db {
|
|||||||
this.series = []
|
this.series = []
|
||||||
|
|
||||||
this.serverSettings = null
|
this.serverSettings = null
|
||||||
|
this.notificationSettings = null
|
||||||
|
|
||||||
// Stores previous version only if upgraded
|
// Stores previous version only if upgraded
|
||||||
this.previousVersion = null
|
this.previousVersion = null
|
||||||
@@ -125,6 +126,10 @@ class Db {
|
|||||||
this.serverSettings = new ServerSettings()
|
this.serverSettings = new ServerSettings()
|
||||||
await this.insertEntity('settings', this.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()
|
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) => {
|
var p5 = this.collectionsDb.select(() => true).then((results) => {
|
||||||
|
|||||||
+65
-60
@@ -23,6 +23,7 @@ const ApiRouter = require('./routers/ApiRouter')
|
|||||||
const HlsRouter = require('./routers/HlsRouter')
|
const HlsRouter = require('./routers/HlsRouter')
|
||||||
const StaticRouter = require('./routers/StaticRouter')
|
const StaticRouter = require('./routers/StaticRouter')
|
||||||
|
|
||||||
|
const NotificationManager = require('./managers/NotificationManager')
|
||||||
const CoverManager = require('./managers/CoverManager')
|
const CoverManager = require('./managers/CoverManager')
|
||||||
const AbMergeManager = require('./managers/AbMergeManager')
|
const AbMergeManager = require('./managers/AbMergeManager')
|
||||||
const CacheManager = require('./managers/CacheManager')
|
const CacheManager = require('./managers/CacheManager')
|
||||||
@@ -33,9 +34,10 @@ const PodcastManager = require('./managers/PodcastManager')
|
|||||||
const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
|
const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
|
||||||
const RssFeedManager = require('./managers/RssFeedManager')
|
const RssFeedManager = require('./managers/RssFeedManager')
|
||||||
const CronManager = require('./managers/CronManager')
|
const CronManager = require('./managers/CronManager')
|
||||||
|
const TaskManager = require('./managers/TaskManager')
|
||||||
|
|
||||||
class Server {
|
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.Port = PORT
|
||||||
this.Host = HOST
|
this.Host = HOST
|
||||||
global.Source = SOURCE
|
global.Source = SOURCE
|
||||||
@@ -43,6 +45,7 @@ class Server {
|
|||||||
global.Gid = isNaN(GID) ? 0 : Number(GID)
|
global.Gid = isNaN(GID) ? 0 : Number(GID)
|
||||||
global.ConfigPath = Path.normalize(CONFIG_PATH)
|
global.ConfigPath = Path.normalize(CONFIG_PATH)
|
||||||
global.MetadataPath = Path.normalize(METADATA_PATH)
|
global.MetadataPath = Path.normalize(METADATA_PATH)
|
||||||
|
global.RouterBasePath = ROUTER_BASE_PATH
|
||||||
|
|
||||||
// Fix backslash if not on Windows
|
// Fix backslash if not on Windows
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32') {
|
||||||
@@ -64,21 +67,23 @@ class Server {
|
|||||||
this.auth = new Auth(this.db)
|
this.auth = new Auth(this.db)
|
||||||
|
|
||||||
// Managers
|
// 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.backupManager = new BackupManager(this.db, this.emitter.bind(this))
|
||||||
this.logManager = new LogManager(this.db)
|
this.logManager = new LogManager(this.db)
|
||||||
this.cacheManager = new CacheManager()
|
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.playbackSessionManager = new PlaybackSessionManager(this.db, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||||
this.coverManager = new CoverManager(this.db, this.cacheManager)
|
this.coverManager = new CoverManager(this.db, this.cacheManager)
|
||||||
this.podcastManager = new PodcastManager(this.db, this.watcher, this.emitter.bind(this))
|
this.podcastManager = new PodcastManager(this.db, this.watcher, this.emitter.bind(this), this.notificationManager)
|
||||||
this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.emitter.bind(this), this.clientEmitter.bind(this))
|
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.rssFeedManager = new RssFeedManager(this.db, this.emitter.bind(this))
|
||||||
|
|
||||||
this.scanner = new Scanner(this.db, this.coverManager, 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)
|
this.cronManager = new CronManager(this.db, this.scanner, this.podcastManager)
|
||||||
|
|
||||||
// Routers
|
// 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.hlsRouter = new HlsRouter(this.db, this.auth, this.playbackSessionManager, this.emitter.bind(this))
|
||||||
this.staticRouter = new StaticRouter(this.db)
|
this.staticRouter = new StaticRouter(this.db)
|
||||||
|
|
||||||
@@ -124,7 +129,6 @@ class Server {
|
|||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
Logger.info('[Server] Init v' + version)
|
Logger.info('[Server] Init v' + version)
|
||||||
await this.abMergeManager.removeOrphanDownloads()
|
|
||||||
await this.playbackSessionManager.removeOrphanStreams()
|
await this.playbackSessionManager.removeOrphanStreams()
|
||||||
|
|
||||||
var previousVersion = await this.db.checkPreviousVersion() // Returns null if same server version
|
var previousVersion = await this.db.checkPreviousVersion() // Returns null if same server version
|
||||||
@@ -143,7 +147,7 @@ class Server {
|
|||||||
await this.auth.initTokenSecret()
|
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.purgeMetadata() // Remove metadata folders without library item
|
||||||
await this.playbackSessionManager.removeInvalidSessions()
|
await this.playbackSessionManager.removeInvalidSessions()
|
||||||
await this.cacheManager.ensureCachePaths()
|
await this.cacheManager.ensureCachePaths()
|
||||||
@@ -168,29 +172,32 @@ class Server {
|
|||||||
await this.init()
|
await this.init()
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
|
const router = express.Router()
|
||||||
|
app.use(global.RouterBasePath, router)
|
||||||
|
|
||||||
this.server = http.createServer(app)
|
this.server = http.createServer(app)
|
||||||
|
|
||||||
app.use(this.auth.cors)
|
router.use(this.auth.cors)
|
||||||
app.use(fileUpload())
|
router.use(fileUpload())
|
||||||
app.use(express.urlencoded({ extended: true, limit: "5mb" }));
|
router.use(express.urlencoded({ extended: true, limit: "5mb" }));
|
||||||
app.use(express.json({ limit: "5mb" }))
|
router.use(express.json({ limit: "5mb" }))
|
||||||
|
|
||||||
// Static path to generated nuxt
|
// Static path to generated nuxt
|
||||||
const distPath = Path.join(global.appRoot, '/client/dist')
|
const distPath = Path.join(global.appRoot, '/client/dist')
|
||||||
app.use(express.static(distPath))
|
router.use(express.static(distPath))
|
||||||
|
|
||||||
// Metadata folder static path
|
// 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
|
// 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)
|
router.use('/api', this.authMiddleware.bind(this), this.apiRouter.router)
|
||||||
app.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router)
|
router.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router)
|
||||||
app.use('/s', this.authMiddleware.bind(this), this.staticRouter.router)
|
router.use('/s', this.authMiddleware.bind(this), this.staticRouter.router)
|
||||||
|
|
||||||
// EBook static file routes
|
// 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)
|
var library = this.db.libraries.find(lib => lib.id === req.params.library)
|
||||||
if (!library) return res.sendStatus(404)
|
if (!library) return res.sendStatus(404)
|
||||||
var folder = library.folders.find(fol => fol.id === req.params.folder)
|
var folder = library.folders.find(fol => fol.id === req.params.folder)
|
||||||
@@ -202,14 +209,14 @@ class Server {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// RSS Feed temp route
|
// 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}`)
|
Logger.info(`[Server] Requesting rss feed ${req.params.id}`)
|
||||||
this.rssFeedManager.getFeed(req, res)
|
this.rssFeedManager.getFeed(req, res)
|
||||||
})
|
})
|
||||||
app.get('/feed/:id/cover', (req, res) => {
|
router.get('/feed/:id/cover', (req, res) => {
|
||||||
this.rssFeedManager.getFeedCover(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}`)
|
Logger.debug(`[Server] Requesting rss feed episode ${req.params.id}/${req.params.episodeId}`)
|
||||||
this.rssFeedManager.getFeedItem(req, res)
|
this.rssFeedManager.getFeedItem(req, res)
|
||||||
})
|
})
|
||||||
@@ -217,31 +224,33 @@ class Server {
|
|||||||
// Client dynamic routes
|
// Client dynamic routes
|
||||||
const dyanimicRoutes = [
|
const dyanimicRoutes = [
|
||||||
'/item/:id',
|
'/item/:id',
|
||||||
'/item/:id/manage',
|
|
||||||
'/author/:id',
|
'/author/:id',
|
||||||
'/audiobook/:id/chapters',
|
'/audiobook/:id/chapters',
|
||||||
'/audiobook/:id/edit',
|
'/audiobook/:id/edit',
|
||||||
|
'/audiobook/:id/manage',
|
||||||
'/library/:library',
|
'/library/:library',
|
||||||
'/library/:library/search',
|
'/library/:library/search',
|
||||||
'/library/:library/bookshelf/:id?',
|
'/library/:library/bookshelf/:id?',
|
||||||
'/library/:library/authors',
|
'/library/:library/authors',
|
||||||
'/library/:library/series/:id?',
|
'/library/:library/series/:id?',
|
||||||
|
'/library/:library/podcast/search',
|
||||||
|
'/library/:library/podcast/latest',
|
||||||
'/config/users/:id',
|
'/config/users/:id',
|
||||||
'/config/users/:id/sessions',
|
'/config/users/:id/sessions',
|
||||||
'/collection/:id'
|
'/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))
|
router.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))
|
router.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
|
||||||
app.post('/init', (req, res) => {
|
router.post('/init', (req, res) => {
|
||||||
if (this.db.hasRootUser) {
|
if (this.db.hasRootUser) {
|
||||||
Logger.error(`[Server] attempt to init server when server already has a root user`)
|
Logger.error(`[Server] attempt to init server when server already has a root user`)
|
||||||
return res.sendStatus(500)
|
return res.sendStatus(500)
|
||||||
}
|
}
|
||||||
this.initializeServer(req, res)
|
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
|
// status check for client to see if server has been initialized
|
||||||
// server has been initialized if a root user exists
|
// server has been initialized if a root user exists
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -253,7 +262,7 @@ class Server {
|
|||||||
}
|
}
|
||||||
res.json(payload)
|
res.json(payload)
|
||||||
})
|
})
|
||||||
app.get('/ping', (req, res) => {
|
router.get('/ping', (req, res) => {
|
||||||
Logger.info('Received ping')
|
Logger.info('Received ping')
|
||||||
res.json({ success: true })
|
res.json({ success: true })
|
||||||
})
|
})
|
||||||
@@ -364,21 +373,37 @@ class Server {
|
|||||||
return purged
|
return purged
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove user media progress entries that dont have a library item
|
// Remove user media progress with items that no longer exist & remove seriesHideFrom that no longer exist
|
||||||
// TODO: Check podcast episode exists still
|
async cleanUserData() {
|
||||||
async checkUserMediaProgress() {
|
|
||||||
for (let i = 0; i < this.db.users.length; i++) {
|
for (let i = 0; i < this.db.users.length; i++) {
|
||||||
var _user = this.db.users[i]
|
var _user = this.db.users[i]
|
||||||
if (_user.mediaProgress) {
|
var hasUpdated = false
|
||||||
var itemProgressIdsToRemove = _user.mediaProgress.map(lip => lip.id).filter(lipId => !this.db.libraryItems.find(_li => _li.id == lipId))
|
if (_user.mediaProgress.length) {
|
||||||
if (itemProgressIdsToRemove.length) {
|
const lengthBefore = _user.mediaProgress.length
|
||||||
Logger.debug(`[Server] Found ${itemProgressIdsToRemove.length} media progress data to remove from user ${_user.username}`)
|
_user.mediaProgress = _user.mediaProgress.filter(mp => {
|
||||||
for (const lipId of itemProgressIdsToRemove) {
|
const libraryItem = this.db.libraryItems.find(li => li.id === mp.libraryItemId)
|
||||||
_user.removeMediaProgress(lipId)
|
if (!libraryItem) return false
|
||||||
}
|
if (mp.episodeId && (libraryItem.mediaType !== 'podcast' || !libraryItem.media.checkHasEpisode(mp.episodeId))) return false // Episode not found
|
||||||
await this.db.updateEntity('user', _user)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,26 +465,7 @@ class Server {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user has session open
|
Logger.debug(`[Server] User Online ${client.user.username}`)
|
||||||
var session = this.playbackSessionManager.getUserSession(user.id)
|
|
||||||
if (session) {
|
|
||||||
Logger.debug(`[Server] User Online "${client.user.username}" with session open "${session.id}"`)
|
|
||||||
var sessionLibraryItem = this.db.libraryItems.find(li => li.id === session.libraryItemId)
|
|
||||||
if (!sessionLibraryItem) {
|
|
||||||
Logger.error(`[Server] Library Item for session "${session.id}" does not exist "${session.libraryItemId}"`)
|
|
||||||
this.playbackSessionManager.removeSession(session.id)
|
|
||||||
session = null
|
|
||||||
} else if (session.mediaType === 'podcast' && !sessionLibraryItem.media.checkHasEpisode(session.episodeId)) {
|
|
||||||
Logger.error(`[Server] Library Item for session "${session.id}" episode ${session.episodeId} does not exist "${session.libraryItemId}"`)
|
|
||||||
this.playbackSessionManager.removeSession(session.id)
|
|
||||||
session = null
|
|
||||||
}
|
|
||||||
if (session) {
|
|
||||||
session = session.toJSONForClient(sessionLibraryItem)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Logger.debug(`[Server] User Online ${client.user.username}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.io.emit('user_online', client.user.toJSONForPublic(this.playbackSessionManager.sessions, this.db.libraryItems))
|
this.io.emit('user_online', client.user.toJSONForPublic(this.playbackSessionManager.sessions, this.db.libraryItems))
|
||||||
|
|
||||||
@@ -470,7 +476,6 @@ class Server {
|
|||||||
metadataPath: global.MetadataPath,
|
metadataPath: global.MetadataPath,
|
||||||
configPath: global.ConfigPath,
|
configPath: global.ConfigPath,
|
||||||
user: client.user.toJSONForBrowser(),
|
user: client.user.toJSONForBrowser(),
|
||||||
session,
|
|
||||||
librariesScanning: this.scanner.librariesScanning,
|
librariesScanning: this.scanner.librariesScanning,
|
||||||
backups: (this.backupManager.backups || []).map(b => b.toJSON())
|
backups: (this.backupManager.backups || []).map(b => b.toJSON())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class AuthorController {
|
|||||||
constructor() { }
|
constructor() { }
|
||||||
|
|
||||||
async findOne(req, res) {
|
async findOne(req, res) {
|
||||||
|
const libraryId = req.query.library
|
||||||
const include = (req.query.include || '').split(',')
|
const include = (req.query.include || '').split(',')
|
||||||
|
|
||||||
const authorJson = req.author.toJSON()
|
const authorJson = req.author.toJSON()
|
||||||
@@ -16,6 +17,7 @@ class AuthorController {
|
|||||||
// Used on author landing page to include library items and items grouped in series
|
// Used on author landing page to include library items and items grouped in series
|
||||||
if (include.includes('items')) {
|
if (include.includes('items')) {
|
||||||
authorJson.libraryItems = this.db.libraryItems.filter(li => {
|
authorJson.libraryItems = this.db.libraryItems.filter(li => {
|
||||||
|
if (libraryId && li.libraryId !== libraryId) return false
|
||||||
if (!req.user.checkCanAccessLibraryItem(li)) return false // filter out library items user cannot access
|
if (!req.user.checkCanAccessLibraryItem(li)) return false // filter out library items user cannot access
|
||||||
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
|
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,18 +24,11 @@ class CollectionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
findOne(req, res) {
|
findOne(req, res) {
|
||||||
var collection = this.db.collections.find(c => c.id === req.params.id)
|
res.json(req.collection.toJSONExpanded(this.db.libraryItems))
|
||||||
if (!collection) {
|
|
||||||
return res.status(404).send('Collection not found')
|
|
||||||
}
|
|
||||||
res.json(collection.toJSONExpanded(this.db.libraryItems))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(req, res) {
|
async update(req, res) {
|
||||||
var collection = this.db.collections.find(c => c.id === req.params.id)
|
const collection = req.collection
|
||||||
if (!collection) {
|
|
||||||
return res.status(404).send('Collection not found')
|
|
||||||
}
|
|
||||||
var wasUpdated = collection.update(req.body)
|
var wasUpdated = collection.update(req.body)
|
||||||
var jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
|
var jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
|
||||||
if (wasUpdated) {
|
if (wasUpdated) {
|
||||||
@@ -46,10 +39,7 @@ class CollectionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async delete(req, res) {
|
async delete(req, res) {
|
||||||
var collection = this.db.collections.find(c => c.id === req.params.id)
|
const collection = req.collection
|
||||||
if (!collection) {
|
|
||||||
return res.status(404).send('Collection not found')
|
|
||||||
}
|
|
||||||
var jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
|
var jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
|
||||||
await this.db.removeEntity('collection', collection.id)
|
await this.db.removeEntity('collection', collection.id)
|
||||||
this.emitter('collection_removed', jsonExpanded)
|
this.emitter('collection_removed', jsonExpanded)
|
||||||
@@ -57,10 +47,7 @@ class CollectionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async addBook(req, res) {
|
async addBook(req, res) {
|
||||||
var collection = this.db.collections.find(c => c.id === req.params.id)
|
const collection = req.collection
|
||||||
if (!collection) {
|
|
||||||
return res.status(404).send('Collection not found')
|
|
||||||
}
|
|
||||||
var libraryItem = this.db.libraryItems.find(li => li.id === req.body.id)
|
var libraryItem = this.db.libraryItems.find(li => li.id === req.body.id)
|
||||||
if (!libraryItem) {
|
if (!libraryItem) {
|
||||||
return res.status(500).send('Book not found')
|
return res.status(500).send('Book not found')
|
||||||
@@ -80,11 +67,7 @@ class CollectionController {
|
|||||||
|
|
||||||
// DELETE: api/collections/:id/book/:bookId
|
// DELETE: api/collections/:id/book/:bookId
|
||||||
async removeBook(req, res) {
|
async removeBook(req, res) {
|
||||||
var collection = this.db.collections.find(c => c.id === req.params.id)
|
const collection = req.collection
|
||||||
if (!collection) {
|
|
||||||
return res.status(404).send('Collection not found')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (collection.books.includes(req.params.bookId)) {
|
if (collection.books.includes(req.params.bookId)) {
|
||||||
collection.removeBook(req.params.bookId)
|
collection.removeBook(req.params.bookId)
|
||||||
var jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
|
var jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
|
||||||
@@ -96,10 +79,7 @@ class CollectionController {
|
|||||||
|
|
||||||
// POST: api/collections/:id/batch/add
|
// POST: api/collections/:id/batch/add
|
||||||
async addBatch(req, res) {
|
async addBatch(req, res) {
|
||||||
var collection = this.db.collections.find(c => c.id === req.params.id)
|
const collection = req.collection
|
||||||
if (!collection) {
|
|
||||||
return res.status(404).send('Collection not found')
|
|
||||||
}
|
|
||||||
if (!req.body.books || !req.body.books.length) {
|
if (!req.body.books || !req.body.books.length) {
|
||||||
return res.status(500).send('Invalid request body')
|
return res.status(500).send('Invalid request body')
|
||||||
}
|
}
|
||||||
@@ -120,10 +100,7 @@ class CollectionController {
|
|||||||
|
|
||||||
// POST: api/collections/:id/batch/remove
|
// POST: api/collections/:id/batch/remove
|
||||||
async removeBatch(req, res) {
|
async removeBatch(req, res) {
|
||||||
var collection = this.db.collections.find(c => c.id === req.params.id)
|
const collection = req.collection
|
||||||
if (!collection) {
|
|
||||||
return res.status(404).send('Collection not found')
|
|
||||||
}
|
|
||||||
if (!req.body.books || !req.body.books.length) {
|
if (!req.body.books || !req.body.books.length) {
|
||||||
return res.status(500).send('Invalid request body')
|
return res.status(500).send('Invalid request body')
|
||||||
}
|
}
|
||||||
@@ -141,5 +118,25 @@ class CollectionController {
|
|||||||
}
|
}
|
||||||
res.json(collection.toJSONExpanded(this.db.libraryItems))
|
res.json(collection.toJSONExpanded(this.db.libraryItems))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
middleware(req, res, next) {
|
||||||
|
if (req.params.id) {
|
||||||
|
var collection = this.db.collections.find(c => c.id === req.params.id)
|
||||||
|
if (!collection) {
|
||||||
|
return res.status(404).send('Collection not found')
|
||||||
|
}
|
||||||
|
req.collection = collection
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method == 'DELETE' && !req.user.canDelete) {
|
||||||
|
Logger.warn(`[CollectionController] User attempted to delete without permission`, req.user.username)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
} else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) {
|
||||||
|
Logger.warn('[CollectionController] User attempted to update without permission', req.user.username)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
module.exports = new CollectionController()
|
module.exports = new CollectionController()
|
||||||
@@ -165,6 +165,7 @@ class LibraryController {
|
|||||||
if (payload.filterBy) {
|
if (payload.filterBy) {
|
||||||
// If filtering by series, will include seriesName and seriesSequence on media metadata
|
// If filtering by series, will include seriesName and seriesSequence on media metadata
|
||||||
filterSeries = (payload.mediaType == 'book' && payload.filterBy.startsWith('series.')) ? libraryHelpers.decode(payload.filterBy.replace('series.', '')) : null
|
filterSeries = (payload.mediaType == 'book' && payload.filterBy.startsWith('series.')) ? libraryHelpers.decode(payload.filterBy.replace('series.', '')) : null
|
||||||
|
if (filterSeries === 'No Series') filterSeries = null
|
||||||
|
|
||||||
libraryItems = libraryHelpers.getFilteredLibraryItems(libraryItems, payload.filterBy, req.user, this.rssFeedManager.feedsArray)
|
libraryItems = libraryHelpers.getFilteredLibraryItems(libraryItems, payload.filterBy, req.user, this.rssFeedManager.feedsArray)
|
||||||
payload.total = libraryItems.length
|
payload.total = libraryItems.length
|
||||||
@@ -484,7 +485,7 @@ class LibraryController {
|
|||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET: api/scan (Root)
|
// GET: api/libraries/:id/scan
|
||||||
async scan(req, res) {
|
async scan(req, res) {
|
||||||
if (!req.user.isAdminOrUp) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error(`[LibraryController] Non-root user attempted to scan library`, req.user)
|
Logger.error(`[LibraryController] Non-root user attempted to scan library`, req.user)
|
||||||
@@ -498,6 +499,45 @@ class LibraryController {
|
|||||||
Logger.info('[LibraryController] Scan complete')
|
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) {
|
middleware(req, res, next) {
|
||||||
if (!req.user.checkCanAccessLibrary(req.params.id)) {
|
if (!req.user.checkCanAccessLibrary(req.params.id)) {
|
||||||
Logger.warn(`[LibraryController] Library ${req.params.id} not accessible to user ${req.user.username}`)
|
Logger.warn(`[LibraryController] Library ${req.params.id} not accessible to user ${req.user.username}`)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user